use std::collections::{BTreeSet, HashMap, HashSet};
use std::fs;
use std::io::{self, Read};
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(crate) universe: SysrootUniverse,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Refusal {
Absent,
HostPackage {
owner: String,
},
UnselectedPackage {
owner: String,
},
Pruned {
owner: String,
},
}
#[derive(Debug, Default)]
pub struct SysrootUniverse {
root: PathBuf,
kept: HashSet<String>,
kept_dirs: HashSet<String>,
left_out: HashSet<String>,
closure: HashSet<String>,
db: Database,
}
impl SysrootUniverse {
pub(crate) fn admit(
root: &Path,
db: Database,
kept: &[String],
left_out: &HashSet<String>,
closure: &[String],
) -> Self {
let mut universe = Self {
root: root.to_path_buf(),
kept: kept.iter().cloned().collect(),
left_out: left_out.clone(),
closure: closure.iter().cloned().collect(),
db,
..Default::default()
};
for file in &universe.kept {
for dir in Path::new(file).ancestors().skip(1) {
if dir.as_os_str().is_empty() {
continue;
}
universe
.kept_dirs
.insert(dir.to_string_lossy().into_owned());
}
}
universe
}
pub(crate) fn root(&self) -> &Path {
&self.root
}
pub(crate) fn admits(&self, rel: &str) -> bool {
let rel = rel.trim_start_matches('/');
self.kept.contains(rel) || self.kept_dirs.contains(rel)
}
pub(crate) fn admitted_under(&self, dir: &str) -> Vec<String> {
let prefix = format!("{}/", dir.trim_start_matches('/'));
let mut files: Vec<String> = self
.kept
.iter()
.filter(|f| f.starts_with(&prefix))
.cloned()
.collect();
files.sort();
files
}
pub(crate) fn refusal(&self, rel: &str) -> Refusal {
let rel = rel.trim_start_matches('/');
if let Some(owner) = self.db.owner(rel) {
return self.classify(rel, &owner.name);
}
let prefix = format!("{rel}/");
for package in self.db.packages() {
for file in &package.files {
if file.starts_with(&prefix) {
return self.classify(file, &package.name);
}
}
}
Refusal::Absent
}
fn classify(&self, file: &str, owner: &str) -> Refusal {
if self.left_out.contains(file) {
Refusal::HostPackage {
owner: owner.to_string(),
}
} else if self.closure.contains(owner) {
Refusal::Pruned {
owner: owner.to_string(),
}
} else {
Refusal::UnselectedPackage {
owner: owner.to_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;
}
};
let collisions = population_collisions(appdir, root, &pruned.kept)?;
if !collisions.is_empty() {
return Err(population_collision_error(&collisions));
}
let conflicts = dispose_generated(appdir, root, &pruned.kept)?;
if !conflicts.is_empty() {
return Err(generated_conflict_error(&conflicts));
}
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 packages: Vec<(String, String)> = closure
.packages
.iter()
.filter_map(|n| db.package(n))
.map(|p| (p.name.clone(), p.version.clone()))
.collect();
let universe = SysrootUniverse::admit(root, db, &pruned.kept, &host_files, &closure.packages);
let mut report = SysrootReport {
packages,
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,
universe,
..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<()> {
onelf_format::rules::RuleId::set_name(name)
.map(|_| ())
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, format!("set {name}: {e}")))
}
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 PROVIDER_INDEX_FILE: &str = ".onelf/providers";
pub const PATHS_FILE: &str = onelf_format::paths::PATHS_FILE;
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,
})
}
thread_local! {
static FETCH_FORBIDDEN: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
pub(crate) struct FetchGuard(bool);
impl Drop for FetchGuard {
fn drop(&mut self) {
FETCH_FORBIDDEN.with(|f| f.set(self.0));
}
}
pub(crate) fn fetch_forbidden() -> bool {
FETCH_FORBIDDEN.with(|f| f.get())
}
pub(crate) fn forbid_fetch() -> FetchGuard {
FetchGuard(FETCH_FORBIDDEN.with(|f| f.replace(true)))
}
fn store_root() -> Option<PathBuf> {
let uid = rustix::process::getuid().as_raw();
store_root_with(
uid,
std::env::var_os("ONELF_PLATFORM_STORE").map(PathBuf::from),
|| onelf_format::cache_layout::resolve_root(uid, None),
)
}
fn store_root_with(
uid: u32,
hint: Option<PathBuf>,
cache_root: impl FnOnce() -> Option<PathBuf>,
) -> Option<PathBuf> {
use std::os::unix::fs::MetadataExt;
let owned = |dir: &Path| match fs::metadata(dir) {
Ok(md) => md.is_dir() && md.uid() == uid && (uid != 0 || md.mode() & 0o022 == 0),
Err(e) => uid != 0 && e.kind() == io::ErrorKind::NotFound,
};
match hint.filter(|dir| !dir.as_os_str().is_empty() && owned(dir)) {
Some(dir) => Some(dir),
None => cache_root().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);
}
if fetch_forbidden() {
return Err(format!(
"pinned build {} is not cached on this machine, and a dry run does not fetch it",
&set.blake3[..set.blake3.len().min(16)]
));
}
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)?;
if let Some(bytes) = crate::metadata::read_packed_file(&build, PROVIDER_INDEX_FILE)
.map_err(|e| format!("{}: {e}", build.display()))?
{
let index = onelf_format::provider_index::ProviderIndex::decode(&bytes)
.map_err(|e| format!("{}: {e}", build.display()))?;
return Ok(index.exports);
}
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 verified_exports(dir: &Path) -> io::Result<Vec<String>> {
let mut names: Vec<String> = jwalk::WalkDir::new(dir)
.skip_hidden(false)
.into_iter()
.flatten()
.filter(|e| e.file_type().is_file())
.filter_map(|e| super::elf::parse_soname(&e.path()))
.filter(|name| !name.is_empty() && !name.contains('/'))
.collect();
names.sort();
names.dedup();
Ok(names)
}
pub(crate) fn encode_provider_index(dir: &Path, name: &str) -> io::Result<Vec<u8>> {
let index = onelf_format::provider_index::ProviderIndex::new(name, &verified_exports(dir)?)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
index
.encode()
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
}
fn check_index(
index: &onelf_format::provider_index::ProviderIndex,
name: &str,
exports: &[String],
) -> io::Result<()> {
if index.set != name {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("provider index names set {}, not {name}", index.set),
));
}
if index.exports != exports {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"provider index does not match the exports the set's objects verify",
));
}
Ok(())
}
pub(crate) fn verify_set_artifact(
output: &Path,
name: &str,
exports: &[String],
) -> io::Result<String> {
let hash = crate::sysroot_cmd::hash_file(output)?;
let bytes =
crate::metadata::read_packed_file(output, PROVIDER_INDEX_FILE)?.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{}: the packed set records no provider index",
output.display()
),
)
})?;
let index = onelf_format::provider_index::ProviderIndex::decode(&bytes).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("{}: {e}", output.display()),
)
})?;
check_index(&index, name, exports)?;
Ok(hash)
}
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)
);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetProvider {
pub set: String,
pub pin: String,
pub provides: Vec<String>,
pub exports: Vec<SetExport>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetExport {
pub soname: String,
pub blake3: [u8; 32],
}
pub(crate) fn verified_export_hashes(set: &SharedSet) -> Result<Vec<SetExport>, String> {
let build = set_build(set)?;
let bytes = crate::metadata::read_packed_file(&build, PROVIDER_INDEX_FILE)
.map_err(|e| format!("{}: {e}", build.display()))?
.ok_or_else(|| {
format!(
"{} records no provider index, so its exports cannot be checked",
build.display()
)
})?;
let index = onelf_format::provider_index::ProviderIndex::decode(&bytes)
.map_err(|e| format!("{}: {e}", build.display()))?;
let (footer, manifest) = crate::info::read_footer_and_manifest(&build)
.map_err(|e| format!("{}: {e}", build.display()))?;
let mut file = fs::File::open(&build).map_err(|e| format!("{}: {e}", build.display()))?;
let codec = onelf_format::payload::Codec::open(&file, &footer)
.map_err(|e| format!("{}: {e}", build.display()))?;
let mut found: Vec<SetExport> = Vec::new();
for (at, entry) in manifest.entries.iter().enumerate() {
if entry.kind != onelf_format::EntryKind::File {
continue;
}
if !manifest.entry_path(at).contains(".so") {
continue;
}
let data = crate::extract::decompress_verified(&mut file, &footer, entry, &codec)
.map_err(|e| format!("{}: {}: {e}", build.display(), manifest.entry_path(at)))?;
let Ok(elf) = goblin::elf::Elf::parse(&data) else {
continue;
};
let Some(soname) = elf.soname else {
continue;
};
if soname.is_empty() || soname.contains('/') {
continue;
}
found.push(SetExport {
soname: soname.to_string(),
blake3: entry.content_hash,
});
}
found.sort_by(|a, b| (&a.soname, &a.blake3).cmp(&(&b.soname, &b.blake3)));
found.dedup();
for pair in found.windows(2) {
if pair[0].soname == pair[1].soname {
return Err(format!(
"{} exports {} from objects with different bytes",
build.display(),
pair[0].soname
));
}
}
let scanned: Vec<String> = found.iter().map(|e| e.soname.clone()).collect();
if scanned != index.exports {
return Err(format!(
"{}: the provider index records {:?}, and its objects export {:?}",
build.display(),
index.exports,
scanned
));
}
Ok(found)
}
pub(crate) struct VerifiedSet {
set: String,
build: PathBuf,
file: fs::File,
footer: onelf_format::Footer,
manifest: onelf_format::Manifest,
codec: onelf_format::payload::Codec,
files: HashMap<String, usize>,
}
impl VerifiedSet {
pub(crate) fn open(set: &SharedSet) -> Result<Self, String> {
let build = set_build(set)?;
let (footer, manifest) = crate::info::read_footer_and_manifest(&build)
.map_err(|e| format!("{}: {e}", build.display()))?;
let file = fs::File::open(&build).map_err(|e| format!("{}: {e}", build.display()))?;
let codec = onelf_format::payload::Codec::open(&file, &footer)
.map_err(|e| format!("{}: {e}", build.display()))?;
let files = manifest
.entries
.iter()
.enumerate()
.filter(|(_, e)| e.kind == onelf_format::EntryKind::File)
.map(|(at, _)| (manifest.entry_path(at), at))
.collect();
Ok(Self {
set: set.name.clone(),
build,
file,
footer,
manifest,
codec,
files,
})
}
pub(crate) fn set(&self) -> &str {
&self.set
}
pub(crate) fn build(&self) -> &Path {
&self.build
}
pub(crate) fn file_paths(&self) -> Vec<String> {
self.manifest
.entries
.iter()
.enumerate()
.filter(|(_, e)| e.kind == onelf_format::EntryKind::File)
.map(|(at, _)| self.manifest.entry_path(at))
.collect()
}
pub(crate) fn read(&mut self, path: &str) -> Result<Option<Vec<u8>>, String> {
let Some(&at) = self.files.get(path) else {
return Ok(None);
};
let entry = &self.manifest.entries[at];
crate::extract::decompress_verified(&mut self.file, &self.footer, entry, &self.codec)
.map(Some)
.map_err(|e| format!("{}: {path}: {e}", self.build.display()))
}
}
pub(crate) fn audit_providers(sets: &[SharedSet]) -> (Vec<SetProvider>, Vec<(String, String)>) {
let mut named: Vec<(String, String, Vec<String>)> = Vec::new();
let mut unchecked: Vec<(String, String)> = Vec::new();
let mut counts: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
for set in sets {
match shipped_sonames(set) {
Ok(mut exports) => {
exports.sort();
exports.dedup();
for name in &exports {
*counts.entry(name.clone()).or_default() += 1;
}
named.push((set.name.clone(), set.blake3.clone(), exports));
}
Err(why) => unchecked.push((set.name.clone(), why)),
}
}
let mut providers: Vec<SetProvider> = Vec::new();
for (name, pin, exports) in named {
let shared = exports.iter().any(|soname| counts[soname] > 1);
if !shared {
providers.push(SetProvider {
set: name,
pin,
provides: exports,
exports: Vec::new(),
});
continue;
}
let set = sets.iter().find(|s| s.name == name);
match set.map(verified_export_hashes) {
Some(Ok(hashes)) => providers.push(SetProvider {
set: name,
pin,
provides: hashes.iter().map(|e| e.soname.clone()).collect(),
exports: hashes,
}),
Some(Err(why)) => unchecked.push((name, why)),
None => unreachable!("the names came from this list"),
}
}
providers.sort_by(|a, b| a.set.cmp(&b.set));
(providers, unchecked)
}
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";
const GENERATED_LEDGER_HEADER: &str = "generated-v2";
const GENERATED_LEDGER_LIMIT: u64 = 8 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GeneratedEntry {
pub path: String,
pub hash: Option<blake3::Hash>,
pub pending: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum GeneratedConflict {
Edited {
recorded: blake3::Hash,
actual: blake3::Hash,
},
Unproven {
actual: blake3::Hash,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GeneratedFinding {
pub path: String,
pub refreshable: bool,
pub why: GeneratedConflict,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Disposition {
Absent,
Dispose,
Excuse,
Conflict(GeneratedConflict),
}
pub(crate) fn decide_generated_disposal(
record: &GeneratedEntry,
actual: Option<blake3::Hash>,
) -> Disposition {
match (&record.hash, actual) {
(_, None) => Disposition::Absent,
(None, Some(actual)) => Disposition::Conflict(GeneratedConflict::Unproven { actual }),
(Some(recorded), Some(actual)) if recorded == &actual => Disposition::Dispose,
(Some(_), Some(_)) if record.pending => Disposition::Excuse,
(Some(recorded), Some(actual)) => Disposition::Conflict(GeneratedConflict::Edited {
recorded: *recorded,
actual,
}),
}
}
pub(crate) fn read_generated(appdir: &Path) -> io::Result<Vec<GeneratedEntry>> {
let record = appdir.join(GENERATED_FILE);
let file = match fs::File::open(&record) {
Ok(file) => file,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let mut text = String::new();
file.take(GENERATED_LEDGER_LIMIT + 1)
.read_to_string(&mut text)
.map_err(|e| io::Error::new(e.kind(), format!("{}: {e}", record.display())))?;
if text.len() as u64 > GENERATED_LEDGER_LIMIT {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{}: larger than the {} MiB ledger limit",
record.display(),
GENERATED_LEDGER_LIMIT / (1024 * 1024)
),
));
}
let lines: Vec<&str> = text.lines().filter(|l| !l.is_empty()).collect();
match lines.first() {
Some(&GENERATED_LEDGER_HEADER) => parse_hashed_ledger(&record, &lines[1..]),
Some(_) => Ok(lines
.into_iter()
.filter(|line| inside_appdir(line))
.map(|path| GeneratedEntry {
path: path.to_string(),
hash: None,
pending: false,
})
.collect()),
None => Ok(Vec::new()),
}
}
fn parse_hashed_ledger(record: &Path, lines: &[&str]) -> io::Result<Vec<GeneratedEntry>> {
let mut entries: Vec<GeneratedEntry> = Vec::new();
let mut awaiting: Option<usize> = None;
for line in lines {
let (hex, pending) = match line.strip_prefix("#~") {
Some(hex) => (hex, true),
None => match line.strip_prefix('#') {
Some(hex) => (hex, false),
None => {
if inside_appdir(line) {
entries.push(GeneratedEntry {
path: (*line).to_string(),
hash: None,
pending: false,
});
awaiting = Some(entries.len() - 1);
} else {
awaiting = None;
}
continue;
}
},
};
let Some(index) = awaiting else {
return Err(malformed_ledger(
record,
line,
"a hash line with no path above it",
));
};
if entries[index].hash.is_some() {
return Err(malformed_ledger(record, line, "a second hash for one path"));
}
let hex = hex.to_ascii_lowercase();
let owner = entries[index].path.clone();
entries[index].hash = Some(hex.parse::<blake3::Hash>().map_err(|_| {
malformed_ledger(record, line, &format!("not a blake3 digest for {owner}"))
})?);
entries[index].pending = pending;
awaiting = None;
}
Ok(entries)
}
fn malformed_ledger(record: &Path, line: &str, why: &str) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
format!("{}: {}: {why}", record.display(), line),
)
}
fn inside_appdir(line: &str) -> bool {
!line.is_empty()
&& Path::new(line)
.components()
.all(|c| matches!(c, Component::Normal(_)))
}
pub(crate) fn hash_entry(path: &Path) -> io::Result<Option<blake3::Hash>> {
let meta = match fs::symlink_metadata(path) {
Ok(meta) => meta,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(io::Error::new(e.kind(), format!("{}: {e}", path.display()))),
};
let mut hasher = blake3::Hasher::new();
if meta.file_type().is_symlink() {
hasher.update(b"link ");
hasher.update(fs::read_link(path)?.as_os_str().as_encoded_bytes());
} else if meta.is_dir() {
hasher.update(b"dir");
} else if meta.is_file() {
hasher.update(b"file ");
hasher.update_reader(fs::File::open(path)?)?;
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("{}: not a regular file, link or directory", path.display()),
));
}
Ok(Some(hasher.finalize()))
}
fn expected_output_hash(root: &Path, rel: &str) -> io::Result<Option<blake3::Hash>> {
let src = match entry_in_root(root, Path::new(rel)) {
Ok(src) => src,
Err(_) => return Ok(None),
};
let meta = match fs::symlink_metadata(&src) {
Ok(meta) => meta,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
let mut hasher = blake3::Hasher::new();
if meta.file_type().is_symlink() {
let Some(target) = relink(rel, &fs::read_link(&src)?) else {
return Ok(None);
};
hasher.update(b"link ");
hasher.update(target.as_os_str().as_encoded_bytes());
} else if meta.is_file() {
hasher.update(b"file ");
hasher.update_reader(fs::File::open(&src)?)?;
} else {
return Ok(None);
}
Ok(Some(hasher.finalize()))
}
fn population_collisions(appdir: &Path, root: &Path, kept: &[String]) -> io::Result<Vec<String>> {
let recorded: HashSet<String> = read_generated(appdir)?
.into_iter()
.map(|entry| entry.path)
.collect();
let mut findings = Vec::new();
for rel in kept {
let dest_rel = appdir_path(rel);
if let Some(link) = super::plan::symlinked_ancestor(appdir, Path::new(dest_rel)) {
findings.push(format!(
"{dest_rel}: passes through the symlink {}, so the copy would land wherever \
it points; replace the link with a directory",
link.display()
));
continue;
}
let dest = appdir.join(dest_rel);
let Some(actual) = hash_entry(&dest)? else {
continue;
};
if recorded.contains(dest_rel) {
continue;
}
let Some(expected) = expected_output_hash(root, rel)? else {
continue;
};
let src_is_link = entry_in_root(root, Path::new(rel))
.ok()
.and_then(|src| fs::symlink_metadata(src).ok())
.is_some_and(|m| m.file_type().is_symlink());
if src_is_link && dest.is_dir() {
continue;
}
if actual != expected {
findings.push(format!(
"{dest_rel}: holds content hashing to {actual}, but the sysroot's {rel} \
hashes to {expected}; the build would replace it, so remove the existing \
path or move it to one the build does not write"
));
}
}
findings.sort();
Ok(findings)
}
fn population_collision_error(findings: &[String]) -> io::Error {
const SHOWN: usize = 8;
let mut text = format!(
"populating from the sysroot stopped before anything was written; {} conflict(s):\n",
findings.len()
);
for finding in findings.iter().take(SHOWN) {
text.push_str(" ");
text.push_str(finding);
text.push('\n');
}
if findings.len() > SHOWN {
text.push_str(&format!(" and {} more\n", findings.len() - SHOWN));
}
io::Error::new(io::ErrorKind::InvalidData, text)
}
pub(crate) fn dispose_generated(
appdir: &Path,
root: &Path,
kept: &[String],
) -> io::Result<Vec<GeneratedFinding>> {
let entries = read_generated(appdir)?;
if entries.is_empty() {
let _ = fs::remove_file(appdir.join(GENERATED_FILE));
return Ok(Vec::new());
}
let planned: std::collections::HashMap<&str, &str> = kept
.iter()
.map(|rel| (appdir_path(rel), rel.as_str()))
.collect();
let mut removals: Vec<String> = Vec::new();
let mut findings: Vec<GeneratedFinding> = Vec::new();
for entry in &entries {
if entry.path == onelf_format::anchors::ANCHORS_FILE {
continue;
}
if entry.path == onelf_format::plugindirs::PLUGIN_DIRS_FILE {
continue;
}
let actual = hash_entry(&appdir.join(&entry.path))?;
let mut judged = entry.clone();
if judged.hash.is_none()
&& let Some(actual) = actual
&& let Some(rel) = planned.get(entry.path.as_str())
&& let Some(expected) = expected_output_hash(root, rel)?
&& expected == actual
{
judged.hash = Some(expected);
}
match decide_generated_disposal(&judged, actual) {
Disposition::Absent => {}
Disposition::Dispose => removals.push(entry.path.clone()),
Disposition::Excuse => {
if !planned.contains_key(entry.path.as_str()) {
removals.push(entry.path.clone());
}
}
Disposition::Conflict(why) => {
findings.push(GeneratedFinding {
path: entry.path.clone(),
refreshable: planned.contains_key(entry.path.as_str()),
why,
});
}
}
}
if !findings.is_empty() {
findings.sort_by(|a, b| a.path.cmp(&b.path));
return Ok(findings);
}
let mut dirs: Vec<PathBuf> = Vec::new();
for rel in &removals {
let _ = fs::remove_file(appdir.join(rel));
if let Some(parent) = Path::new(rel).parent() {
dirs.push(appdir.join(parent));
}
}
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(appdir.join(GENERATED_FILE))?;
Ok(Vec::new())
}
pub(crate) fn attest_generated(appdir: &Path) -> io::Result<()> {
let mut entries = read_generated(appdir)?;
for entry in &mut entries {
entry.hash = hash_entry(&appdir.join(&entry.path))?;
entry.pending = false;
}
write_generated(appdir, &entries)
}
pub(crate) fn generated_conflict_error(findings: &[GeneratedFinding]) -> io::Error {
const SHOWN: usize = 8;
let mut text = String::from(
"generated files were changed since the build that wrote them; publication stops until each is resolved:\n",
);
for finding in findings.iter().take(SHOWN) {
text.push_str(" ");
text.push_str(&finding_line(finding));
text.push('\n');
}
if findings.len() > SHOWN {
text.push_str(&format!(" and {} more\n", findings.len() - SHOWN));
}
text.push_str(
" for each one, remove the file, or move it to a path the build does not generate to keep it as publisher-owned input\n",
);
io::Error::new(io::ErrorKind::InvalidData, text)
}
fn finding_line(finding: &GeneratedFinding) -> String {
let replaced = if finding.refreshable {
", and this build would have replaced it"
} else {
", and no current output writes it anymore"
};
match &finding.why {
GeneratedConflict::Edited { recorded, actual } => format!(
"{}: the ledger records blake3 {recorded} but the file hashes to {actual}{replaced}",
finding.path
),
GeneratedConflict::Unproven { actual } => format!(
"{}: the ledger entry carries no hash, so the file's blake3 {actual} cannot be proven to be generated output{replaced}",
finding.path
),
}
}
pub fn record_generated(appdir: &Path, paths: Vec<String>) -> io::Result<()> {
let mut merged: std::collections::BTreeMap<String, GeneratedEntry> = read_generated(appdir)?
.into_iter()
.map(|entry| (entry.path.clone(), entry))
.collect();
for path in paths {
if !inside_appdir(&path) {
continue;
}
let pending = is_elf(&appdir.join(&path));
let hash = hash_entry(&appdir.join(&path))?;
merged.insert(
path.clone(),
GeneratedEntry {
path,
hash,
pending,
},
);
}
let entries: Vec<GeneratedEntry> = merged.into_values().collect();
write_generated(appdir, &entries)
}
pub fn remove_generated(appdir: &Path, paths: &[String]) -> io::Result<()> {
let existing = read_generated(appdir)?;
if existing.is_empty() {
return Ok(());
}
let mut merged: std::collections::BTreeMap<String, GeneratedEntry> = existing
.into_iter()
.map(|entry| (entry.path.clone(), entry))
.collect();
for path in paths {
merged.remove(path);
}
let entries: Vec<GeneratedEntry> = merged.into_values().collect();
write_generated(appdir, &entries)
}
fn write_generated(appdir: &Path, entries: &[GeneratedEntry]) -> io::Result<()> {
let record = appdir.join(GENERATED_FILE);
if let Some(parent) = record.parent() {
fs::create_dir_all(parent)?;
}
let mut text = String::from(GENERATED_LEDGER_HEADER);
text.push('\n');
for entry in entries {
text.push_str(&entry.path);
text.push('\n');
if let Some(hash) = entry.hash {
text.push_str(if entry.pending { "#~" } else { "#" });
text.push_str(hash.to_hex().as_ref());
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())
}
pub(crate) 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 {
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)]
pub(crate) mod fixture {
use std::collections::HashSet;
use std::path::Path;
use onelf_sysroot::prune::prune;
use onelf_sysroot::{Database, Package, Policy, Trace};
use super::SysrootUniverse;
pub(crate) fn package(name: &str, files: &[&str]) -> Package {
Package {
name: name.to_string(),
version: "1.0-1".to_string(),
files: files.iter().map(|s| s.to_string()).collect(),
..Default::default()
}
}
pub(crate) fn populate_universe(
root: &Path,
packages: &[Package],
owner: &str,
optional: &[String],
policy: Option<&Policy>,
trace: Option<&Trace>,
) -> SysrootUniverse {
let mut db = Database::default();
for package in packages {
db.insert(package.clone());
}
let closure = db.closure(owner, optional);
let files = db.files_of(&closure);
let pruned = prune(&files, None, policy, trace, &HashSet::new(), None);
SysrootUniverse::admit(root, db, &pruned.kept, &HashSet::new(), &closure.packages)
}
}
#[cfg(test)]
mod tests {
use super::*;
use onelf_sysroot::Package;
#[test]
fn a_hinted_store_is_used_only_when_the_uid_owns_it() {
use std::os::unix::fs::MetadataExt;
let mine = std::env::temp_dir().join(format!("onelf-store-hint-{}", std::process::id()));
fs::create_dir_all(&mine).unwrap();
let me = fs::metadata(&mine).unwrap().uid();
let cache = || Some(PathBuf::from("/cache-root/onelf"));
let fallback = Some(PathBuf::from("/cache-root/onelf/platform"));
assert_eq!(
store_root_with(me, Some(mine.clone()), cache),
Some(mine.clone())
);
let missing = mine.join("not-yet");
assert_eq!(
store_root_with(me, Some(missing.clone()), cache),
Some(missing.clone())
);
assert_eq!(store_root_with(me + 1, Some(mine.clone()), cache), fallback);
assert_eq!(store_root_with(me, None, cache), fallback);
assert_eq!(store_root_with(me, Some(PathBuf::new()), cache), fallback);
if me != 0 {
assert_eq!(store_root_with(0, Some(mine.clone()), cache), fallback);
assert_eq!(store_root_with(0, Some(mine.clone()), || None), None);
}
assert_eq!(store_root_with(0, Some(missing), cache), fallback);
let root_owned = Path::new("/");
if fs::metadata(root_owned).unwrap().uid() == 0 {
assert_eq!(
store_root_with(0, Some(root_owned.to_path_buf()), cache),
Some(root_owned.to_path_buf())
);
}
let shared = Path::new("/tmp");
if fs::metadata(shared).is_ok_and(|md| md.uid() == 0 && md.mode() & 0o002 != 0) {
assert_eq!(
store_root_with(0, Some(shared.to_path_buf()), cache),
fallback
);
}
let _ = fs::remove_dir_all(&mine);
}
#[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();
let hash = hash_entry(&appdir.join("lib/sub/libfoo.so"))
.unwrap()
.unwrap();
fs::write(
appdir.join(GENERATED_FILE),
format!(
"{GENERATED_LEDGER_HEADER}\n\
../outside/keep\n\
lib/../../outside/empty/x\n\
{}\n\
lib/sub/libfoo.so\n\
#{}\n",
root.join("outside/keep").display(),
hash.to_hex()
),
)
.unwrap();
let conflicts = dispose_generated(&appdir, &root, &[]).unwrap();
assert!(conflicts.is_empty(), "{conflicts:?}");
assert!(!appdir.join("lib").exists(), "the emptied directories go");
assert!(appdir.is_dir(), "the AppDir stays");
assert!(!appdir.join(GENERATED_FILE).exists(), "the record goes too");
assert!(root.join("outside/keep").is_file());
assert!(root.join("outside/empty").is_dir());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn an_unchanged_generated_file_is_removed_by_its_recorded_hash() {
let root = std::env::temp_dir().join(format!("onelf-dispose-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let appdir = root.join("appdir");
fs::create_dir_all(appdir.join("lib/sub")).unwrap();
fs::write(appdir.join("lib/sub/cache.bin"), b"generated").unwrap();
record_generated(&appdir, vec!["lib/sub/cache.bin".to_string()]).unwrap();
let conflicts = dispose_generated(&appdir, &root, &[]).unwrap();
assert!(conflicts.is_empty(), "{conflicts:?}");
assert!(!appdir.join("lib/sub/cache.bin").exists());
assert!(!appdir.join("lib").exists(), "the emptied directories go");
assert!(!appdir.join(GENERATED_FILE).exists(), "the record goes too");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn the_anchor_record_survives_the_cleanup_of_generated_ones() {
let root =
std::env::temp_dir().join(format!("onelf-dispose-anchor-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let appdir = root.join("appdir");
fs::create_dir_all(appdir.join("lib")).unwrap();
fs::write(appdir.join("lib/cache.bin"), b"generated").unwrap();
fs::create_dir_all(appdir.join(".onelf")).unwrap();
fs::write(appdir.join(".onelf/anchors"), b"a\tshare/fw").unwrap();
record_generated(
&appdir,
vec![
"lib/cache.bin".to_string(),
onelf_format::anchors::ANCHORS_FILE.to_string(),
],
)
.unwrap();
let conflicts = dispose_generated(&appdir, &root, &[]).unwrap();
assert!(conflicts.is_empty(), "{conflicts:?}");
assert!(!appdir.join("lib/cache.bin").exists());
assert!(!appdir.join("lib").exists(), "the emptied directories go");
assert_eq!(
fs::read(appdir.join(".onelf/anchors")).unwrap(),
b"a\tshare/fw",
"the anchor record stays"
);
assert!(
!appdir.join(GENERATED_FILE).exists(),
"the ledger goes with the cleanup, and population rewrites it"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn the_plugin_dirs_record_survives_the_cleanup_of_generated_ones() {
let root =
std::env::temp_dir().join(format!("onelf-dispose-plugdirs-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let appdir = root.join("appdir");
fs::create_dir_all(appdir.join("lib")).unwrap();
fs::write(appdir.join("lib/cache.bin"), b"generated").unwrap();
fs::create_dir_all(appdir.join(".onelf")).unwrap();
std::fs::write(
appdir.join(".onelf/plugin-dirs"),
"lib/demo\tdemo\t/usr/lib/demo\n",
)
.unwrap();
record_generated(
&appdir,
vec![
"lib/cache.bin".to_string(),
onelf_format::plugindirs::PLUGIN_DIRS_FILE.to_string(),
],
)
.unwrap();
let conflicts = dispose_generated(&appdir, &root, &[]).unwrap();
assert!(conflicts.is_empty(), "{conflicts:?}");
assert!(!appdir.join("lib/cache.bin").exists());
assert!(!appdir.join("lib").exists(), "the emptied directories go");
assert_eq!(
fs::read(appdir.join(".onelf/plugin-dirs")).unwrap(),
b"lib/demo\tdemo\t/usr/lib/demo\n" as &[u8],
"the plugin-dirs record stays"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_publisher_file_survives_the_cleanup_of_generated_ones() {
let root = std::env::temp_dir().join(format!("onelf-publisher-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let appdir = root.join("appdir");
fs::create_dir_all(appdir.join("lib")).unwrap();
fs::write(appdir.join("lib/generated.so"), b"generated").unwrap();
fs::write(appdir.join("lib/mine.so"), b"mine").unwrap();
record_generated(&appdir, vec!["lib/generated.so".to_string()]).unwrap();
let conflicts = dispose_generated(&appdir, &root, &[]).unwrap();
assert!(conflicts.is_empty(), "{conflicts:?}");
assert!(!appdir.join("lib/generated.so").exists());
assert_eq!(fs::read(appdir.join("lib/mine.so")).unwrap(), b"mine");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn an_edited_generated_file_is_kept_and_stops_publication() {
let root = std::env::temp_dir().join(format!("onelf-edited-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let appdir = root.join("appdir");
fs::create_dir_all(appdir.join("lib")).unwrap();
let path = appdir.join("lib/plugin.so");
fs::write(&path, b"generated").unwrap();
record_generated(&appdir, vec!["lib/plugin.so".to_string()]).unwrap();
let recorded = hash_entry(&path).unwrap().unwrap();
fs::write(&path, b"publisher edit").unwrap();
let edited = hash_entry(&path).unwrap().unwrap();
let conflicts = dispose_generated(&appdir, &root, &[]).unwrap();
assert_eq!(conflicts.len(), 1, "{conflicts:?}");
assert!(!conflicts[0].refreshable);
assert_eq!(
conflicts[0].why,
GeneratedConflict::Edited {
recorded,
actual: edited,
}
);
assert_eq!(
fs::read(&path).unwrap(),
b"publisher edit",
"the edit stays"
);
assert!(appdir.join(GENERATED_FILE).is_file(), "nothing was mutated");
let text = generated_conflict_error(&conflicts).to_string();
assert!(text.contains("lib/plugin.so"), "{text}");
assert!(text.contains(recorded.to_hex().as_ref()), "{text}");
assert!(text.contains("remove"), "{text}");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn an_edited_generated_file_the_plan_would_refresh_names_the_replacement() {
let root = std::env::temp_dir().join(format!("onelf-refresh-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let appdir = root.join("appdir");
fs::create_dir_all(appdir.join("lib")).unwrap();
let path = appdir.join("lib/libfoo.so");
fs::write(&path, b"generated").unwrap();
record_generated(&appdir, vec!["lib/libfoo.so".to_string()]).unwrap();
fs::write(&path, b"publisher edit").unwrap();
let kept = vec!["usr/lib/libfoo.so".to_string()];
let conflicts = dispose_generated(&appdir, &root, &kept).unwrap();
assert_eq!(conflicts.len(), 1, "{conflicts:?}");
assert!(conflicts[0].refreshable);
assert!(
generated_conflict_error(&conflicts)
.to_string()
.contains("replaced"),
"the replacement is named"
);
assert_eq!(fs::read(&path).unwrap(), b"publisher edit");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_legacy_record_adopts_a_file_the_plan_reproduces() {
let root = std::env::temp_dir().join(format!("onelf-legacy-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let sysroot = root.join("sysroot");
let appdir = root.join("appdir");
fs::create_dir_all(sysroot.join("usr/lib")).unwrap();
fs::create_dir_all(appdir.join("lib/.onelf")).unwrap();
fs::write(sysroot.join("usr/lib/libfoo.so"), b"same bytes").unwrap();
fs::write(appdir.join("lib/libfoo.so"), b"same bytes").unwrap();
fs::create_dir_all(appdir.join(".onelf")).unwrap();
fs::write(appdir.join(GENERATED_FILE), "lib/libfoo.so\n").unwrap();
let kept = vec!["usr/lib/libfoo.so".to_string()];
let conflicts = dispose_generated(&appdir, &sysroot, &kept).unwrap();
assert!(conflicts.is_empty(), "{conflicts:?}");
assert!(
!appdir.join("lib/libfoo.so").exists(),
"adopted, so it goes"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_legacy_record_without_provenance_is_kept_and_reported() {
let root = std::env::temp_dir().join(format!("onelf-unproven-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let appdir = root.join("appdir");
fs::create_dir_all(appdir.join("lib/.onelf")).unwrap();
fs::write(appdir.join("lib/old.so"), b"something").unwrap();
fs::create_dir_all(appdir.join(".onelf")).unwrap();
fs::write(appdir.join(GENERATED_FILE), "lib/old.so\n").unwrap();
let held = hash_entry(&appdir.join("lib/old.so")).unwrap().unwrap();
let conflicts = dispose_generated(&appdir, &root, &[]).unwrap();
assert_eq!(conflicts.len(), 1, "{conflicts:?}");
assert_eq!(
conflicts[0].why,
GeneratedConflict::Unproven { actual: held }
);
assert!(appdir.join("lib/old.so").is_file(), "the file stays");
assert!(appdir.join(GENERATED_FILE).is_file(), "nothing was mutated");
let text = generated_conflict_error(&conflicts).to_string();
assert!(text.contains("no hash"), "{text}");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_publisher_file_at_a_populated_destination_stops_before_writing() {
let root = std::env::temp_dir().join(format!("onelf-popcollide-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let sysroot = root.join("sysroot");
let appdir = root.join("appdir");
fs::create_dir_all(sysroot.join("usr/lib")).unwrap();
fs::create_dir_all(appdir.join("lib")).unwrap();
fs::write(sysroot.join("usr/lib/libfoo.so.1"), b"sysroot bytes").unwrap();
fs::write(sysroot.join("usr/lib/libsame.so.1"), b"same bytes").unwrap();
fs::write(appdir.join("lib/libfoo.so.1"), b"publisher bytes").unwrap();
fs::write(appdir.join("lib/libsame.so.1"), b"same bytes").unwrap();
let kept = vec![
"usr/lib/libfoo.so.1".to_string(),
"usr/lib/libsame.so.1".to_string(),
];
let findings = population_collisions(&appdir, &sysroot, &kept).unwrap();
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(findings[0].starts_with("lib/libfoo.so.1:"), "{findings:?}");
assert!(findings[0].contains("would replace it"), "{findings:?}");
assert_eq!(
fs::read(appdir.join("lib/libfoo.so.1")).unwrap(),
b"publisher bytes"
);
record_generated(&appdir, vec!["lib/libfoo.so.1".to_string()]).unwrap();
assert!(
population_collisions(&appdir, &sysroot, &kept)
.unwrap()
.is_empty(),
"a recorded path is left to the ledger's disposal"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_mismatched_legacy_file_the_plan_writes_is_an_ownership_conflict() {
let root = std::env::temp_dir().join(format!("onelf-legacyedit-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let sysroot = root.join("sysroot");
let appdir = root.join("appdir");
fs::create_dir_all(sysroot.join("usr/lib")).unwrap();
fs::create_dir_all(appdir.join("lib")).unwrap();
fs::write(sysroot.join("usr/lib/libfoo.so"), b"current output").unwrap();
fs::write(appdir.join("lib/libfoo.so"), b"something else").unwrap();
fs::create_dir_all(appdir.join(".onelf")).unwrap();
fs::write(appdir.join(GENERATED_FILE), "lib/libfoo.so\n").unwrap();
let kept = vec!["usr/lib/libfoo.so".to_string()];
let stale = appdir.join("lib/libfoo.so");
let conflicts = dispose_generated(&appdir, &sysroot, &kept).unwrap();
assert_eq!(conflicts.len(), 1, "{conflicts:?}");
assert_eq!(conflicts[0].path, "lib/libfoo.so");
assert!(conflicts[0].refreshable, "{conflicts:?}");
assert!(
matches!(conflicts[0].why, GeneratedConflict::Unproven { .. }),
"{conflicts:?}"
);
assert_eq!(
fs::read(&stale).unwrap(),
b"something else",
"the unproven file is preserved"
);
assert!(appdir.join(GENERATED_FILE).is_file(), "nothing was mutated");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn the_ledger_round_trips_and_rejects_malformed_records() {
let root = std::env::temp_dir().join(format!("onelf-ledger-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let appdir = root.join("appdir");
fs::create_dir_all(appdir.join("lib")).unwrap();
fs::write(appdir.join("lib/a.so"), b"\x7fELFobject").unwrap();
std::os::unix::fs::symlink("a.so", appdir.join("lib/b.so")).unwrap();
record_generated(
&appdir,
vec!["lib/a.so".to_string(), "lib/b.so".to_string()],
)
.unwrap();
let link_hash = hash_entry(&appdir.join("lib/b.so")).unwrap().unwrap();
let a_hash = hash_entry(&appdir.join("lib/a.so")).unwrap().unwrap();
assert_eq!(
read_generated(&appdir).unwrap(),
vec![
GeneratedEntry {
path: "lib/a.so".to_string(),
hash: Some(a_hash),
pending: true,
},
GeneratedEntry {
path: "lib/b.so".to_string(),
hash: Some(link_hash),
pending: true,
},
],
"an ELF object and the link onto it are pending the rewriting"
);
assert!(
appdir.join(GENERATED_FILE).is_file(),
"the record stays for the next build"
);
fs::write(appdir.join(GENERATED_FILE), "lib/a.so\nlib/b.so\n").unwrap();
assert_eq!(
read_generated(&appdir).unwrap(),
vec![
GeneratedEntry {
path: "lib/a.so".to_string(),
hash: None,
pending: false,
},
GeneratedEntry {
path: "lib/b.so".to_string(),
hash: None,
pending: false,
},
],
"the legacy path-only form parses"
);
fs::write(
appdir.join(GENERATED_FILE),
format!("{GENERATED_LEDGER_HEADER}\nlib/a.so\n#nothex\n"),
)
.unwrap();
let err = read_generated(&appdir).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData, "{err}");
assert!(err.to_string().contains("lib/a.so"), "{err}");
fs::write(
appdir.join(GENERATED_FILE),
format!("{GENERATED_LEDGER_HEADER}\n#0123456789abcdef\n"),
)
.unwrap();
assert_eq!(
read_generated(&appdir).unwrap_err().kind(),
io::ErrorKind::InvalidData,
"a hash line with no path above it"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_pending_entry_is_excused_whatever_the_plan_decides() {
let appdir = |root: &Path| root.join("appdir");
let seed = |root: &Path| {
let appdir = appdir(root);
fs::create_dir_all(appdir.join("lib")).unwrap();
let object = appdir.join("lib/rewritten.so");
fs::write(&object, b"\x7fELFsource").unwrap();
record_generated(&appdir, vec!["lib/rewritten.so".to_string()]).unwrap();
fs::write(&object, b"\x7fELFrewritten").unwrap();
appdir
};
let root = std::env::temp_dir().join(format!("onelf-pending-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let appdir = seed(&root);
let object = appdir.join("lib/rewritten.so");
let conflicts = dispose_generated(&appdir, &root, &[]).unwrap();
assert!(conflicts.is_empty(), "{conflicts:?}");
assert!(!object.exists(), "the excused object still goes");
let _ = fs::remove_dir_all(&root);
let root = std::env::temp_dir().join(format!("onelf-pending-plan-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let appdir = seed(&root);
let object = appdir.join("lib/rewritten.so");
let conflicts =
dispose_generated(&appdir, &root, &["lib/rewritten.so".to_string()]).unwrap();
assert!(conflicts.is_empty(), "{conflicts:?}");
assert!(object.is_file(), "the plan replaces it, nothing else may");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn attesting_pins_the_bytes_the_tree_now_holds() {
let root = std::env::temp_dir().join(format!("onelf-attest-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let appdir = root.join("appdir");
fs::create_dir_all(appdir.join("lib")).unwrap();
let object = appdir.join("lib/app.so");
fs::write(&object, b"\x7fELFsource").unwrap();
record_generated(&appdir, vec!["lib/app.so".to_string()]).unwrap();
assert!(
read_generated(&appdir).unwrap()[0].pending,
"pending at first"
);
fs::write(&object, b"\x7fELFrewritten").unwrap();
attest_generated(&appdir).unwrap();
let final_hash = hash_entry(&object).unwrap().unwrap();
assert_eq!(
read_generated(&appdir).unwrap(),
vec![GeneratedEntry {
path: "lib/app.so".to_string(),
hash: Some(final_hash),
pending: false,
}],
"the record now attests the rewritten bytes"
);
fs::write(&object, b"\x7fELFpublisher edit").unwrap();
let conflicts = dispose_generated(&appdir, &root, &[]).unwrap();
assert_eq!(conflicts.len(), 1, "{conflicts:?}");
assert_eq!(fs::read(&object).unwrap(), b"\x7fELFpublisher edit");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn the_disposition_is_decided_by_content() {
let recorded = blake3::hash(b"recorded");
let other = blake3::hash(b"other");
let entry = |hash: Option<blake3::Hash>, pending: bool| GeneratedEntry {
path: "lib/x.so".to_string(),
hash,
pending,
};
assert_eq!(
decide_generated_disposal(&entry(None, false), None),
Disposition::Absent
);
assert_eq!(
decide_generated_disposal(&entry(Some(recorded), false), None),
Disposition::Absent
);
assert_eq!(
decide_generated_disposal(&entry(Some(recorded), false), Some(recorded)),
Disposition::Dispose
);
assert_eq!(
decide_generated_disposal(&entry(Some(recorded), false), Some(other)),
Disposition::Conflict(GeneratedConflict::Edited {
recorded,
actual: other,
})
);
assert_eq!(
decide_generated_disposal(&entry(None, false), Some(other)),
Disposition::Conflict(GeneratedConflict::Unproven { actual: other })
);
assert_eq!(
decide_generated_disposal(&entry(Some(recorded), true), Some(recorded)),
Disposition::Dispose
);
assert_eq!(
decide_generated_disposal(&entry(Some(recorded), true), Some(other)),
Disposition::Excuse
);
}
#[test]
fn the_hash_tags_what_a_path_holds() {
let root = std::env::temp_dir().join(format!("onelf-hash-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("dir")).unwrap();
let file = root.join("file");
fs::write(&file, b"same text").unwrap();
std::os::unix::fs::symlink("file", root.join("link")).unwrap();
assert_eq!(hash_entry(&root.join("absent")).unwrap(), None);
let file_hash = hash_entry(&file).unwrap().unwrap();
let link_hash = hash_entry(&root.join("link")).unwrap().unwrap();
assert_ne!(link_hash, file_hash, "a link is not the file it names");
let dir_hash = hash_entry(&root.join("dir")).unwrap().unwrap();
assert_ne!(dir_hash, file_hash);
fs::write(root.join("other"), b"same text").unwrap();
assert_eq!(
hash_entry(&root.join("other")).unwrap().unwrap(),
file_hash,
"the same bytes hash the same"
);
std::os::unix::fs::symlink("file", root.join("link2")).unwrap();
assert_eq!(
hash_entry(&root.join("link2")).unwrap().unwrap(),
link_hash,
"the same target hashes the same"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn recording_again_refreshes_only_the_paths_named() {
let root = std::env::temp_dir().join(format!("onelf-rerecord-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let appdir = root.join("appdir");
fs::create_dir_all(appdir.join("lib")).unwrap();
fs::write(appdir.join("lib/a.so"), b"a1").unwrap();
fs::write(appdir.join("lib/b.so"), b"b1").unwrap();
record_generated(
&appdir,
vec!["lib/a.so".to_string(), "lib/b.so".to_string()],
)
.unwrap();
fs::write(appdir.join("lib/a.so"), b"a2").unwrap();
fs::write(appdir.join("lib/b.so"), b"b2").unwrap();
record_generated(&appdir, vec!["lib/a.so".to_string()]).unwrap();
let tagged = |bytes: &[u8]| {
let mut hasher = blake3::Hasher::new();
hasher.update(b"file ");
hasher.update(bytes);
hasher.finalize()
};
let entries = read_generated(&appdir).unwrap();
assert_eq!(
entries[0].hash,
Some(tagged(b"a2")),
"the path named again is hashed anew"
);
assert_eq!(
entries[1].hash,
Some(tagged(b"b1")),
"the path left alone keeps its record"
);
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);
}
fn stored_package(files: &[(&str, &[u8])]) -> Vec<u8> {
use onelf_format::{Block, Entry, EntryKind, Flags, Footer, Manifest, ManifestHeader};
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 pinned_build(tag: &str, files: &[(&str, &[u8])]) -> (PathBuf, SharedSet) {
let dir = std::env::temp_dir().join(format!("onelf-vset-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let bytes = stored_package(files);
let path = dir.join("set.onelf");
fs::write(&path, &bytes).unwrap();
let set = 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)
}
#[test]
fn verified_reads_answer_a_pinned_build() {
let (dir, set) = pinned_build(
"read",
&[
("share/alsa/alsa.conf", b"conf\n"),
("lib/gstreamer-1.0/gst-plugin-scanner", b"#!/bin/sh\n"),
],
);
let mut read = VerifiedSet::open(&set).unwrap();
assert_eq!(read.set(), "audio");
assert_eq!(read.build(), set.url.strip_prefix("file://").unwrap());
assert_eq!(
read.file_paths(),
[
"share/alsa/alsa.conf",
"lib/gstreamer-1.0/gst-plugin-scanner"
]
);
assert_eq!(
read.read("share/alsa/alsa.conf").unwrap(),
Some(b"conf\n".to_vec())
);
assert_eq!(read.read("share/alsa/absent.conf").unwrap(), None);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_verified_reader_refuses_a_build_that_is_not_the_pin() {
let (dir, set) = pinned_build("unpinned", &[("share/alsa/alsa.conf", b"conf\n")]);
let path = PathBuf::from(set.url.strip_prefix("file://").unwrap());
let original = fs::read(&path).unwrap();
let mut flipped = original.clone();
let at = flipped.len() - onelf_format::FOOTER_SIZE - 1;
flipped[at] ^= 0xff;
fs::write(&path, &flipped).unwrap();
let err = VerifiedSet::open(&set).err().unwrap();
assert!(err.contains("not the pinned build"), "{err}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_verified_read_refuses_tampered_content() {
let (dir, set) = pinned_build("tampered", &[("share/alsa/alsa.conf", b"conf\n")]);
let path = PathBuf::from(set.url.strip_prefix("file://").unwrap());
let original = fs::read(&path).unwrap();
let mut flipped = original.clone();
let at = flipped.len() - onelf_format::FOOTER_SIZE - 1;
flipped[at] ^= 0xff;
fs::write(&path, &flipped).unwrap();
let corrupt = SharedSet {
blake3: blake3::hash(&flipped).to_hex().to_string(),
..set
};
let mut read = VerifiedSet::open(&corrupt).unwrap();
let err = read.read("share/alsa/alsa.conf").err().unwrap();
assert!(err.contains("tampered or corrupt package"), "{err}");
assert!(err.contains("alsa.conf"), "{err}");
let _ = fs::remove_dir_all(&dir);
}
fn soname_object(soname: &str) -> Vec<u8> {
let strtab = {
let mut s = vec![0u8];
s.extend_from_slice(soname.as_bytes());
s.push(0);
s
};
let dyn_off = 64 + 2 * 56;
let dyn_size = 4 * 16;
let str_off = dyn_off + dyn_size;
let total = str_off + strtab.len();
let strtab_value = str_off 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());
elf.extend_from_slice(&5u64.to_le_bytes());
elf.extend_from_slice(&strtab_value.to_le_bytes());
elf.extend_from_slice(&10u64.to_le_bytes());
elf.extend_from_slice(&(strtab.len() as u64).to_le_bytes());
elf.extend_from_slice(&14u64.to_le_bytes());
elf.extend_from_slice(&1u64.to_le_bytes());
elf.extend_from_slice(&0u64.to_le_bytes());
elf.extend_from_slice(&0u64.to_le_bytes());
elf.extend_from_slice(&strtab);
assert_eq!(elf.len(), total);
elf
}
#[test]
fn the_provider_index_records_verified_sonames() {
let dir = std::env::temp_dir().join(format!("onelf-pindex-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("lib/plugins")).unwrap();
fs::write(
dir.join("lib/libcraft.so.9.1"),
soname_object("libcraft.so.9"),
)
.unwrap();
std::os::unix::fs::symlink("libcraft.so.9.1", dir.join("lib/libcraft.so.9")).unwrap();
fs::write(dir.join("lib/plugins/libcraft.so.9"), b"not an elf object").unwrap();
fs::write(dir.join("lib/readme"), b"neither").unwrap();
let bytes = encode_provider_index(&dir, "gtk").unwrap();
let index = onelf_format::provider_index::ProviderIndex::decode(&bytes).unwrap();
assert_eq!(index.set, "gtk");
assert_eq!(index.exports, ["libcraft.so.9"]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_published_index_must_match_the_verified_exports() {
let exports = vec!["liba.so.1".to_owned()];
let index = onelf_format::provider_index::ProviderIndex::new("gtk", &exports).unwrap();
check_index(&index, "gtk", &exports).unwrap();
assert!(check_index(&index, "qt", &exports).is_err());
let other = vec!["libb.so.2".to_owned()];
assert!(check_index(&index, "gtk", &other).is_err());
let mut extra = exports.clone();
extra.push("libb.so.2".to_owned());
assert!(check_index(&index, "gtk", &extra).is_err());
assert!(check_index(&index, "gtk", &[]).is_err());
}
fn selectable_sysroot(root: &Path) -> Vec<Package> {
let mut app = fixture::package("app", &["usr/bin/app"]);
app.depends = vec!["glib".into()];
let glib = fixture::package(
"glib",
&[
"usr/lib/libglib-2.0.so.0",
"usr/share/glib-2.0/schemas/gschemas.compiled",
],
);
let extra = fixture::package("extra", &["usr/lib/gstreamer-1.0/libgstextra.so"]);
for package in [&app, &glib, &extra] {
for file in &package.files {
let path = root.join(file);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, b"x").unwrap();
}
}
vec![app, glib, extra]
}
#[test]
fn an_unselected_optional_package_is_never_expanded() {
let dir =
std::env::temp_dir().join(format!("onelf-universe-{}-{}", std::process::id(), line!()));
let _ = fs::remove_dir_all(&dir);
let root = dir.join("sys");
let packages = selectable_sysroot(&root);
let universe = fixture::populate_universe(&root, &packages, "app", &[], None, None);
assert_eq!(universe.root(), root);
assert!(
!universe.admits("usr/lib/gstreamer-1.0"),
"the plugin directory of an unselected package is not admitted"
);
assert_eq!(
universe.admitted_under("usr/lib/gstreamer-1.0"),
Vec::<String>::new(),
"discovery never walks into an unselected package"
);
assert_eq!(
universe.refusal("usr/lib/gstreamer-1.0/libgstextra.so"),
Refusal::UnselectedPackage {
owner: "extra".to_string()
}
);
assert!(
universe.admits("usr/lib/libglib-2.0.so.0"),
"the selected closure is admitted"
);
let named = vec!["extra".to_string()];
let universe = fixture::populate_universe(&root, &packages, "app", &named, None, None);
assert!(
universe.admits("usr/lib/gstreamer-1.0"),
"an explicitly selected package is admitted"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn policy_and_trace_pruned_content_is_never_resurrected() {
let dir =
std::env::temp_dir().join(format!("onelf-universe-{}-{}", std::process::id(), line!()));
let _ = fs::remove_dir_all(&dir);
let root = dir.join("sys");
let mut packages = selectable_sysroot(&root);
let mut gst = fixture::package(
"gst",
&[
"usr/lib/gstreamer-1.0/libgstcore.so",
"usr/lib/gstreamer-1.0/libgstbad.so",
"usr/share/gstreamer-1.0/taglists.def",
],
);
gst.depends = vec!["glib".into()];
for file in &gst.files {
let path = root.join(file);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, b"x").unwrap();
}
packages.push(gst);
packages[0].depends.push("gst".into());
let policy_path = dir.join("policy.txt");
fs::write(&policy_path, "usr/lib/gstreamer-1.0/libgstbad.so\n").unwrap();
let policy = Policy::load(&policy_path).unwrap();
let universe =
fixture::populate_universe(&root, &packages, "app", &[], Some(&policy), None);
assert_eq!(
universe.refusal("usr/lib/gstreamer-1.0/libgstbad.so"),
Refusal::Pruned {
owner: "gst".to_string()
},
"a policy removal is named as one, not as absence"
);
assert_eq!(
universe.admitted_under("usr/lib/gstreamer-1.0"),
["usr/lib/gstreamer-1.0/libgstcore.so"],
"the surviving members of a pruned directory stay admitted"
);
let trace = Trace::from_paths(vec![
"/usr/bin/app".into(),
"/usr/lib/gstreamer-1.0/libgstcore.so".into(),
]);
let universe = fixture::populate_universe(&root, &packages, "app", &[], None, Some(&trace));
assert_eq!(
universe.refusal("usr/share/gstreamer-1.0/taglists.def"),
Refusal::Pruned {
owner: "gst".to_string()
},
"a trace removal is named as one too"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn host_package_files_and_unowned_paths_are_refused_by_reason() {
let root = Path::new("/fixture").join("sys");
let gst = fixture::package(
"gst",
&[
"usr/lib/libgstreamer-1.0.so.0",
"usr/lib/libnvidia-glcore.so.1",
],
);
let mut left_out = HashSet::new();
left_out.insert("usr/lib/libnvidia-glcore.so.1".to_string());
let kept = vec!["usr/lib/libgstreamer-1.0.so.0".to_string()];
let universe = SysrootUniverse::admit(
&root,
{
let mut db = Database::default();
db.insert(gst);
db
},
&kept,
&left_out,
&["gst".to_string()],
);
assert_eq!(
universe.refusal("usr/lib/libnvidia-glcore.so.1"),
Refusal::HostPackage {
owner: "gst".to_string()
},
"the package left to the host is the one named"
);
assert!(!universe.admits("usr/lib/libnvidia-glcore.so.1"));
assert_eq!(universe.refusal("usr/share/nothing"), Refusal::Absent);
}
#[test]
fn a_directory_is_admitted_only_while_it_holds_admitted_content() {
let dir =
std::env::temp_dir().join(format!("onelf-universe-{}-{}", std::process::id(), line!()));
let _ = fs::remove_dir_all(&dir);
let root = dir.join("sys");
let mut packages = selectable_sysroot(&root);
let mut gst = fixture::package(
"gst",
&[
"usr/lib/gstreamer-1.0/libgstcore.so",
"usr/lib/gstreamer-1.0/libgstbad.so",
],
);
gst.depends = vec!["glib".into()];
for file in &gst.files {
let path = root.join(file);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, b"x").unwrap();
}
packages.push(gst);
let policy_path = dir.join("policy.txt");
fs::write(&policy_path, "usr/lib/gstreamer-1.0/**\n").unwrap();
let policy = Policy::load(&policy_path).unwrap();
let universe =
fixture::populate_universe(&root, &packages, "app", &[], Some(&policy), None);
assert!(
!universe.admits("usr/lib/gstreamer-1.0"),
"a directory whose every file was pruned holds nothing admitted"
);
let _ = fs::remove_dir_all(&dir);
}
}