use std::collections::{BTreeSet, HashSet};
use std::fs;
use std::io;
use std::path::{Component, Path, PathBuf};
use onelf_sysroot::path::{entry_in_root, in_root};
use onelf_sysroot::platform::{self, Pin};
use onelf_sysroot::prune::prune;
use onelf_sysroot::{Database, PlatformLine, Policy, Trace};
use super::elf::parse_needed;
use super::ui::color;
#[derive(Debug, Clone, Default)]
pub struct SysrootOptions {
pub root: PathBuf,
pub command: String,
pub platform: String,
pub optional: Vec<String>,
pub platform_line: Option<PathBuf>,
pub policy: Option<PathBuf>,
pub trace: Option<PathBuf>,
pub keep: Option<PathBuf>,
pub platform_url: Option<String>,
pub platform_hash: Option<String>,
pub sets: Vec<SharedSet>,
}
#[derive(Debug, Clone)]
pub struct SharedSet {
pub name: String,
pub packages: Vec<String>,
pub url: String,
pub blake3: String,
}
#[derive(Debug, Default)]
pub struct SysrootReport {
pub packages: Vec<(String, String)>,
pub unsatisfied: Vec<(String, String)>,
pub host_provided: Vec<String>,
pub removed_platform: usize,
pub removed_policy: usize,
pub removed_trace: usize,
pub copied: usize,
pub absent: usize,
pub host_packages: Vec<String>,
pub host_files: HashSet<String>,
pub pin: Option<Pin>,
pub sets: Vec<(String, Vec<String>)>,
pub set_sonames: HashSet<String>,
}
pub fn populate(appdir: &Path, opts: &SysrootOptions) -> io::Result<(SysrootReport, PlatformLine)> {
let root = &opts.root;
refuse_inside(root, appdir)?;
let db = Database::read(root)?;
let command = opts
.command
.trim_start_matches("./")
.trim_start_matches('/');
let candidates = [format!("usr/{command}"), command.to_string()];
let owner = candidates.iter().find_map(|c| db.owner(c)).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!(
"{}: no package in the sysroot owns usr/{command} or {command}",
root.display()
),
)
})?;
if let Some(name) = opts.optional.iter().find(|n| db.satisfier(n).is_none()) {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"{}: optional {name}: no package in the sysroot provides it",
root.display()
),
));
}
let platform = match &opts.platform_line {
Some(p) => PlatformLine::load(p)?,
None => PlatformLine::from_prefixes(
onelf_format::drivers::DRIVER_FAMILIES
.iter()
.map(|s| s.to_string())
.collect(),
),
};
let full = db.closure(&owner.name, &opts.optional);
let host_packages: std::collections::BTreeSet<String> = full
.packages
.iter()
.filter(|n| *n != &owner.name)
.filter_map(|n| db.package(n))
.filter(|p| {
p.files
.iter()
.any(|f| onelf_sysroot::is_top_level_lib_object(f) && platform.matches(f))
})
.map(|p| p.name.clone())
.collect();
let mut set_packages: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut set_report: Vec<(String, Vec<String>)> = Vec::new();
for set in &opts.sets {
check_set_name(&set.name)?;
let Some((first, rest)) = set.packages.split_first() else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("set {}: names no packages", set.name),
));
};
for name in &set.packages {
if db.satisfier(name).is_none() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"set {}: no package in the sysroot provides {name}",
set.name
),
));
}
}
if set.packages.iter().any(|p| p == &owner.name) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"set {}: names the application's own package {}",
set.name, owner.name
),
));
}
platform::check_url(&set.url)
.and_then(|()| platform::check_hash(&set.blake3))
.map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("set {}: {e}", set.name),
)
})?;
let names: Vec<String> = db
.closure_excluding(first, rest, &host_packages)
.packages
.into_iter()
.filter(|n| n != "glibc" && n != &owner.name)
.collect();
set_packages.extend(names.iter().cloned());
set_report.push((set.name.clone(), names));
}
let left_out: std::collections::BTreeSet<String> = host_packages
.iter()
.chain(set_packages.iter())
.cloned()
.collect();
let host_files: HashSet<String> = left_out
.iter()
.filter_map(|n| db.package(n))
.flat_map(|p| p.files.iter().cloned())
.collect();
let mut set_sonames: HashSet<String> = HashSet::new();
for set in &opts.sets {
match shipped_sonames(set) {
Ok(shipped) => set_sonames.extend(shipped),
Err(why) => {
eprintln!(
" {} set {}: {why}, so what it holds is inferred from the sysroot",
color::bold("note:"),
set.name
);
set_sonames.extend(
db.closure_excluding(&set.packages[0], &set.packages[1..], &host_packages)
.packages
.iter()
.filter_map(|n| db.package(n))
.flat_map(|p| p.files.iter())
.filter(|f| onelf_sysroot::is_top_level_lib_object(f))
.filter_map(|f| f.rsplit('/').next().map(String::from)),
);
}
}
}
let closure = db.closure_excluding(&owner.name, &opts.optional, &left_out);
let files = db.files_of(&closure);
let policy = opts.policy.as_deref().map(Policy::load).transpose()?;
let trace = opts.trace.as_deref().map(Trace::load).transpose()?;
let keep = opts.keep.as_deref().map(Policy::load).transpose()?;
let mut keep_names: HashSet<String> = HashSet::new();
let pruned = loop {
let pruned = prune(
&files,
Some(&platform),
policy.as_ref(),
trace.as_ref(),
&keep_names,
keep.as_ref(),
);
if trace.is_none() {
break pruned;
}
let before = keep_names.len();
for rel in &pruned.kept {
if let Some(target) = link_target(root, rel) {
keep_names.insert(target);
}
let Ok(path) = in_root(root, Path::new(rel)) else {
continue;
};
if is_elf(&path)
&& let Ok(needed) = parse_needed(&path)
{
keep_names.extend(needed);
}
}
let kept: HashSet<&str> = pruned.kept.iter().map(String::as_str).collect();
let kept_dirs: HashSet<&str> = pruned
.kept
.iter()
.flat_map(|rel| Path::new(rel).ancestors().skip(1))
.filter_map(Path::to_str)
.collect();
for rel in &files {
if !kept.contains(rel.as_str())
&& link_target(root, rel)
.is_some_and(|t| kept.contains(t.as_str()) || kept_dirs.contains(t.as_str()))
{
keep_names.insert(rel.clone());
}
}
if keep_names.len() == before {
break pruned;
}
};
remove_generated(appdir)?;
let mut generated: Vec<String> = Vec::new();
let mut host_provided = pruned.host_provided.clone();
let mut removed_platform = pruned.removed_platform;
for rel in host_packages
.iter()
.filter_map(|n| db.package(n))
.flat_map(|p| p.files.iter())
.filter(|f| platform.matches(f))
{
removed_platform += 1;
host_provided.push(rel.rsplit('/').next().unwrap_or(rel).to_string());
}
host_provided.sort();
host_provided.dedup();
let mut report = SysrootReport {
packages: closure
.packages
.iter()
.filter_map(|n| db.package(n))
.map(|p| (p.name.clone(), p.version.clone()))
.collect(),
unsatisfied: closure.unsatisfied.clone(),
host_provided,
removed_platform,
removed_policy: pruned.removed_policy,
removed_trace: pruned.removed_trace,
host_packages: host_packages.into_iter().collect(),
host_files,
sets: set_report,
set_sonames,
..Default::default()
};
for rel in &pruned.kept {
match copy_entry(root, rel, appdir)? {
Some(dest) => {
report.copied += 1;
generated.push(dest.to_string_lossy().into_owned());
}
None => report.absent += 1,
}
}
let caches = copy_generated_caches(root, appdir)?;
report.copied += caches.len();
generated.extend(caches);
let links = link_sonames(appdir)?;
report.copied += links.len();
generated.extend(links);
record_generated(appdir, generated)?;
let record = appdir.join(PROVENANCE_FILE);
if let Some(parent) = record.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&record, render_provenance(&opts.platform, &report.packages))?;
super::normalize_mtime(&record);
report.pin = resolve_pin(opts)?;
let pin_path = appdir.join(PLATFORM_FILE);
match &report.pin {
Some(pin) => {
fs::write(&pin_path, render_pin(pin))?;
super::normalize_mtime(&pin_path);
}
None => {
let _ = fs::remove_file(&pin_path);
}
}
let sets_path = appdir.join(SETS_FILE);
if opts.sets.is_empty() {
let _ = fs::remove_file(&sets_path);
} else {
fs::write(&sets_path, render_sets(&opts.sets, &report.sets))?;
super::normalize_mtime(&sets_path);
}
Ok((report, platform))
}
pub fn record_sets(appdir: &Path, sets: &[SharedSet], pin: Option<&Pin>) -> io::Result<()> {
let records = [
(
SETS_FILE,
(!sets.is_empty()).then(|| render_sets(sets, &[])),
),
(PLATFORM_FILE, pin.map(render_pin)),
];
for (name, text) in records {
let path = appdir.join(name);
match text {
Some(text) => {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, text)?;
super::normalize_mtime(&path);
}
None => {
let _ = fs::remove_file(&path);
}
}
}
Ok(())
}
pub fn pin_without_root(url: Option<&String>, hash: Option<&String>) -> io::Result<Option<Pin>> {
let (Some(url), Some(hash)) = (url, hash) else {
if url.is_some() || hash.is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"a GL build pin needs both platform-url and platform-hash",
));
}
return Ok(None);
};
let label = "sysroot".to_string();
platform::check_label(&label)
.and_then(|()| platform::check_url(url))
.and_then(|()| platform::check_hash(hash))
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
Ok(Some(Pin {
label,
url: url.clone(),
blake3: hash.to_ascii_lowercase(),
}))
}
fn link_target(root: &Path, rel: &str) -> Option<String> {
let target = fs::read_link(entry_in_root(root, Path::new(rel)).ok()?).ok()?;
let named = Path::new(rel)
.parent()
.unwrap_or(Path::new(""))
.join(target);
let resolved = entry_in_root(root, &named).ok()?;
Some(resolved.strip_prefix(root).ok()?.to_str()?.to_string())
}
pub fn refuse_inside(root: &Path, appdir: &Path) -> io::Result<()> {
if let (Some(root_abs), Ok(appdir_abs)) = (absolute(root), appdir.canonicalize())
&& root_abs.starts_with(&appdir_abs)
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"{}: the sysroot must not be inside the AppDir {}",
root.display(),
appdir.display()
),
));
}
Ok(())
}
pub fn check_set_name(name: &str) -> io::Result<()> {
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("set {name}: a set name is letters, digits, - and _ only"),
));
}
Ok(())
}
fn absolute(path: &Path) -> Option<PathBuf> {
let mut base = path.to_path_buf();
let mut rest = Vec::new();
let mut abs = loop {
let probe = if base.as_os_str().is_empty() {
Path::new(".")
} else {
base.as_path()
};
if let Ok(abs) = probe.canonicalize() {
break abs;
}
rest.push(base.components().next_back()?.as_os_str().to_os_string());
base.pop();
};
for part in rest.iter().rev() {
if part == ".." {
abs.pop();
} else {
abs.push(part);
}
}
Some(abs)
}
pub const SETS_FILE: &str = ".onelf/sets";
pub const SHIPPED_FILE: &str = ".onelf/sonames";
pub const PATHS_FILE: &str = ".onelf/paths";
pub fn render_sets(sets: &[SharedSet], closures: &[(String, Vec<String>)]) -> String {
let mut out = String::new();
for set in sets {
let packages = closures
.iter()
.find(|(n, _)| n == &set.name)
.map(|(_, p)| p.as_slice())
.unwrap_or(&[]);
out.push_str(&format!(
"[{}]\nurl = {}\nblake3 = {}\npackages = [{}]\n\n",
set.name,
toml_string(&set.url),
toml_string(&set.blake3),
packages
.iter()
.map(|p| toml_string(p))
.collect::<Vec<_>>()
.join(", ")
));
}
out
}
pub struct GlSelection {
pub packages: Vec<(String, String)>,
pub copied: usize,
}
const GL_SHARE_DIRS: &[&str] = &[
"usr/share/vulkan/",
"usr/share/glvnd/",
"usr/share/drirc.d/",
"usr/share/libdrm/",
];
const SET_SKIP_DIRS: &[&str] = &[
"usr/include/",
"usr/share/man/",
"usr/share/doc/",
"usr/share/info/",
"usr/share/licenses/",
"usr/share/gtk-doc/",
"usr/lib/pkgconfig/",
"usr/lib/cmake/",
"usr/share/gir-1.0/",
];
pub fn populate_set(
tree: &Path,
root: &Path,
packages: &[String],
platform: &PlatformLine,
) -> io::Result<GlSelection> {
populate_closure(tree, root, packages, Some(platform), |rel| {
!SET_SKIP_DIRS.iter().any(|d| rel.starts_with(d))
})
}
pub fn populate_gl(tree: &Path, root: &Path, packages: &[String]) -> io::Result<GlSelection> {
populate_closure(tree, root, packages, None, |rel| {
let is_lib = rel.starts_with("usr/lib/") || rel.starts_with("lib/");
let is_object = Path::new(rel)
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains(".so"));
(is_lib && is_object) || GL_SHARE_DIRS.iter().any(|d| rel.starts_with(d))
})
}
fn populate_closure(
tree: &Path,
root: &Path,
packages: &[String],
platform: Option<&PlatformLine>,
wanted: impl Fn(&str) -> bool,
) -> io::Result<GlSelection> {
let Some((first, rest)) = packages.split_first() else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"name at least one package to build from",
));
};
let db = Database::read(root)?;
for name in packages {
if db.satisfier(name).is_none() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("{}: no package provides {name}", root.display()),
));
}
}
let host_packages: std::collections::BTreeSet<String> = match platform {
Some(platform) => db
.closure(first, rest)
.packages
.iter()
.filter(|n| !packages.contains(n))
.filter_map(|n| db.package(n))
.filter(|p| {
p.files
.iter()
.any(|f| onelf_sysroot::is_top_level_lib_object(f) && platform.matches(f))
})
.map(|p| p.name.clone())
.collect(),
None => std::collections::BTreeSet::new(),
};
let closure = db.closure_excluding(first, rest, &host_packages);
let glibc: HashSet<&str> = db
.package("glibc")
.map(|p| p.files.iter().map(String::as_str).collect())
.unwrap_or_default();
let mut copied = 0;
for rel in db.files_of(&closure) {
if !glibc.contains(rel.as_str()) && wanted(&rel) && copy_entry(root, &rel, tree)?.is_some()
{
copied += 1;
}
}
copied += copy_generated_caches(root, tree)?.len();
copied += link_sonames(tree)?.len();
Ok(GlSelection {
packages: closure
.packages
.iter()
.filter_map(|n| db.package(n))
.map(|p| (p.name.clone(), p.version.clone()))
.collect(),
copied,
})
}
fn store_root() -> Option<PathBuf> {
match std::env::var_os("ONELF_PLATFORM_STORE").filter(|v| !v.is_empty()) {
Some(dir) => Some(PathBuf::from(dir)),
None => onelf_format::cache_layout::resolve_root(rustix::process::getuid().as_raw(), None)
.map(|root| onelf_format::cache_layout::platform_dir(&root)),
}
}
fn set_build(set: &SharedSet) -> Result<PathBuf, String> {
let pinned = |p: &Path| {
p.is_file()
&& crate::sysroot_cmd::hash_file(p).is_ok_and(|h| h.eq_ignore_ascii_case(&set.blake3))
};
if let Some(path) = set.url.strip_prefix("file://") {
let path = PathBuf::from(path);
return if pinned(&path) {
Ok(path)
} else {
Err(format!(
"{} is missing or not the pinned build",
path.display()
))
};
}
if !set.url.starts_with("https://") {
return Err(format!("{} is neither https:// nor file://", set.url));
}
let store = store_root()
.ok_or("no store to fetch it into (set HOME, XDG_CACHE_HOME or ONELF_PLATFORM_STORE)")?;
let stored = store.join(&set.name).join(format!("{}.onelf", set.blake3));
if pinned(&stored) {
return Ok(stored);
}
eprintln!(
" {} set {} from {}",
color::bold("Fetching"),
color::cyan(&set.name),
set.url
);
if let Some(parent) = stored.parent() {
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
let staged = stored.with_extension(format!("part-{}", std::process::id()));
let fetched = crate::sysroot_cmd::download(&set.url, &staged)
.map_err(|e| e.to_string())
.and_then(|()| {
if pinned(&staged) {
fs::rename(&staged, &stored).map_err(|e| format!("{}: {e}", stored.display()))
} else {
Err(format!(
"{} does not hash to the pinned {}",
set.url, set.blake3
))
}
});
if fetched.is_err() {
let _ = fs::remove_file(&staged);
}
fetched.map(|()| stored)
}
fn shipped_sonames(set: &SharedSet) -> Result<Vec<String>, String> {
let build = set_build(set)?;
let bytes = crate::metadata::read_packed_file(&build, SHIPPED_FILE)
.map_err(|e| format!("{}: {e}", build.display()))?
.ok_or_else(|| format!("{} records no sonames", build.display()))?;
let text = String::from_utf8(bytes).map_err(|e| format!("{}: {e}", build.display()))?;
Ok(text
.lines()
.filter(|l| !l.is_empty())
.map(String::from)
.collect())
}
pub(crate) fn take_set_provides(
sets: &[SharedSet],
findings: &mut Vec<(PathBuf, Vec<String>)>,
) -> (Vec<SetProvides>, Vec<(String, String)>) {
let mut known: Vec<(String, HashSet<String>)> = Vec::new();
let mut unchecked: Vec<(String, String)> = Vec::new();
for set in sets {
match shipped_sonames(set) {
Ok(names) => known.push((set.name.clone(), names.into_iter().collect())),
Err(why) => unchecked.push((set.name.clone(), why)),
}
}
(partition_set_provides(&known, findings), unchecked)
}
#[derive(Debug)]
pub(crate) struct SetProvides {
pub set: String,
pub sonames: BTreeSet<String>,
}
fn partition_set_provides(
known: &[(String, HashSet<String>)],
findings: &mut Vec<(PathBuf, Vec<String>)>,
) -> Vec<SetProvides> {
let mut provides: Vec<SetProvides> = known
.iter()
.map(|(name, _)| SetProvides {
set: name.clone(),
sonames: BTreeSet::new(),
})
.collect();
for (_, libs) in findings.iter_mut() {
libs.retain(
|soname| match known.iter().position(|(_, names)| names.contains(soname)) {
Some(i) => {
provides[i].sonames.insert(soname.clone());
false
}
None => true,
},
);
}
findings.retain(|(_, libs)| !libs.is_empty());
provides.retain(|p| !p.sonames.is_empty());
provides
}
pub(crate) fn report_set_provides(provides: &[SetProvides], unchecked: &[(String, String)]) {
for p in provides {
eprintln!(
" {} {}: {}",
color::bold("Provided by set"),
color::cyan(&p.set),
p.sonames.iter().cloned().collect::<Vec<_>>().join(", ")
);
}
for (set, why) in unchecked {
eprintln!(
" {} set {} could not be checked ({why}); what it supplies is \
listed below as missing",
color::bold("warning:"),
color::cyan(set)
);
}
}
fn relocate_cache(cache: &Path, tree: &Path) -> io::Result<()> {
let Ok(text) = fs::read_to_string(cache) else {
return Ok(()); };
let Some(dir) = cache.parent() else {
return Ok(());
};
let mut out = String::with_capacity(text.len());
let mut dropping = false;
for line in text.split_inclusive('\n') {
let body = line.trim_end();
if dropping {
dropping = !body.is_empty();
continue;
}
let module = body
.strip_prefix('"')
.and_then(|b| b.strip_suffix('"'))
.filter(|m| m.starts_with('/') && !m.contains('"'));
if let Some(module) = module {
match relative_in_tree(module, dir, tree) {
Some(relative) => {
out.push('"');
out.push_str(&relative);
out.push('"');
out.push_str(&line[body.len()..]);
}
None => dropping = true,
}
continue;
}
out.push_str(&relocate_paths(line, dir, tree));
}
if out != text {
fs::write(cache, out)?;
}
Ok(())
}
fn relocate_paths(line: &str, dir: &Path, tree: &Path) -> String {
let mut out = String::with_capacity(line.len());
let mut rest = line;
while let Some(start) = rest.find("\"/") {
let (before, after) = rest.split_at(start + 1);
out.push_str(before);
let Some(end) = after.find('"') else {
rest = after;
break;
};
let (path, tail) = after.split_at(end);
match relative_in_tree(path, dir, tree) {
Some(relative) => out.push_str(&relative),
None => out.push_str(path),
}
rest = tail;
}
out.push_str(rest);
out
}
fn relative_in_tree(absolute: &str, dir: &Path, tree: &Path) -> Option<String> {
let mapped = appdir_path(absolute.trim_start_matches('/'));
let target = tree.join(Path::new(&mapped));
if !target.is_file() {
return None;
}
let from = dir.strip_prefix(tree).ok()?;
let to = Path::new(&mapped);
Some(relative_path(from, to).to_string_lossy().into_owned())
}
fn link_sonames(dir: &Path) -> io::Result<Vec<String>> {
let mut made = 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();
let Some(soname) = super::elf::parse_soname(&path) else {
continue;
};
if path.file_name().and_then(|n| n.to_str()) == Some(soname.as_str()) {
continue;
}
if soname.contains('/') || soname.is_empty() {
continue;
}
let Some(parent) = path.parent() else {
continue;
};
let link = parent.join(&soname);
if link.exists() || link.is_symlink() {
continue;
}
let Some(target) = path.file_name() else {
continue;
};
std::os::unix::fs::symlink(target, &link)?;
if let Ok(rel) = link.strip_prefix(dir) {
made.push(rel.to_string_lossy().into_owned());
}
}
Ok(made)
}
const GENERATED_CACHES: &[&str] = &[
"gschemas.compiled",
"icon-theme.cache",
"mime.cache",
"mimeinfo.cache",
"loaders.cache",
"immodules.cache",
"giomodule.cache",
];
fn copy_generated_caches(root: &Path, tree: &Path) -> io::Result<Vec<String>> {
let mut copied = Vec::new();
for entry in jwalk::WalkDir::new(tree).skip_hidden(false).sort(true) {
let Ok(entry) = entry else { continue };
if !entry.file_type().is_dir() {
continue;
}
let dir = entry.path();
let Ok(rel) = dir.strip_prefix(tree) else {
continue;
};
for name in GENERATED_CACHES {
let dest = dir.join(name);
if dest.exists() {
continue;
}
let src = ["usr", ""]
.iter()
.filter_map(|prefix| in_root(root, &Path::new(prefix).join(rel).join(name)).ok())
.find(|p| p.is_file());
let Some(src) = src else { continue };
fs::copy(&src, &dest)?;
relocate_cache(&dest, tree)?;
super::normalize_mtime(&dest);
copied.push(rel.join(name).to_string_lossy().into_owned());
}
}
Ok(copied)
}
pub const GENERATED_FILE: &str = ".onelf/generated";
pub fn remove_generated(appdir: &Path) -> io::Result<()> {
let record = appdir.join(GENERATED_FILE);
let text = match fs::read_to_string(&record) {
Ok(t) => t,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
let mut dirs: Vec<PathBuf> = Vec::new();
let inside = |line: &&str| {
!line.is_empty()
&& Path::new(line)
.components()
.all(|c| matches!(c, Component::Normal(_)))
};
for line in text.lines().filter(inside) {
let path = appdir.join(line);
let _ = fs::remove_file(&path);
if let Some(parent) = path.parent() {
dirs.push(parent.to_path_buf());
}
}
dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count()));
dirs.dedup();
for dir in dirs {
let mut dir = dir.as_path();
while dir != appdir && fs::remove_dir(dir).is_ok() {
let Some(parent) = dir.parent() else { break };
dir = parent;
}
}
fs::remove_file(&record)
}
pub fn record_generated(appdir: &Path, paths: Vec<String>) -> io::Result<()> {
let record = appdir.join(GENERATED_FILE);
let mut all: Vec<String> = fs::read_to_string(&record)
.unwrap_or_default()
.lines()
.filter(|l| !l.is_empty())
.map(String::from)
.collect();
all.extend(paths);
all.sort();
all.dedup();
if let Some(parent) = record.parent() {
fs::create_dir_all(parent)?;
}
let mut text = all.join("\n");
text.push('\n');
fs::write(&record, text)
}
pub const PLATFORM_FILE: &str = ".onelf/platform";
fn resolve_pin(opts: &SysrootOptions) -> io::Result<Option<Pin>> {
let from_sysroot = platform::read(&opts.root)?;
let url = opts
.platform_url
.clone()
.or_else(|| from_sysroot.as_ref().map(|p| p.url.clone()));
let blake3 = opts
.platform_hash
.as_ref()
.map(|h| h.to_ascii_lowercase())
.or_else(|| from_sysroot.as_ref().map(|p| p.blake3.clone()));
let (Some(url), Some(blake3)) = (url, blake3) else {
if opts.platform_url.is_some() || opts.platform_hash.is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"a GL build pin needs both platform-url and platform-hash",
));
}
return Ok(None);
};
let label = from_sysroot
.map(|p| p.label)
.unwrap_or_else(|| opts.platform.clone());
platform::check_label(&label)
.and_then(|()| platform::check_url(&url))
.and_then(|()| platform::check_hash(&blake3))
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
Ok(Some(Pin { label, url, blake3 }))
}
pub fn render_pin(pin: &Pin) -> String {
format!(
"label = {}\nurl = {}\nblake3 = {}\n",
toml_string(&pin.label),
toml_string(&pin.url),
toml_string(&pin.blake3)
)
}
pub const PROVENANCE_FILE: &str = ".onelf/provenance.toml";
pub fn render_provenance(platform: &str, packages: &[(String, String)]) -> String {
let mut out = format!("platform = {}\n", toml_string(platform));
for (name, version) in packages {
out.push_str(&format!(
"\n[[package]]\nname = {}\nversion = {}\n",
toml_string(name),
toml_string(version)
));
}
out
}
fn toml_string(value: &str) -> String {
let mut out = String::from("\"");
for c in value.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
c if c.is_control() => out.push_str(&format!("\\u{:04X}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
pub fn default_label(root: &Path, archive: Option<&Path>) -> String {
archive
.and_then(|a| a.file_name())
.or_else(|| root.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "sysroot".to_string())
}
fn appdir_path(rel: &str) -> &str {
rel.strip_prefix("usr/").unwrap_or(rel)
}
fn copy_entry(root: &Path, rel: &str, appdir: &Path) -> io::Result<Option<PathBuf>> {
if !Path::new(rel).components().all(|c| {
matches!(
c,
std::path::Component::Normal(_) | std::path::Component::CurDir
)
}) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("{rel}: package database path leaves the sysroot"),
));
}
let src = entry_in_root(root, Path::new(rel))?;
let Ok(md) = fs::symlink_metadata(&src) else {
return Ok(None);
};
let dest_rel = PathBuf::from(appdir_path(rel));
let dest = appdir.join(&dest_rel);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
if md.file_type().is_symlink() {
let target = fs::read_link(&src)?;
let Some(target) = relink(rel, &target) else {
return Ok(None);
};
let target_str = target.to_string_lossy();
if !onelf_format::symlink_target_within_root(&dest_rel, &target_str) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{rel}: symlink target {} leaves the bundle",
target.display()
),
));
}
match fs::symlink_metadata(&dest) {
Ok(existing) if existing.is_dir() => return Ok(None),
Ok(_) => fs::remove_file(&dest)?,
Err(_) => {}
}
std::os::unix::fs::symlink(&target, &dest)?;
return Ok(Some(dest_rel));
}
if !md.file_type().is_file() {
return Ok(None);
}
if fs::symlink_metadata(&dest).is_ok() {
fs::remove_file(&dest)?;
}
fs::copy(&src, &dest)
.map_err(|e| io::Error::new(e.kind(), format!("copying {rel} from the sysroot: {e}")))?;
super::normalize_mtime(&dest);
Ok(Some(dest_rel))
}
fn relink(link_rel: &str, target: &Path) -> Option<PathBuf> {
let link = Path::new(link_rel);
let resolved = if target.is_absolute() {
normalize(target.strip_prefix("/").unwrap_or(target))
} else {
normalize(&link.parent().unwrap_or(Path::new("")).join(target))
};
let mapped = PathBuf::from(appdir_path(&resolved.to_string_lossy()));
let dest = PathBuf::from(appdir_path(link_rel));
if mapped == dest {
return None;
}
let from = dest.parent().unwrap_or(Path::new(""));
Some(relative_path(from, &mapped))
}
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in path.components() {
match c {
Component::ParentDir => {
out.pop();
}
Component::Normal(n) => out.push(n),
Component::CurDir | Component::RootDir | Component::Prefix(_) => {}
}
}
out
}
fn relative_path(from: &Path, to: &Path) -> PathBuf {
let from: Vec<Component> = from.components().collect();
let to: Vec<Component> = to.components().collect();
let common = from
.iter()
.zip(to.iter())
.take_while(|(a, b)| a == b)
.count();
let mut out = PathBuf::new();
for _ in common..from.len() {
out.push("..");
}
for c in &to[common..] {
out.push(c.as_os_str());
}
if out.as_os_str().is_empty() {
out.push(".");
}
out
}
fn is_elf(path: &Path) -> bool {
use std::io::Read;
let Ok(mut f) = fs::File::open(path) else {
return false;
};
let mut magic = [0u8; 4];
matches!(f.read(&mut magic), Ok(4)) && magic == *b"\x7fELF"
}
pub fn print_report(opts: &SysrootOptions, report: &SysrootReport) {
eprintln!("{} {}", color::bold("Sysroot:"), opts.root.display());
eprintln!(
" {} ({}): {}",
color::bold("Packages"),
report.packages.len(),
report
.packages
.iter()
.map(|(n, v)| format!("{n} {v}"))
.collect::<Vec<_>>()
.join(", ")
);
eprintln!(
" {} {} files; left out: {} on the platform line, {} by policy, {} by trace{}",
color::bold("Copied"),
report.copied,
report.removed_platform,
report.removed_policy,
report.removed_trace,
if report.absent > 0 {
format!("; {} listed but absent from the sysroot", report.absent)
} else {
String::new()
}
);
if !report.host_packages.is_empty() {
eprintln!(
" {} {}",
color::bold("Host packages:"),
report.host_packages.join(", ")
);
} else if !report.packages.is_empty() {
eprintln!(
" {} none; the bundle ships every library in the closure",
color::bold("Host packages:")
);
}
if !report.host_provided.is_empty() {
eprintln!(
" {} {}",
color::bold("Host-provided:"),
report.host_provided.join(", ")
);
}
if let Some(pin) = &report.pin {
eprintln!(
" {} {} from {} ({}...)",
color::bold("GL build:"),
pin.label,
pin.url,
&pin.blake3[..16]
);
}
for (name, packages) in &report.sets {
eprintln!(
" {} {} leaves out {} package(s): {}",
color::bold("Shared set:"),
name,
packages.len(),
packages.join(", ")
);
}
for (dep, by) in &report.unsatisfied {
eprintln!(
" {} {dep} (needed by {by}) is not installed in the sysroot",
color::bold_red("warning:")
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_module_cache_is_rewritten_to_reach_the_bundles_own_copies() {
let root =
std::env::temp_dir().join(format!("onelf-cache-{}-{}", std::process::id(), line!()));
let dir = root.join("lib/gdk-pixbuf-2.0/2.10.0");
std::fs::create_dir_all(dir.join("loaders")).unwrap();
std::fs::write(dir.join("loaders/libpixbufloader_svg.so"), b"elf").unwrap();
let cache = dir.join("loaders.cache");
std::fs::write(
&cache,
"# LoaderDir = /usr/lib/gdk-pixbuf-2.0/2.10.0/loaders\n\
\"/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders/absent.so\"\n\
\"absent\" 6 \"gdk-pixbuf\" \"/usr/share/locale\"\n\
\n\
\"/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders/libpixbufloader_svg.so\" \n\
\"svg\" 6 \"gdk-pixbuf\" \"/usr/share/locale\"\n\
\n",
)
.unwrap();
relocate_cache(&cache, &root).unwrap();
let got = std::fs::read_to_string(&cache).unwrap();
assert_eq!(
got,
"# LoaderDir = /usr/lib/gdk-pixbuf-2.0/2.10.0/loaders\n\
\"loaders/libpixbufloader_svg.so\" \n\
\"svg\" 6 \"gdk-pixbuf\" \"/usr/share/locale\"\n\
\n",
"the bundle's own copy is named, and a module it lacks loses its entry"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn symlink_targets_are_remapped_relative_to_the_link() {
let some = |s: &str| Some(PathBuf::from(s));
assert_eq!(
relink("usr/lib/libfoo.so", Path::new("libfoo.so.1")),
some("libfoo.so.1")
);
assert_eq!(
relink("usr/lib/libfoo.so", Path::new("/usr/lib/libfoo.so.1")),
some("libfoo.so.1")
);
assert_eq!(
relink("usr/bin/tool", Path::new("/usr/lib/tool/bin/tool")),
some("../lib/tool/bin/tool")
);
assert_eq!(
relink("usr/bin/tool", Path::new("../lib/tool/bin/tool")),
some("../lib/tool/bin/tool")
);
assert_eq!(relink("lib64", Path::new("usr/lib")), some("lib"));
assert_eq!(relink("usr/lib64", Path::new("lib")), some("lib"));
assert_eq!(relink("usr/sbin", Path::new("bin")), some("bin"));
assert_eq!(
relink("opt/tbb/lib/gcc4.8", Path::new("..//lib")),
some(".")
);
assert_eq!(relink("bin", Path::new("usr/bin")), None);
assert_eq!(relink("lib", Path::new("usr/lib")), None);
}
#[test]
fn a_database_path_leaving_the_sysroot_is_refused() {
let root = std::env::temp_dir().join(format!("onelf-dbpath-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("sysroot")).unwrap();
fs::create_dir_all(root.join("appdir")).unwrap();
fs::write(root.join("outside"), b"secret").unwrap();
for rel in ["../outside", "usr/../../outside", "/outside"] {
let err = copy_entry(&root.join("sysroot"), rel, &root.join("appdir")).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData, "{rel}");
}
assert!(
copy_entry(
&root.join("sysroot"),
"usr/lib/missing",
&root.join("appdir")
)
.unwrap()
.is_none()
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_database_path_through_an_absolute_link_stays_in_the_sysroot() {
let root = std::env::temp_dir().join(format!("onelf-dblink-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let sysroot = root.join("sysroot");
fs::create_dir_all(sysroot.join("usr/share")).unwrap();
fs::create_dir_all(root.join("home/me")).unwrap();
fs::create_dir_all(root.join("appdir")).unwrap();
fs::write(root.join("home/me/secret"), b"secret").unwrap();
std::os::unix::fs::symlink(root.join("home"), sysroot.join("usr/share/x")).unwrap();
let copied = copy_entry(&sysroot, "usr/share/x/me/secret", &root.join("appdir")).unwrap();
assert!(copied.is_none(), "{copied:?}");
assert!(!root.join("appdir/share/x/me/secret").exists());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_sysroot_inside_the_appdir_is_refused_before_it_exists() {
let root = std::env::temp_dir().join(format!("onelf-inside-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let appdir = root.join("appdir");
fs::create_dir_all(&appdir).unwrap();
for sysroot in [
"appdir/sysroot",
"appdir/a/b/../sysroot",
"other/../appdir/x",
] {
assert!(
refuse_inside(&root.join(sysroot), &appdir).is_err(),
"{sysroot}"
);
}
for sysroot in ["sysroot", "appdir/../sysroot", "appdir-2/sysroot"] {
assert!(
refuse_inside(&root.join(sysroot), &appdir).is_ok(),
"{sysroot}"
);
}
let _ = fs::remove_dir_all(&root);
}
#[test]
fn the_generated_record_never_reaches_outside_the_appdir() {
let root = std::env::temp_dir().join(format!("onelf-generated-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let appdir = root.join("appdir");
fs::create_dir_all(appdir.join("lib/sub")).unwrap();
fs::create_dir_all(root.join("outside/empty")).unwrap();
fs::write(appdir.join("lib/sub/libfoo.so"), b"elf").unwrap();
fs::write(root.join("outside/keep"), b"keep").unwrap();
fs::create_dir_all(appdir.join(".onelf")).unwrap();
fs::write(
appdir.join(GENERATED_FILE),
format!(
"lib/sub/libfoo.so\n../outside/keep\nlib/../../outside/empty/x\n{}\n",
root.join("outside/keep").display()
),
)
.unwrap();
remove_generated(&appdir).unwrap();
assert!(!appdir.join("lib").exists(), "the emptied directories go");
assert!(appdir.is_dir(), "the AppDir stays");
assert!(root.join("outside/keep").is_file());
assert!(root.join("outside/empty").is_dir());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_link_target_is_the_sysroot_path_it_names() {
let root = std::env::temp_dir().join(format!("onelf-linktarget-{}", 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/icons/a")).unwrap();
std::os::unix::fs::symlink("usr/lib", root.join("lib")).unwrap();
std::os::unix::fs::symlink("libtbb.so.12.14", root.join("usr/lib/libtbb.so.12")).unwrap();
std::os::unix::fs::symlink("/lib/libz.so.1.3", root.join("usr/lib/libz.so.1")).unwrap();
std::os::unix::fs::symlink(
"../b/index.theme",
root.join("usr/share/icons/a/index.theme"),
)
.unwrap();
let target = |rel| link_target(&root, rel);
assert_eq!(
target("usr/lib/libtbb.so.12").as_deref(),
Some("usr/lib/libtbb.so.12.14")
);
assert_eq!(
target("usr/lib/libz.so.1").as_deref(),
Some("usr/lib/libz.so.1.3")
);
assert_eq!(
target("usr/share/icons/a/index.theme").as_deref(),
Some("usr/share/icons/b/index.theme")
);
assert_eq!(target("usr/lib"), None);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn the_record_renders_to_fixed_bytes_with_escaping() {
let packages = vec![
("app".to_string(), "1.0-1".to_string()),
("we\"ird".to_string(), "2\\3".to_string()),
];
assert_eq!(
render_provenance("platform-1", &packages),
"platform = \"platform-1\"\n\n[[package]]\nname = \"app\"\nversion = \"1.0-1\"\n\n[[package]]\nname = \"we\\\"ird\"\nversion = \"2\\\\3\"\n"
);
assert_eq!(
default_label(
Path::new("/x/sysroot"),
Some(Path::new("/y/platform-1.tar.zst"))
),
"platform-1.tar.zst"
);
assert_eq!(default_label(Path::new("/x/sysroot"), None), "sysroot");
}
#[test]
fn usr_is_flattened_and_nothing_else_is() {
assert_eq!(appdir_path("usr/bin/app"), "bin/app");
assert_eq!(appdir_path("usr/share/x"), "share/x");
assert_eq!(appdir_path("etc/app.conf"), "etc/app.conf");
assert_eq!(appdir_path("opt/app/run"), "opt/app/run");
}
#[test]
fn set_provides_are_taken_from_the_findings() {
let known = vec![
(
"qt".to_string(),
HashSet::from(["libQt6Core.so.6".to_string()]),
),
(
"gtk".to_string(),
HashSet::from(["libgtk-3.so.0".to_string()]),
),
];
let mut findings = vec![
(
PathBuf::from("bin/app"),
vec![
"libQt6Core.so.6".to_string(),
"libgdk-3.so.0".to_string(),
"libgtk-3.so.0".to_string(),
],
),
(
PathBuf::from("bin/helper"),
vec!["libtruly.so.1".to_string()],
),
];
let provides = partition_set_provides(&known, &mut findings);
assert_eq!(provides.len(), 2, "{provides:?}");
assert_eq!(provides[0].set, "qt");
assert_eq!(
provides[0].sonames.iter().cloned().collect::<Vec<_>>(),
["libQt6Core.so.6"]
);
assert_eq!(provides[1].set, "gtk");
assert_eq!(
provides[1].sonames.iter().cloned().collect::<Vec<_>>(),
["libgtk-3.so.0"]
);
assert_eq!(
findings,
vec![
(PathBuf::from("bin/app"), vec!["libgdk-3.so.0".to_string()]),
(
PathBuf::from("bin/helper"),
vec!["libtruly.so.1".to_string()]
),
],
"what no build ships stays a finding"
);
}
#[test]
fn a_set_build_must_match_its_pin() {
let dir = std::env::temp_dir().join(format!("onelf-set-build-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let file = dir.join("set.onelf");
fs::write(&file, b"not the pinned build").unwrap();
let set = |url: String| SharedSet {
name: "gtk".to_string(),
packages: vec!["gtk3".to_string()],
url,
blake3: "0".repeat(64),
};
let err = set_build(&set(format!("file://{}", file.display()))).unwrap_err();
assert!(err.contains("not the pinned build"), "{err}");
let err = set_build(&set("http://example.com/set.onelf".to_string())).unwrap_err();
assert!(err.contains("neither https:// nor file://"), "{err}");
let _ = fs::remove_dir_all(&dir);
}
}