use std::io;
use std::os::unix::fs::PermissionsExt;
use std::path::{Component, Path, PathBuf};
use onelf_format::WorkingDir;
use onelf_format::elf;
use crate::bundle;
use crate::pack;
use crate::recipe;
use crate::sysroot_cmd;
pub(crate) const RUNTIME_BINARY_SLIM: &[u8] = include_bytes!(env!("ONELF_RT_PATH"));
pub(crate) const RUNTIME_BINARY_UPDATE: &[u8] = include_bytes!(env!("ONELF_RT_UPDATE_PATH"));
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entrypoint {
pub name: String,
pub path: String,
pub args: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackageRequest {
pub command: String,
pub name: Option<String>,
pub package_info: Option<String>,
pub memfd: Option<bool>,
pub working_dir: WorkingDir,
pub host_libs: pack::HostLibs,
pub cache: bool,
pub prefer_cache: bool,
pub needs_setuid: bool,
pub exclude: Vec<String>,
pub mtime: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompressionRequest {
pub level: i32,
pub block_size: u64,
pub payload: pack::Payload,
pub use_dict: bool,
pub no_compress: bool,
pub profile: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateRequest {
pub url: Option<String>,
pub key_path: Option<PathBuf>,
pub revision: Option<u64>,
pub unsigned: bool,
pub embed: bool,
}
impl UpdateRequest {
pub fn read_key(&self) -> io::Result<Option<Vec<u8>>> {
self.key_path
.as_ref()
.map(|path| {
std::fs::read(path).map_err(|e| {
io::Error::new(
e.kind(),
format!("recipe [update] key {}: {e}", path.display()),
)
})
})
.transpose()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BinaryImport {
pub source: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedSelection {
pub name: String,
pub sets: recipe::SetSelection,
}
impl ResolvedSelection {
pub fn is_explicit(&self) -> bool {
self.sets.is_explicit()
}
}
#[derive(Debug, Clone)]
pub struct BundleRequest {
pub skip: bool,
pub gl: bool,
pub dri: bool,
pub vulkan: bool,
pub wayland: bool,
pub gtk: bool,
pub no_gl: bool,
pub no_dri: bool,
pub no_vulkan: bool,
pub no_wayland: bool,
pub no_gtk: bool,
pub strip: bool,
pub strict_libc: bool,
pub scan_dlopen: bool,
pub dlopen_extra: Vec<String>,
pub trace_run: bool,
pub trace_seconds: u64,
pub trace_args: Vec<String>,
pub trim_inferred: bool,
pub search_paths: Vec<PathBuf>,
pub exclude: Vec<String>,
pub include: Vec<String>,
pub sysroot: Option<bundle::SysrootOptions>,
pub sets: Vec<bundle::sysroot::SharedSet>,
pub platform_pin: Option<onelf_sysroot::platform::Pin>,
pub rules: crate::rules::RuleCatalog,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
HostScan,
SysrootDatabase,
Unchecked,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssemblyReport {
pub source: Source,
pub frameworks: Vec<&'static str>,
pub sets: Vec<String>,
pub active_rules: Vec<bundle::RuleActivation>,
pub pinned_gl: bool,
pub prepared: Vec<bundle::PreparedPin>,
pub findings: Vec<bundle::Finding>,
pub inferred_pairs: Vec<onelf_format::paths::PathPair>,
}
impl AssemblyReport {
pub fn summary_lines(&self) -> Vec<String> {
let mut lines = Vec::new();
match self.source {
Source::SysrootDatabase => lines.push(
"Source: sysroot package database; the dependency closure was \
audited against the selected source"
.to_string(),
),
Source::HostScan => lines.push(
"Source: host scan; unresolved required dependencies fail the \
build, host-platform providers are reported"
.to_string(),
),
Source::Unchecked => lines.push(
"Source: unchecked; the tree stands as authored, no scan or \
dependency audit ran, and nothing here claims it complete"
.to_string(),
),
}
if !self.frameworks.is_empty() {
lines.push(format!(
"Frameworks requested: {}",
self.frameworks.join(", ")
));
}
let mut rules: Vec<&bundle::RuleActivation> = self.active_rules.iter().collect();
rules.sort_by(|a, b| (&a.rule, &a.reason).cmp(&(&b.rule, &b.reason)));
for rule in rules {
let groups = if rule.groups.is_empty() {
String::new()
} else {
format!("; groups: {}", rule.groups.join(", "))
};
lines.push(format!(
"Rule {} activated by {}{groups}",
rule.rule, rule.reason
));
}
if !self.sets.is_empty() {
lines.push(format!("Shared sets: {}", self.sets.join(", ")));
}
if self.pinned_gl {
lines.push("Pinned GL build: recorded for the launch to fetch".to_string());
}
lines.push(
"Not inferred: computed plugin paths, script subprocesses, \
application assets, kernel features and external services are \
not audited"
.to_string(),
);
lines
}
}
#[derive(Debug)]
pub struct AssemblyRequest {
pub directory: PathBuf,
pub output: PathBuf,
pub package: PackageRequest,
pub entrypoints: Vec<Entrypoint>,
pub default_entrypoint: Option<String>,
pub lib_dirs: Vec<String>,
pub compression: CompressionRequest,
pub update: UpdateRequest,
pub env: Vec<(String, String)>,
pub paths: Vec<(String, String)>,
pub preload: Vec<String>,
pub input: Option<BinaryImport>,
pub selections: Vec<ResolvedSelection>,
pub bundle: BundleRequest,
}
impl AssemblyRequest {
pub fn plan(
recipe_path: &Path,
recipe: recipe::Recipe,
output_override: Option<&Path>,
) -> io::Result<Self> {
let selections = recipe::SetSelections::normalize(&recipe)?;
let dir = recipe_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let recipe::Recipe {
package,
entrypoint,
compression,
update,
bundle: rb,
sysroot,
env,
paths,
preload,
input,
} = recipe;
refuse_library_search_env(recipe_path, &env)?;
let paths: Vec<(String, String)> = paths.into_iter().collect();
crate::pack::paths_record(&crate::pack::path_pairs(&paths))
.map_err(|e| io::Error::new(e.kind(), format!("{}: {e}", recipe_path.display())))?;
let rules =
crate::rules::RuleCatalog::load(recipe_path, &dir, &rb.rules, &rb.disable_rules)?;
let import = match input
.as_ref()
.and_then(|i| i.binary.as_ref().map(|b| (i, b)))
{
None => None,
Some((declared, binary)) => {
declared.validate()?;
if sysroot.as_ref().is_some_and(|sr| sr.path.is_some()) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"{}: [input] binary and [sysroot] path conflict: a build \
takes its contents from one authoritative source, so \
name one of them",
recipe_path.display()
),
));
}
if rb.skip {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"{}: [input] binary conflicts with [bundle] skip: skip \
expects an already prepared tree, so there is nothing \
to import into",
recipe_path.display()
),
));
}
let destination = dir.join(import_destination(&package.command)?);
let source = dir.join(binary);
let why = "[input] binary names a bare executable file to import; \
archives, package-manager identifiers and source trees \
are not supported inputs";
let meta = std::fs::metadata(&source).map_err(|e| {
io::Error::new(
e.kind(),
format!(
"{}: [input] binary {}: {e}. {why}",
recipe_path.display(),
source.display()
),
)
})?;
if !meta.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"{}: [input] binary {}: not a file. {why}",
recipe_path.display(),
source.display()
),
));
}
if same_file(&source, &destination) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"{}: [input] binary {} is the same file as the package \
command {}, so importing it would rewrite the source. \
Point [input] binary at a file outside the command path, \
or drop [input] when the AppDir already carries the command",
recipe_path.display(),
source.display(),
package.command
),
));
}
Some(BinaryImport { source })
}
};
let frameworks = framework_names(rb.gl, rb.dri, rb.vulkan, rb.wayland, rb.gtk);
let sysroot_db = sysroot.as_ref().is_some_and(|sr| sr.path.is_some());
if sysroot_db && !frameworks.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"{}: [bundle] {} cannot be used with [sysroot]. A sysroot \
build takes the bundle's contents from the sysroot and does \
not use the framework bundlers",
recipe_path.display(),
frameworks.join(", ")
),
));
}
let mtime = package
.mtime
.or((sysroot.is_some() && pack::source_date_epoch().is_none()).then_some(0));
let (sysroot, sets, platform_pin) = match sysroot {
Some(sr) => {
let resolved = sysroot_from_recipe(&dir, &package.command, &sr)?;
(resolved.root, resolved.sets, resolved.pin)
}
None => (None, Vec::new(), None),
};
let output = match output_override {
Some(o) => o.to_path_buf(),
None => match package.output.clone() {
Some(o) if o.is_absolute() => o,
Some(o) => dir.join(o),
None => {
let name = package.name.clone().unwrap_or_else(|| {
package
.command
.rsplit('/')
.next()
.unwrap_or("app")
.to_string()
});
dir.join(format!("{name}.onelf"))
}
},
};
let default_entrypoint = entrypoint
.iter()
.find(|e| e.default)
.map(|e| e.name.clone());
let entrypoints: Vec<Entrypoint> = entrypoint
.into_iter()
.map(|e| Entrypoint {
name: e.name,
path: e.path,
args: e.args,
})
.collect();
let selections = resolve_selections(
&selections,
&package.command,
&default_entrypoint,
&entrypoints,
)?;
let lib_dirs = rb
.lib_dirs
.clone()
.unwrap_or_else(|| vec!["auto".to_string()]);
let update_url = update.as_ref().map(|u| u.url.clone());
let update_key_path = update
.as_ref()
.and_then(|u| u.key.clone())
.map(|k| if k.is_absolute() { k } else { dir.join(k) });
let update_revision = update.as_ref().and_then(|u| u.revision);
let update_unsigned = update.as_ref().map(|u| u.unsigned).unwrap_or(false);
let embed_updater = update.as_ref().and_then(|u| u.embed).unwrap_or(true);
let search_paths: Vec<PathBuf> = rb.search_paths.iter().map(|p| dir.join(p)).collect();
let package_info = build_package_info(&package);
let profile = compression.prefetch.map(|p| dir.join(p));
let payload: pack::Payload = compression.payload.into();
Ok(AssemblyRequest {
directory: dir,
output,
package: PackageRequest {
command: package.command,
name: package.name,
package_info,
memfd: package.memfd,
working_dir: package.working_dir.into(),
host_libs: package
.host_libs
.map(Into::into)
.unwrap_or(pack::HostLibs::Auto),
cache: package.cache,
prefer_cache: package.prefer_cache,
needs_setuid: package.needs_setuid,
exclude: package.exclude,
mtime,
},
entrypoints,
default_entrypoint,
lib_dirs,
compression: CompressionRequest {
level: compression.level,
block_size: compression
.block_size
.unwrap_or(payload.default_block_size()),
payload,
use_dict: compression.dict,
no_compress: compression.store,
profile,
},
update: UpdateRequest {
url: update_url,
key_path: update_key_path,
revision: update_revision,
unsigned: update_unsigned,
embed: embed_updater,
},
env: env.into_iter().collect(),
paths,
preload,
input: import,
selections,
bundle: BundleRequest {
skip: rb.skip && sysroot.is_none(),
gl: rb.gl,
dri: rb.dri,
vulkan: rb.vulkan,
wayland: rb.wayland,
gtk: rb.gtk,
no_gl: rb.no_gl,
no_dri: rb.no_dri,
no_vulkan: rb.no_vulkan,
no_wayland: rb.no_wayland,
no_gtk: rb.no_gtk,
strip: rb.strip,
strict_libc: rb.strict_libc,
scan_dlopen: rb.scan_dlopen,
dlopen_extra: rb.dlopen,
trace_run: rb.trace_run,
trace_seconds: rb.trace_seconds.unwrap_or(5),
trace_args: rb.trace_args,
trim_inferred: rb.trim_inferred,
search_paths,
exclude: rb.exclude,
include: rb.include,
sysroot,
sets,
platform_pin,
rules,
},
})
}
pub fn stage(&self) -> io::Result<AssemblyReport> {
if let Some(import) = &self.input {
import_binary(&self.directory, import, &self.package.command)?;
}
let sets = set_names(&self.bundle.sets);
if self.bundle.skip {
bundle::sysroot::record_sets(
&self.directory,
&self.bundle.sets,
self.bundle.platform_pin.as_ref(),
)?;
return Ok(AssemblyReport {
source: Source::Unchecked,
frameworks: Vec::new(),
sets,
pinned_gl: self.bundle.platform_pin.is_some(),
active_rules: Vec::new(),
prepared: Vec::new(),
findings: Vec::new(),
inferred_pairs: Vec::new(),
});
}
let bundled = {
let _source_out_of_walk = self.exclude_declared_source();
bundle::bundle_libs(&self.bundle_options())
};
if let Err(e) = bundled {
if let Some(import) = &self.input
&& let Err(why) = import_destination(&self.package.command).and_then(|dest| {
let snapshot = hash_file(&import.source)?;
place_import(&self.directory, &import.source, &dest, snapshot)
})
{
eprintln!("warning: the import could not be restored after the failure: {why}");
}
return Err(e);
}
if self.input.is_some() {
bundle::sysroot::attest_generated(&self.directory)?;
}
Ok(AssemblyReport {
source: if self.bundle.sysroot.is_some() {
Source::SysrootDatabase
} else {
Source::HostScan
},
frameworks: if self.bundle.sysroot.is_none() {
framework_names(
self.bundle.gl,
self.bundle.dri,
self.bundle.vulkan,
self.bundle.wayland,
self.bundle.gtk,
)
} else {
Vec::new()
},
sets,
pinned_gl: self.bundle.platform_pin.is_some(),
active_rules: bundle::take_activations(),
prepared: bundle::take_prepared(),
findings: bundle::take_findings(),
inferred_pairs: bundle::take_pairs(),
})
}
pub fn dry_run(&self) -> io::Result<AssemblyReport> {
let _no_fetch = bundle::sysroot::forbid_fetch();
let sets = set_names(&self.bundle.sets);
if self.bundle.skip {
return Ok(AssemblyReport {
source: Source::Unchecked,
frameworks: Vec::new(),
sets,
pinned_gl: self.bundle.platform_pin.is_some(),
active_rules: Vec::new(),
prepared: Vec::new(),
findings: Vec::new(),
inferred_pairs: Vec::new(),
});
}
let mut options = self.bundle_options();
options.dry_run = true;
let _scratch = match &self.input {
Some(import) => {
let scratch = bundle::ScratchTree::copy_of(&self.directory)?;
if let Ok(rel) = import.source.strip_prefix(&self.directory) {
let _ = std::fs::remove_file(scratch.path().join(rel));
}
import_binary(scratch.path(), import, &self.package.command)?;
bundle::record_finding(bundle::Finding::new(
self.package.command.clone(),
bundle::Outcome::Selected,
format!(
"imported from {}; the scan reads the import in a scratch copy",
import.source.display()
),
));
options.directory = scratch.path().to_path_buf();
Some(scratch)
}
None => None,
};
bundle::bundle_libs(&options)?;
Ok(AssemblyReport {
source: if self.bundle.sysroot.is_some() {
Source::SysrootDatabase
} else {
Source::HostScan
},
frameworks: if self.bundle.sysroot.is_none() {
framework_names(
self.bundle.gl,
self.bundle.dri,
self.bundle.vulkan,
self.bundle.wayland,
self.bundle.gtk,
)
} else {
Vec::new()
},
sets,
pinned_gl: self.bundle.platform_pin.is_some(),
active_rules: bundle::take_activations(),
prepared: bundle::take_prepared(),
findings: bundle::take_findings(),
inferred_pairs: bundle::take_pairs(),
})
}
fn bundle_options(&self) -> bundle::BundleOptions {
bundle::BundleOptions {
directory: self.directory.clone(),
target: None,
primary: Some(PathBuf::from(&self.package.command)),
lib_dir: PathBuf::from("lib"),
lib_dirs: self.lib_dirs.clone(),
recipe_pairs: crate::pack::path_pairs(&self.paths),
entrypoint_selections: self.audit_entrypoints(),
exclude: self.bundle.exclude.clone(),
include: self.bundle.include.clone(),
search_path: self.bundle.search_paths.clone(),
dry_run: false,
recursive: true,
gl: self.bundle.gl,
dri: self.bundle.dri,
vulkan: self.bundle.vulkan,
wayland: self.bundle.wayland,
gtk: self.bundle.gtk,
no_gl: self.bundle.no_gl,
no_dri: self.bundle.no_dri,
no_vulkan: self.bundle.no_vulkan,
no_wayland: self.bundle.no_wayland,
no_gtk: self.bundle.no_gtk,
strip: self.bundle.strip,
strict_libc: self.bundle.strict_libc,
scan_dlopen: self.bundle.scan_dlopen,
dlopen_extra: self.bundle.dlopen_extra.clone(),
trace_run: self.bundle.trace_run,
trace_seconds: self.bundle.trace_seconds,
trace_args: self.bundle.trace_args.clone(),
trim_inferred: self.bundle.trim_inferred,
sysroot: self.bundle.sysroot.clone(),
sets: self.bundle.sets.clone(),
platform_pin: self.bundle.platform_pin.clone(),
rules: self.bundle.rules.clone(),
strict: true,
}
}
fn exclude_declared_source(&self) -> bundle::WalkExclusion {
bundle::exclude_from_walk(self.input.iter().map(|i| i.source.clone()).collect())
}
fn audit_entrypoints(&self) -> Vec<bundle::AuditedEntrypoint> {
self.selections
.iter()
.map(|row| {
let path = self
.entrypoints
.iter()
.find(|e| e.name == row.name)
.map(|e| PathBuf::from(&e.path))
.unwrap_or_else(|| PathBuf::from(&self.package.command));
bundle::AuditedEntrypoint {
name: row.name.clone(),
path: Some(path),
selection: row.sets.clone(),
}
})
.collect()
}
pub fn pack_options(
&self,
update_key: Option<Vec<u8>>,
runtime_note: elf::RuntimeNote,
profile: Vec<(String, u64)>,
) -> pack::PackOptions {
pack::PackOptions {
directory: self.directory.clone(),
output: self.output.clone(),
command: self.package.command.clone(),
name: self.package.name.clone(),
entrypoints: self
.entrypoints
.iter()
.map(|e| (e.name.clone(), e.path.clone(), e.args.clone()))
.collect(),
default_entrypoint: self.default_entrypoint.clone(),
lib_dirs: self.lib_dirs.clone(),
level: self.compression.level,
block_size: self.compression.block_size,
payload: self.compression.payload,
use_dict: self.compression.use_dict,
no_compress: self.compression.no_compress,
memfd: self.package.memfd,
working_dir: self.package.working_dir,
host_libs: self.package.host_libs,
cache: self.package.cache,
prefer_cache: self.package.prefer_cache,
update_url: self.update.url.clone(),
embed_updater: self.update.embed,
update_key,
update_revision: self.update.revision,
update_unsigned: self.update.unsigned,
exclude: self.package.exclude.clone(),
package_info: self.package.package_info.clone(),
mtime: self.package.mtime,
env: self.env.clone(),
paths: self.paths.clone(),
inferred_pairs: Vec::new(),
preload: self.preload.clone(),
needs_setuid: self.package.needs_setuid,
profile,
provider_index: None,
metadata: pack::MetadataPayload::default(),
runtime_note,
}
}
}
pub fn refuse_library_search_env(
recipe_path: &Path,
env: &std::collections::BTreeMap<String, String>,
) -> io::Result<()> {
for name in env.keys() {
if let Some(why) = onelf_rt::env::library_search_override(name) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"{}: [env] {name}: refused: {why}. Bundle the library with the \
AppDir or let the resolver choose it",
recipe_path.display()
),
));
}
}
Ok(())
}
fn resolve_selections(
selections: &recipe::SetSelections,
command: &str,
default_entrypoint: &Option<String>,
entrypoints: &[Entrypoint],
) -> io::Result<Vec<ResolvedSelection>> {
let names = pack::final_entrypoint_names(
command,
default_entrypoint.as_deref(),
entrypoints.iter().map(|e| e.name.as_str()),
);
let mut table = Vec::with_capacity(names.len());
for (index, name) in names.into_iter().enumerate() {
let sets = if index == 0 && default_entrypoint.is_none() {
selections.primary.clone()
} else {
set_selection_named(&selections.named, &name)?
};
table.push(ResolvedSelection { name, sets });
}
if let Some(dup) = table
.iter()
.enumerate()
.find(|(i, e)| table[..*i].iter().any(|m| m.name == e.name))
.map(|(_, e)| e.name.clone())
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("duplicate entrypoint name '{dup}'"),
));
}
Ok(table)
}
fn set_selection_named(
named: &std::collections::BTreeMap<String, recipe::SetSelection>,
name: &str,
) -> io::Result<recipe::SetSelection> {
named.get(name).cloned().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("entrypoint '{name}': no declared set selection"),
)
})
}
fn import_destination(command: &str) -> io::Result<PathBuf> {
let mut out = PathBuf::new();
for component in Path::new(command).components() {
match component {
Component::Normal(part) => out.push(part),
_ => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"command path '{command}' must be a plain relative path \
inside the AppDir"
),
));
}
}
}
if out.as_os_str().is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("command path '{command}' names nothing inside the AppDir"),
));
}
Ok(out)
}
fn refuse_destination_links(appdir: &Path, rel: &Path) -> io::Result<()> {
let mut prefix = PathBuf::from(appdir);
for component in rel.components() {
prefix.push(component);
match std::fs::symlink_metadata(&prefix) {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!(
"{}: a symbolic link is in the way of the import; remove \
it or point [package] command elsewhere",
prefix.display()
),
));
}
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) => {
return Err(io::Error::new(
e.kind(),
format!("{}: {e}", prefix.display()),
));
}
}
}
Ok(())
}
fn same_file(a: &Path, b: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
match (std::fs::metadata(a), std::fs::metadata(b)) {
(Ok(x), Ok(y)) => (x.dev(), x.ino()) == (y.dev(), y.ino()),
_ => false,
}
}
fn hash_file(path: &Path) -> io::Result<blake3::Hash> {
let mut hasher = blake3::Hasher::new();
hasher.update(b"file ");
hasher.update_reader(std::fs::File::open(path)?)?;
Ok(hasher.finalize())
}
fn judge_existing_import(
appdir: &Path,
dest_rel: &Path,
actual: blake3::Hash,
snapshot: blake3::Hash,
) -> io::Result<()> {
if actual == snapshot {
return Ok(());
}
let dest_rel = dest_rel.to_string_lossy();
let recorded = bundle::sysroot::read_generated(appdir)?
.into_iter()
.find(|e| e.path == dest_rel);
match recorded {
Some(entry) if entry.hash == Some(actual) => Ok(()),
_ => Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!(
"{dest_rel}: holds bytes this recipe did not import: neither the \
recorded import nor the source matches. Remove the file or move \
it to a name [package] command does not use"
),
)),
}
}
fn staged_copy(source: &Path, dest: &Path) -> io::Result<PathBuf> {
let dir = dest.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"{}: no parent directory to stage the import in",
dest.display()
),
)
})?;
let name = dest.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{}: no file name to stage the import under", dest.display()),
)
})?;
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let staged = dir.join(format!(
".{}.import-{}-{nanos}",
name.to_string_lossy(),
std::process::id()
));
std::fs::copy(source, &staged).map_err(|e| {
io::Error::new(
e.kind(),
format!("[input] binary {}: {e}", source.display()),
)
})?;
Ok(staged)
}
fn import_binary(appdir: &Path, import: &BinaryImport, command: &str) -> io::Result<()> {
let dest_rel = import_destination(command)?;
let dest = appdir.join(&dest_rel);
refuse_destination_links(appdir, &dest_rel)?;
let snapshot = hash_file(&import.source)?;
match std::fs::symlink_metadata(&dest) {
Ok(meta) if meta.is_dir() => {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!(
"{}: is a directory; [package] command must name a file the \
import can write",
dest.display()
),
));
}
Ok(meta) if !meta.is_file() => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{}: not a regular file; refusing to import over it",
dest.display()
),
));
}
Ok(_) => judge_existing_import(appdir, &dest_rel, hash_file(&dest)?, snapshot)?,
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(io::Error::new(e.kind(), format!("{}: {e}", dest.display()))),
}
place_import(appdir, &import.source, &dest_rel, snapshot)
}
fn place_import(
appdir: &Path,
source: &Path,
dest_rel: &Path,
snapshot: blake3::Hash,
) -> io::Result<()> {
let dest = appdir.join(dest_rel);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
let staged = staged_copy(source, &dest)?;
if hash_file(&staged)? != snapshot {
let _ = std::fs::remove_file(&staged);
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("{}: changed while the import copied it", source.display()),
));
}
std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))?;
std::fs::rename(&staged, &dest)?;
bundle::sysroot::record_generated(appdir, vec![dest_rel.to_string_lossy().into_owned()])
}
pub struct Runtime {
pub bytes: &'static [u8],
pub note: elf::RuntimeNote,
}
pub fn embed_runtime(request: &AssemblyRequest) -> io::Result<Runtime> {
let bytes = if (request.update.url.is_some() && request.update.embed)
|| pins_https_build(&request.directory)
{
RUNTIME_BINARY_UPDATE
} else {
RUNTIME_BINARY_SLIM
};
Ok(Runtime {
bytes,
note: elf::probe_capability_note(bytes)?,
})
}
pub(crate) fn pins_https_build(dir: &Path) -> bool {
[bundle::sysroot::PLATFORM_FILE, bundle::sysroot::SETS_FILE]
.iter()
.any(|f| {
std::fs::read_to_string(dir.join(f))
.map(|t| t.lines().any(|l| l.starts_with("url = \"https://")))
.unwrap_or(false)
})
}
struct ResolvedSysroot {
root: Option<bundle::SysrootOptions>,
sets: Vec<bundle::sysroot::SharedSet>,
pin: Option<onelf_sysroot::platform::Pin>,
}
fn sysroot_from_recipe(
dir: &Path,
command: &str,
sr: &recipe::Sysroot,
) -> io::Result<ResolvedSysroot> {
let sets = sr
.sets
.iter()
.map(|(name, set)| bundle::sysroot::SharedSet {
name: name.clone(),
packages: set.packages.clone(),
url: set.url.clone(),
blake3: set.blake3.clone(),
})
.collect();
let Some(path) = &sr.path else {
return Ok(ResolvedSysroot {
root: None,
sets,
pin: bundle::sysroot::pin_without_root(
sr.platform_url.as_ref(),
sr.platform_hash.as_ref(),
)?,
});
};
let resolve = |p: &Path| {
if p.is_absolute() {
p.to_path_buf()
} else {
dir.join(p)
}
};
let root = resolve(path);
bundle::sysroot::refuse_inside(&root, dir)?;
if !root.is_dir() {
if bundle::sysroot::fetch_forbidden() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"{}: the sysroot is not materialized, and a dry run neither fetches \
nor unpacks its archive; run `onelf sysroot fetch` or a build first",
root.display()
),
));
}
let Some(archive) = &sr.archive else {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"{}: sysroot does not exist and [sysroot] names no archive",
root.display()
),
));
};
let archive_str = archive.to_string_lossy();
let source = if archive_str.contains("://") {
archive_str.into_owned()
} else {
resolve(archive).to_string_lossy().into_owned()
};
sysroot_cmd::fetch(&source, &root, sr.hash.as_deref())?;
}
let platform = sr
.platform
.clone()
.unwrap_or_else(|| bundle::sysroot::default_label(&root, sr.archive.as_deref()));
Ok(ResolvedSysroot {
root: Some(bundle::SysrootOptions {
root,
command: command.to_string(),
platform,
optional: sr.optional.clone(),
platform_line: sr.platform_line.as_deref().map(resolve),
policy: sr.policy.as_deref().map(resolve),
trace: sr.trace.as_deref().map(resolve),
keep: sr.keep.as_deref().map(resolve),
platform_url: sr.platform_url.clone(),
platform_hash: sr.platform_hash.clone(),
sets,
}),
sets: Vec::new(),
pin: None,
})
}
fn framework_names(
gl: bool,
dri: bool,
vulkan: bool,
wayland: bool,
gtk: bool,
) -> Vec<&'static str> {
[
("gl", gl),
("dri", dri),
("vulkan", vulkan),
("wayland", wayland),
("gtk", gtk),
]
.into_iter()
.filter_map(|(name, on)| on.then_some(name))
.collect()
}
fn set_names(sets: &[bundle::sysroot::SharedSet]) -> Vec<String> {
sets.iter().map(|set| set.name.clone()).collect()
}
fn build_package_info(pkg: &recipe::Package) -> Option<String> {
let mut lines = Vec::new();
if let Some(ref s) = pkg.name {
lines.push(format!("name = {}", toml_str(s)));
}
if let Some(ref s) = pkg.version {
lines.push(format!("version = {}", toml_str(s)));
}
if let Some(ref s) = pkg.description {
lines.push(format!("description = {}", toml_str(s)));
}
if let Some(ref s) = pkg.license {
lines.push(format!("license = {}", toml_str(s)));
}
if let Some(ref s) = pkg.homepage {
lines.push(format!("homepage = {}", toml_str(s)));
}
if lines.is_empty() {
None
} else {
Some(lines.join("\n") + "\n")
}
}
fn toml_str(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 || c == '\u{7f}' => {
out.push_str(&format!("\\u{:04X}", c as u32));
}
c => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"onelf-assembly-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn plan_in(dir: &Path, body: &str) -> io::Result<AssemblyRequest> {
let path = dir.join("onelf.toml");
std::fs::write(&path, body)?;
let recipe = recipe::load(&path)?;
AssemblyRequest::plan(&path, recipe, None)
}
fn expected_sysroot_mtime() -> Option<u64> {
(pack::source_date_epoch().is_none()).then_some(0)
}
#[test]
fn a_recipe_library_search_setting_is_refused_at_planning() {
let dir = temp_dir("libpath");
let err = plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\n\n[env]\nLD_LIBRARY_PATH = \"lib\"\n",
)
.unwrap_err();
assert!(err.to_string().contains("LD_LIBRARY_PATH"), "{err}");
assert!(err.to_string().contains("refused"), "{err}");
}
#[test]
fn an_ordinary_recipe_env_variable_plans() {
let dir = temp_dir("ordinary-env");
let request = plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\n\n[env]\nQT_PLUGIN_PATH = \"plugins\"\n",
)
.unwrap();
assert_eq!(
request.env,
vec![("QT_PLUGIN_PATH".to_string(), "plugins".to_string())]
);
}
#[test]
fn a_minimal_recipe_resolves_defaults() {
let dir = temp_dir("defaults");
let request = plan_in(&dir, "[package]\ncommand = \"bin/app\"\n").unwrap();
assert_eq!(request.directory, dir);
assert_eq!(request.output, dir.join("app.onelf"));
assert_eq!(request.package.command, "bin/app");
assert_eq!(request.package.name, None);
assert_eq!(request.package.package_info, None);
assert_eq!(request.package.mtime, None);
assert_eq!(request.package.working_dir, WorkingDir::Inherit);
assert_eq!(request.package.host_libs, pack::HostLibs::Auto);
assert!(!request.package.cache && !request.package.prefer_cache);
assert!(!request.package.needs_setuid);
assert_eq!(request.package.memfd, None);
assert_eq!(request.lib_dirs, ["auto"]);
assert!(!request.bundle.skip);
assert!(request.bundle.sysroot.is_none());
assert!(request.bundle.sets.is_empty());
assert!(request.bundle.platform_pin.is_none());
assert_eq!(request.entrypoints, []);
assert_eq!(request.default_entrypoint, None);
assert_eq!(request.compression.payload, pack::Payload::Blocks);
assert_eq!(request.compression.profile, None);
assert!(!request.compression.use_dict && !request.compression.no_compress);
assert_eq!(
request.update,
UpdateRequest {
url: None,
key_path: None,
revision: None,
unsigned: false,
embed: true,
}
);
assert!(request.env.is_empty() && request.paths.is_empty() && request.preload.is_empty());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn output_paths_resolve_against_the_recipe_directory() {
let dir = temp_dir("output");
let request = plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\noutput = \"out/pkg.onelf\"\n",
)
.unwrap();
assert_eq!(request.output, dir.join("out/pkg.onelf"));
let request = plan_in(
&dir,
"[package]\ncommand = \"bin/tool\"\noutput = \"/tmp/pkg.onelf\"\n",
)
.unwrap();
assert_eq!(request.output, PathBuf::from("/tmp/pkg.onelf"));
let path = dir.join("onelf.toml");
let recipe = recipe::load(&path).unwrap();
let request =
AssemblyRequest::plan(&path, recipe, Some(Path::new("/tmp/override.onelf"))).unwrap();
assert_eq!(request.output, PathBuf::from("/tmp/override.onelf"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn entrypoints_and_their_default_are_carried() {
let dir = temp_dir("entrypoints");
let request = plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\n\n[[entrypoint]]\nname = \"cli\"\n\
path = \"bin/app\"\nargs = [\"--cli\"]\n\n[[entrypoint]]\nname = \"gui\"\n\
path = \"bin/app\"\ndefault = true\n",
)
.unwrap();
assert_eq!(request.entrypoints.len(), 2);
assert_eq!(request.entrypoints[0].args, ["--cli"]);
assert_eq!(request.default_entrypoint.as_deref(), Some("gui"));
let opts = request.pack_options(None, elf::RuntimeNote::Absent, Vec::new());
assert_eq!(opts.default_entrypoint.as_deref(), Some("gui"));
assert_eq!(
opts.entrypoints,
vec![
(
"cli".to_string(),
"bin/app".to_string(),
vec!["--cli".to_string()]
),
("gui".to_string(), "bin/app".to_string(), Vec::new()),
]
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn framework_flags_are_refused_with_a_sysroot_database() {
let dir = temp_dir("conflict");
let err = plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\n\n[bundle]\ngl = true\nwayland = true\n\n\
[sysroot]\npath = \"sysroot\"\n",
)
.unwrap_err();
assert!(
err.to_string()
.contains("gl, wayland cannot be used with [sysroot]"),
"{err}"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_sysroot_database_overrides_skip_and_pins_the_epoch_mtime() {
let sysroot = temp_dir("sysroot-db");
let dir = temp_dir("sysroot-recipe");
let body = format!(
"[package]\ncommand = \"bin/app\"\n\n[bundle]\nskip = true\n\n\
[sysroot]\npath = \"{}\"\n",
sysroot.display()
);
let request = plan_in(&dir, &body).unwrap();
assert!(!request.bundle.skip);
assert!(request.bundle.sysroot.is_some());
assert_eq!(request.package.mtime, expected_sysroot_mtime());
std::fs::remove_dir_all(&dir).unwrap();
std::fs::remove_dir_all(&sysroot).unwrap();
}
const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
#[test]
fn a_set_only_sysroot_keeps_a_skipped_appdir_unchecked() {
let dir = temp_dir("skip-sets");
let body = format!(
"[package]\ncommand = \"bin/app\"\n\n[bundle]\nskip = true\n\n\
[sysroot.sets.gtk]\npackages = [\"gtk3\"]\nurl = \"https://e/gtk.onelf\"\n\
blake3 = \"{HASH}\"\n"
);
let request = plan_in(&dir, &body).unwrap();
assert!(request.bundle.skip);
assert!(request.bundle.sysroot.is_none());
assert_eq!(request.package.mtime, expected_sysroot_mtime());
let report = request.stage().unwrap();
assert_eq!(report.source, Source::Unchecked);
assert_eq!(report.sets, ["gtk"]);
assert!(report.frameworks.is_empty());
assert!(!report.pinned_gl);
assert!(dir.join(".onelf/sets").exists());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_plain_skip_records_nothing() {
let dir = temp_dir("skip-plain");
let request = plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\n\n[bundle]\nskip = true\n",
)
.unwrap();
assert!(request.bundle.skip);
let report = request.stage().unwrap();
assert_eq!(
report,
AssemblyReport {
source: Source::Unchecked,
frameworks: Vec::new(),
sets: Vec::new(),
pinned_gl: false,
active_rules: Vec::new(),
prepared: Vec::new(),
findings: Vec::new(),
inferred_pairs: Vec::new(),
}
);
assert!(!dir.join(".onelf").exists());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_skip_rebuild_without_sets_removes_the_stale_record() {
let dir = temp_dir("skip-stale");
let pinned = format!(
"[package]\ncommand = \"bin/app\"\n\n[bundle]\nskip = true\n\n\
[sysroot.sets.toolkit]\npackages = [\"t\"]\nurl = \"https://e/t.onelf\"\n\
blake3 = \"{HASH}\"\n"
);
plan_in(&dir, &pinned).unwrap().stage().unwrap();
assert!(dir.join(".onelf/sets").exists());
plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\n\n[bundle]\nskip = true\n",
)
.unwrap()
.stage()
.unwrap();
assert!(
!dir.join(".onelf/sets").exists(),
"the record of the removed set survived"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn search_paths_resolve_against_the_recipe_directory() {
let dir = temp_dir("search-paths");
let request = plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\n\n[bundle]\n\
search-paths = [\"deps/lib\", \"/opt/custom/lib\"]\n",
)
.unwrap();
assert_eq!(
request.bundle.search_paths,
[dir.join("deps/lib"), PathBuf::from("/opt/custom/lib")]
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_gl_pin_records_into_a_skipped_appdir() {
let dir = temp_dir("skip-pin");
let body = format!(
"[package]\ncommand = \"bin/app\"\n\n[bundle]\nskip = true\n\n[sysroot]\n\
platform-url = \"https://e/gl.onelf\"\nplatform-hash = \"{HASH}\"\n"
);
let request = plan_in(&dir, &body).unwrap();
assert!(request.bundle.skip);
assert!(request.bundle.platform_pin.is_some());
let report = request.stage().unwrap();
assert_eq!(report.source, Source::Unchecked);
assert!(report.pinned_gl);
assert!(dir.join(".onelf/platform").exists());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_skipped_appdir_reports_unchecked_with_no_completeness_claim() {
let report = AssemblyReport {
source: Source::Unchecked,
frameworks: Vec::new(),
sets: vec!["gtk".to_string()],
pinned_gl: false,
active_rules: Vec::new(),
prepared: Vec::new(),
findings: Vec::new(),
inferred_pairs: Vec::new(),
};
assert_eq!(
report.summary_lines(),
vec![
"Source: unchecked; the tree stands as authored, no scan or \
dependency audit ran, and nothing here claims it complete"
.to_string(),
"Shared sets: gtk".to_string(),
"Not inferred: computed plugin paths, script subprocesses, \
application assets, kernel features and external services \
are not audited"
.to_string(),
]
);
}
#[test]
fn the_summary_lines_do_not_depend_on_activation_order() {
let activation = |rule: &str, reason: &str, groups: &[&str]| bundle::RuleActivation {
rule: rule.to_string(),
reason: reason.to_string(),
groups: groups.iter().map(|g| (*g).to_string()).collect(),
};
let report = |rules: Vec<bundle::RuleActivation>| AssemblyReport {
source: Source::SysrootDatabase,
frameworks: Vec::new(),
sets: Vec::new(),
pinned_gl: true,
active_rules: rules,
prepared: Vec::new(),
findings: Vec::new(),
inferred_pairs: Vec::new(),
};
let forward = report(vec![
activation("alsa", "libasound.so.2", &["plugins", "configuration"]),
activation(
"gstreamer",
"libgstreamer-1.0.so.0",
&["plugins", "scanner"],
),
]);
let reversed = report(forward.active_rules.clone().into_iter().rev().collect());
let expected = vec![
"Source: sysroot package database; the dependency closure was \
audited against the selected source"
.to_string(),
"Rule alsa activated by libasound.so.2; groups: plugins, \
configuration"
.to_string(),
"Rule gstreamer activated by libgstreamer-1.0.so.0; groups: \
plugins, scanner"
.to_string(),
"Pinned GL build: recorded for the launch to fetch".to_string(),
"Not inferred: computed plugin paths, script subprocesses, \
application assets, kernel features and external services are \
not audited"
.to_string(),
];
assert_eq!(forward.summary_lines(), expected, "{:?}", forward);
assert_eq!(reversed.summary_lines(), expected);
}
#[test]
fn a_host_scan_report_names_its_frameworks_and_its_strictness() {
let report = AssemblyReport {
source: Source::HostScan,
frameworks: vec!["wayland", "gtk"],
sets: Vec::new(),
pinned_gl: false,
active_rules: Vec::new(),
prepared: Vec::new(),
findings: Vec::new(),
inferred_pairs: Vec::new(),
};
assert_eq!(
report.summary_lines(),
vec![
"Source: host scan; unresolved required dependencies fail the \
build, host-platform providers are reported"
.to_string(),
"Frameworks requested: wayland, gtk".to_string(),
"Not inferred: computed plugin paths, script subprocesses, \
application assets, kernel features and external services \
are not audited"
.to_string(),
]
);
}
#[test]
fn update_key_resolves_against_the_recipe_and_reads_bytes() {
let dir = temp_dir("update-key");
std::fs::write(dir.join("key.pub"), [7u8; 32]).unwrap();
let request = plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\n\n[update]\nurl = \"https://e/app.onelf\"\n\
key = \"key.pub\"\nrevision = 3\n",
)
.unwrap();
assert_eq!(
request.update.key_path.as_deref(),
Some(dir.join("key.pub").as_path())
);
assert_eq!(request.update.revision, Some(3));
assert!(!request.update.unsigned);
assert!(request.update.embed);
assert_eq!(
request.update.read_key().unwrap().as_deref(),
Some(&[7u8; 32][..])
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_missing_update_key_is_reported_with_its_recipe_context() {
let dir = temp_dir("update-key-missing");
let request = plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\n\n[update]\nurl = \"https://e/app.onelf\"\n\
key = \"absent.key\"\n",
)
.unwrap();
let err = request.update.read_key().unwrap_err();
assert!(err.to_string().contains("recipe [update] key"), "{err}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn runtime_selection_follows_the_update_url_and_the_embed_flag() {
let dir = temp_dir("runtime");
let request = plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\n\n[update]\nurl = \"https://e/app.onelf\"\n",
)
.unwrap();
assert_eq!(
embed_runtime(&request).unwrap().bytes,
RUNTIME_BINARY_UPDATE
);
let request = plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\n\n[update]\nurl = \"https://e/app.onelf\"\n\
embed = false\n",
)
.unwrap();
assert_eq!(embed_runtime(&request).unwrap().bytes, RUNTIME_BINARY_SLIM);
let request = plan_in(&dir, "[package]\ncommand = \"bin/app\"\n").unwrap();
assert_eq!(embed_runtime(&request).unwrap().bytes, RUNTIME_BINARY_SLIM);
std::fs::remove_dir_all(&dir).unwrap();
}
fn seed_binary(base: &Path, rel: &str, bytes: &[u8]) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = base.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, bytes).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
const UPSTREAM: &[u8] = b"#!/bin/sh\necho IMPORT_OK\n";
#[test]
fn an_import_resolves_against_the_recipe_directory() {
let dir = temp_dir("import-resolve");
seed_binary(&dir, "upstream/app", UPSTREAM);
let request = plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\n\n[input]\nbinary = \"upstream/app\"\n",
)
.unwrap();
assert_eq!(
request.input,
Some(BinaryImport {
source: dir.join("upstream/app"),
})
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_binary_import_and_a_sysroot_database_are_refused_together() {
let sysroot = temp_dir("import-sysroot");
let dir = temp_dir("import-conflict");
seed_binary(&dir, "upstream/app", UPSTREAM);
std::fs::create_dir_all(&sysroot).unwrap();
let body = format!(
"[package]\ncommand = \"bin/app\"\n\n[input]\nbinary = \"upstream/app\"\n\n\
[sysroot]\npath = \"{}\"\n",
sysroot.display()
);
let err = plan_in(&dir, &body).unwrap_err();
let text = err.to_string();
assert!(text.contains("[input] binary"), "{err}");
assert!(text.contains("[sysroot] path"), "{err}");
std::fs::remove_dir_all(&dir).unwrap();
std::fs::remove_dir_all(&sysroot).unwrap();
}
#[test]
fn a_binary_import_and_bundle_skip_are_refused_together() {
let dir = temp_dir("import-skip");
seed_binary(&dir, "upstream/app", UPSTREAM);
let err = plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\n\n[input]\nbinary = \"upstream/app\"\n\n\
[bundle]\nskip = true\n",
)
.unwrap_err();
let text = err.to_string();
assert!(text.contains("[input] binary"), "{err}");
assert!(text.contains("[bundle] skip"), "{err}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn an_import_must_name_a_supported_file() {
let dir = temp_dir("import-missing");
let err = plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\n\n[input]\nbinary = \"absent/app\"\n",
)
.unwrap_err();
assert!(err.to_string().contains("bare executable"), "{err}");
std::fs::create_dir_all(dir.join("tree")).unwrap();
let err = plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\n\n[input]\nbinary = \"tree\"\n",
)
.unwrap_err();
assert!(err.to_string().contains("bare executable"), "{err}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn an_import_refuses_a_command_that_leaves_the_appdir() {
let dir = temp_dir("import-escape");
seed_binary(&dir, "upstream/app", UPSTREAM);
for command in ["../app", "/bin/app", "."] {
let body = format!(
"[package]\ncommand = \"{command}\"\n\n[input]\nbinary = \"upstream/app\"\n"
);
let err = plan_in(&dir, &body).unwrap_err();
assert!(
err.to_string().contains("plain relative path"),
"{command}: {err}"
);
}
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn an_import_stages_the_command_and_records_it() {
let dir = temp_dir("import-stage");
seed_binary(&dir, "upstream/app", UPSTREAM);
let request = plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\n\n[input]\nbinary = \"upstream/app\"\n",
)
.unwrap();
let report = request.stage().unwrap();
assert_eq!(report.source, Source::HostScan);
let dest = dir.join("bin/app");
assert_eq!(std::fs::read(&dest).unwrap(), UPSTREAM);
let mode = std::fs::metadata(&dest).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o755);
let entries = bundle::sysroot::read_generated(&dir).unwrap();
let entry = entries
.iter()
.find(|e| e.path == "bin/app")
.expect("recorded");
assert_eq!(entry.hash, Some(hash_file(&dest).unwrap()));
assert!(!entry.pending);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn an_import_refreshes_a_staged_copy_from_its_source() {
let dir = temp_dir("import-refresh");
let source = seed_binary(&dir, "upstream/app", UPSTREAM);
let body = "[package]\ncommand = \"bin/app\"\n\n[input]\nbinary = \"upstream/app\"\n";
let request = plan_in(&dir, body).unwrap();
request.stage().unwrap();
request.stage().unwrap();
std::fs::remove_file(dir.join("bin/app")).unwrap();
request.stage().unwrap();
std::fs::write(&source, b"#!/bin/sh\necho IMPORT_V2\n").unwrap();
request.stage().unwrap();
assert_eq!(
std::fs::read(dir.join("bin/app")).unwrap(),
b"#!/bin/sh\necho IMPORT_V2\n"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn an_edited_staged_import_stops_the_build() {
let dir = temp_dir("import-edited");
seed_binary(&dir, "upstream/app", UPSTREAM);
let request = plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\n\n[input]\nbinary = \"upstream/app\"\n",
)
.unwrap();
request.stage().unwrap();
std::fs::write(dir.join("bin/app"), b"#!/bin/sh\necho EDITED\n").unwrap();
let err = request.stage().unwrap_err();
assert!(err.to_string().contains("did not import"), "{err}");
assert_eq!(
std::fs::read(dir.join("bin/app")).unwrap(),
b"#!/bin/sh\necho EDITED\n",
"the edit must be preserved, not overwritten"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn an_import_whose_source_is_its_destination_is_refused() {
let dir = temp_dir("import-alias");
let dest = seed_binary(&dir, "bin/app", UPSTREAM);
std::os::unix::fs::symlink("app", dir.join("bin/alias")).unwrap();
std::fs::hard_link(&dest, dir.join("hard")).unwrap();
for source in ["bin/app", "bin/alias", "hard"] {
let body =
format!("[package]\ncommand = \"bin/app\"\n\n[input]\nbinary = \"{source}\"\n");
let err = plan_in(&dir, &body).unwrap_err();
let text = err.to_string();
assert!(
text.contains("same file as the package command"),
"{source}: {text}"
);
assert!(text.contains("drop [input]"), "{source}: {text}");
}
assert_eq!(std::fs::read(&dest).unwrap(), UPSTREAM);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_pending_import_does_not_excuse_a_later_edit() {
let dir = temp_dir("import-pending");
let elf = b"\x7fELF pending import";
seed_binary(&dir, "upstream/app", elf);
let import = BinaryImport {
source: dir.join("upstream/app"),
};
import_binary(&dir, &import, "bin/app").unwrap();
let entries = bundle::sysroot::read_generated(&dir).unwrap();
assert!(entries.iter().any(|e| e.path == "bin/app" && e.pending));
import_binary(&dir, &import, "bin/app").unwrap();
std::fs::write(dir.join("bin/app"), b"\x7fELF edited by hand").unwrap();
let err = import_binary(&dir, &import, "bin/app").unwrap_err();
assert!(err.to_string().contains("did not import"), "{err}");
assert_eq!(
std::fs::read(dir.join("bin/app")).unwrap(),
b"\x7fELF edited by hand"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn an_import_refuses_a_destination_symlink_and_leaves_it_alone() {
use std::os::unix::fs::symlink;
let dir = temp_dir("import-link");
seed_binary(&dir, "upstream/app", UPSTREAM);
let victim = dir.join("victim");
std::fs::write(&victim, b"do not touch\n").unwrap();
std::fs::create_dir_all(dir.join("bin")).unwrap();
symlink(&victim, dir.join("bin/app")).unwrap();
let request = plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\n\n[input]\nbinary = \"upstream/app\"\n",
)
.unwrap();
let err = request.stage().unwrap_err();
assert!(err.to_string().contains("symbolic link"), "{err}");
assert_eq!(std::fs::read(&victim).unwrap(), b"do not touch\n");
assert!(
std::fs::symlink_metadata(dir.join("bin/app"))
.unwrap()
.file_type()
.is_symlink()
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn an_import_refuses_publisher_owned_content() {
let dir = temp_dir("import-publisher");
seed_binary(&dir, "upstream/app", UPSTREAM);
std::fs::create_dir_all(dir.join("bin")).unwrap();
std::fs::write(dir.join("bin/app"), b"the publisher's own binary\n").unwrap();
let request = plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\n\n[input]\nbinary = \"upstream/app\"\n",
)
.unwrap();
let err = request.stage().unwrap_err();
assert!(err.to_string().contains("did not import"), "{err}");
assert_eq!(
std::fs::read(dir.join("bin/app")).unwrap(),
b"the publisher's own binary\n"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn an_import_through_a_linked_directory_is_refused() {
use std::os::unix::fs::symlink;
let dir = temp_dir("import-linkdir");
seed_binary(&dir, "upstream/app", UPSTREAM);
let outside = dir.parent().unwrap().join("elsewhere");
std::fs::create_dir_all(&outside).unwrap();
symlink(&outside, dir.join("bin")).unwrap();
let request = plan_in(
&dir,
"[package]\ncommand = \"bin/app\"\n\n[input]\nbinary = \"upstream/app\"\n",
)
.unwrap();
let err = request.stage().unwrap_err();
assert!(err.to_string().contains("symbolic link"), "{err}");
assert!(!outside.join("app").exists(), "nothing may land outside");
std::fs::remove_dir_all(&dir).unwrap();
std::fs::remove_dir_all(&outside).unwrap();
}
}