mod assembly;
mod bundle;
mod cache;
mod compress;
mod extract;
mod info;
mod init;
mod integrate;
mod list;
mod metadata;
#[cfg(test)]
mod metadata_emission;
mod pack;
mod payload;
mod recipe;
mod rules;
mod run;
mod sign;
mod sysroot_cmd;
mod verify;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use clap::{Parser, Subcommand, ValueEnum};
use onelf_format::WorkingDir;
#[derive(Parser)]
#[command(name = "onelf", about = "Single-binary packaging tool", version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Pack {
directory: PathBuf,
#[arg(short, long)]
output: PathBuf,
#[arg(long)]
command: String,
#[arg(long)]
name: Option<String>,
#[arg(long, value_parser = parse_entrypoint)]
entrypoint: Vec<(String, String)>,
#[arg(long)]
default_entrypoint: Option<String>,
#[arg(long, default_values_t = [String::from("auto")])]
lib_dir: Vec<String>,
#[arg(long, default_value = "19")]
level: i32,
#[arg(long, value_parser = parse_block_size)]
block_size: Option<u64>,
#[arg(long, value_name = "FORMAT", default_value = "blocks")]
payload: PayloadArg,
#[arg(long)]
dict: bool,
#[arg(long)]
no_compress: bool,
#[arg(long, conflicts_with = "no_memfd")]
memfd: bool,
#[arg(long)]
no_memfd: bool,
#[arg(long, default_value = "inherit")]
working_dir: WorkingDirArg,
#[arg(long)]
update_url: Option<String>,
#[arg(long, requires = "update_url")]
update_key: Option<PathBuf>,
#[arg(long, requires = "update_url")]
update_revision: Option<u64>,
#[arg(long, requires = "update_url", conflicts_with = "update_key")]
update_unsigned: bool,
#[arg(long, value_name = "MODE", default_value = "auto")]
host_libs: HostLibsArg,
#[arg(long)]
cache: bool,
#[arg(long)]
prefer_cache: bool,
#[arg(long)]
no_embed_updater: bool,
#[arg(long)]
exclude: Vec<String>,
#[arg(long)]
preload: Vec<String>,
#[arg(long)]
needs_setuid: bool,
#[arg(long)]
mtime: Option<u64>,
#[arg(long, value_name = "FILE")]
prefetch: Option<PathBuf>,
},
Init {
#[arg(short, long, default_value = "onelf.toml")]
output: PathBuf,
#[arg(long, value_name = "PATH")]
binary: Option<PathBuf>,
#[arg(long)]
force: bool,
},
Build {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
dry_run: bool,
},
Run {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long)]
command: Option<String>,
#[arg(long)]
entrypoint: Option<String>,
#[arg(long)]
bundle: bool,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
Verify {
binary: PathBuf,
#[arg(long)]
key: Option<PathBuf>,
#[arg(long)]
sig: Option<PathBuf>,
},
Sysroot {
#[command(subcommand)]
action: SysrootAction,
},
Info {
binary: PathBuf,
},
List {
binary: PathBuf,
},
Extract {
binary: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
file: Vec<String>,
#[arg(long)]
preserve_mode: bool,
},
Icon {
binary: PathBuf,
#[arg(long)]
entrypoint: Option<String>,
#[arg(short, long)]
output: Option<PathBuf>,
},
Desktop {
binary: PathBuf,
#[arg(long)]
entrypoint: Option<String>,
#[arg(short, long)]
output: Option<PathBuf>,
},
Integrate {
binary: PathBuf,
#[arg(long)]
entrypoint: Option<String>,
},
Unintegrate {
binary: PathBuf,
#[arg(long)]
entrypoint: Option<String>,
},
Key {
#[command(subcommand)]
action: KeyAction,
},
Sign {
binary: PathBuf,
#[arg(long, value_name = "PATH")]
key: PathBuf,
#[arg(short, long, value_name = "PATH")]
output: Option<PathBuf>,
},
Cache {
#[command(subcommand)]
action: CacheAction,
},
BundleLibs {
directory: PathBuf,
#[arg(long)]
target: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
from_binary: Option<PathBuf>,
#[arg(long, default_value = "lib")]
lib_dir: PathBuf,
#[arg(long, value_delimiter = ',')]
exclude: Vec<String>,
#[arg(long, value_delimiter = ',')]
include: Vec<String>,
#[arg(long)]
search_path: Vec<PathBuf>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
no_recursive: bool,
#[arg(long, conflicts_with = "sysroot")]
gl: bool,
#[arg(long, conflicts_with = "sysroot")]
dri: bool,
#[arg(long, conflicts_with = "sysroot")]
vulkan: bool,
#[arg(long, conflicts_with = "sysroot")]
wayland: bool,
#[arg(long, conflicts_with = "sysroot")]
gtk: bool,
#[arg(long)]
no_gl: bool,
#[arg(long)]
no_dri: bool,
#[arg(long)]
no_vulkan: bool,
#[arg(long)]
no_wayland: bool,
#[arg(long)]
no_gtk: bool,
#[arg(long)]
strip: bool,
#[arg(long)]
strict_libc: bool,
#[arg(long)]
scan_dlopen: bool,
#[arg(long, value_delimiter = ',')]
dlopen: Vec<String>,
#[arg(long, requires = "target")]
trace_run: bool,
#[arg(long, default_value = "5", requires = "trace_run")]
trace_seconds: u64,
#[arg(long, requires = "trace_run", allow_hyphen_values = true)]
trace_arg: Vec<String>,
#[arg(long, value_name = "DIR")]
sysroot: Option<PathBuf>,
#[arg(long, value_name = "PACKAGE", requires = "sysroot")]
sysroot_optional: Vec<String>,
#[arg(long, value_name = "FILE", requires = "sysroot")]
platform_line: Option<PathBuf>,
#[arg(long, value_name = "FILE", requires = "sysroot")]
policy: Option<PathBuf>,
#[arg(long, value_name = "FILE", requires = "sysroot")]
trace: Option<PathBuf>,
#[arg(long, value_name = "FILE", requires = "sysroot")]
keep: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum KeyAction {
New {
#[arg(long, value_name = "PATH", default_value = "onelf.key")]
secret: PathBuf,
#[arg(long, value_name = "PATH", default_value = "onelf.pub")]
public: PathBuf,
},
Show {
#[arg(long, value_name = "PATH")]
secret: PathBuf,
#[arg(short, long, value_name = "PATH")]
output: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum CacheAction {
List,
Clear,
Gc {
#[arg(long, default_value = "30")]
max_age: u64,
},
}
#[derive(Subcommand)]
enum SysrootAction {
Fetch {
source: String,
dir: PathBuf,
#[arg(long)]
hash: Option<String>,
},
Info {
dir: PathBuf,
},
PackGl {
dir: PathBuf,
#[arg(short, long)]
output: PathBuf,
#[arg(long, value_name = "DIR", requires = "package")]
sysroot: Option<PathBuf>,
#[arg(long, value_name = "NAME", requires = "sysroot")]
package: Vec<String>,
},
PackSet {
dir: PathBuf,
#[arg(short, long)]
output: PathBuf,
#[arg(long)]
name: String,
#[arg(long)]
sysroot: Option<PathBuf>,
#[arg(long, requires = "sysroot")]
package: Vec<String>,
#[arg(long, requires = "sysroot")]
platform_line: Option<PathBuf>,
},
}
#[derive(Clone, Copy, ValueEnum)]
enum HostLibsArg {
Auto,
Always,
Never,
}
impl From<HostLibsArg> for pack::HostLibs {
fn from(v: HostLibsArg) -> Self {
match v {
HostLibsArg::Auto => pack::HostLibs::Auto,
HostLibsArg::Always => pack::HostLibs::Always,
HostLibsArg::Never => pack::HostLibs::Never,
}
}
}
#[derive(Clone, ValueEnum)]
enum PayloadArg {
Blocks,
Stipple,
}
impl From<PayloadArg> for pack::Payload {
fn from(v: PayloadArg) -> Self {
match v {
PayloadArg::Blocks => pack::Payload::Blocks,
PayloadArg::Stipple => pack::Payload::Stipple,
}
}
}
#[derive(Clone, ValueEnum)]
enum WorkingDirArg {
Inherit,
Package,
Command,
}
fn parse_entrypoint(s: &str) -> Result<(String, String), String> {
let (name, path) = s.split_once('=').ok_or("expected format: name=path")?;
Ok((name.to_string(), path.to_string()))
}
fn parse_block_size(s: &str) -> Result<u64, String> {
let t = s.trim();
let (digits, scale) = match t.as_bytes().last() {
Some(b'K' | b'k') => (&t[..t.len() - 1], 1024),
Some(b'M' | b'm') => (&t[..t.len() - 1], 1024 * 1024),
_ => (t, 1),
};
let n: u64 = digits
.trim()
.parse()
.map_err(|_| format!("invalid block size '{s}': expected a number, optionally K or M"))?;
let bytes = n
.checked_mul(scale)
.ok_or_else(|| format!("block size '{s}' overflows"))?;
let (min, max) = (compress::MIN_BLOCK_SIZE, compress::MAX_STIPPLE_BLOCK_SIZE);
if !(min..=max).contains(&bytes) {
return Err(format!(
"block size {bytes} out of range: expected {}K to {}M",
min / 1024,
max / (1024 * 1024)
));
}
Ok(bytes)
}
fn main() {
let cli = Cli::parse();
let result = match cli.command {
Commands::Pack {
directory,
output,
command,
name,
entrypoint,
default_entrypoint,
lib_dir,
level,
block_size,
payload,
dict,
no_compress,
memfd,
no_memfd,
working_dir,
update_url,
update_key,
update_revision,
update_unsigned,
no_embed_updater,
host_libs,
cache,
prefer_cache,
exclude,
preload,
needs_setuid,
mtime,
prefetch,
} => {
let payload: pack::Payload = payload.into();
let memfd_opt = if no_memfd {
Some(false)
} else if memfd {
Some(true)
} else {
None
};
let wd = match working_dir {
WorkingDirArg::Inherit => WorkingDir::Inherit,
WorkingDirArg::Package => WorkingDir::PackageRoot,
WorkingDirArg::Command => WorkingDir::EntrypointParent,
};
let pins_https = assembly::pins_https_build(&directory);
let entrypoints = entrypoint
.into_iter()
.map(|(n, p)| (n, p, Vec::new()))
.collect();
if no_embed_updater && update_url.is_none() {
eprintln!(
"warning: --no-embed-updater has no effect without --update-url; \
the slim runtime is already used when no update URL is set"
);
}
let update_key = update_key
.as_deref()
.map(|path| {
std::fs::read(path).map_err(|e| {
std::io::Error::new(
e.kind(),
format!("--update-key {}: {e}", path.display()),
)
})
})
.transpose();
let profile = prefetch.as_deref().map(pack::read_profile).transpose();
let runtime: &[u8] = if (update_url.is_some() && !no_embed_updater) || pins_https {
assembly::RUNTIME_BINARY_UPDATE
} else {
assembly::RUNTIME_BINARY_SLIM
};
match update_key.and_then(|key| Ok((key, profile?.unwrap_or_default()))) {
Err(e) => Err(e),
Ok((update_key, profile)) => {
match onelf_format::elf::probe_capability_note(runtime) {
Err(e) => Err(e.into()),
Ok(runtime_note) => pack::require_store_runtime(&runtime_note, pins_https)
.and_then(|()| {
pack::pack(
&pack::PackOptions {
directory,
output,
command,
name,
entrypoints,
default_entrypoint,
lib_dirs: lib_dir,
level,
block_size: block_size
.unwrap_or(payload.default_block_size()),
payload,
use_dict: dict,
no_compress,
memfd: memfd_opt,
working_dir: wd,
host_libs: host_libs.into(),
cache,
prefer_cache,
update_url: update_url.clone(),
embed_updater: !no_embed_updater,
update_key,
update_revision,
update_unsigned,
exclude,
package_info: None,
mtime,
env: Vec::new(),
paths: Vec::new(),
inferred_pairs: Vec::new(),
preload,
needs_setuid,
profile,
provider_index: None,
metadata: pack::MetadataPayload::default(),
runtime_note,
},
runtime,
)
}),
}
}
}
}
Commands::Init {
output,
binary,
force,
} => init::init(&output, binary.as_deref(), force),
Commands::Build {
path,
output,
dry_run: false,
} => run_build(&path, output.as_deref()),
Commands::Build {
path,
output,
dry_run: true,
} => dry_run_build(&path, output.as_deref()),
Commands::Run {
path,
command,
entrypoint,
bundle,
args,
} => run::run(
&path,
command.as_deref(),
entrypoint.as_deref(),
bundle,
&args,
),
Commands::Verify { binary, key, sig } => {
verify::verify(&binary, key.as_deref(), sig.as_deref())
}
Commands::Info { binary } => info::info(&binary),
Commands::List { binary } => list::list(&binary),
Commands::Extract {
binary,
output,
file,
preserve_mode,
} => extract::extract(&binary, output.as_deref(), &file, preserve_mode),
Commands::Icon {
binary,
entrypoint,
output,
} => metadata::icon(&binary, entrypoint.as_deref(), output.as_deref()),
Commands::Desktop {
binary,
entrypoint,
output,
} => metadata::desktop(&binary, entrypoint.as_deref(), output.as_deref()),
Commands::Integrate { binary, entrypoint } => {
integrate::integrate(&binary, entrypoint.as_deref())
}
Commands::Unintegrate { binary, entrypoint } => {
integrate::unintegrate(&binary, entrypoint.as_deref())
}
Commands::Key { action } => match action {
KeyAction::New { secret, public } => sign::generate(&public, &secret).map(|()| {
println!("Secret key: {} (keep private)", secret.display());
println!("Public key: {}", public.display());
println!(
"Pack with: onelf pack ... --update-key {}",
public.display()
);
}),
KeyAction::Show { secret, output } => {
sign::public_key_of(&secret).and_then(|pk| match output {
Some(path) => std::fs::write(&path, pk),
None => std::io::Write::write_all(&mut std::io::stdout(), &pk),
})
}
},
Commands::Sign {
binary,
key,
output,
} => sign::sign(&binary, &key, output.as_deref()).map(|signed| {
match signed.matched {
sign::KeyMatch::Matches => {
println!("Signed with the key this package embeds.");
}
sign::KeyMatch::NoEmbeddedKey => {
eprintln!(
"warning: {} embeds no update key, so it cannot self-update. \
Repack with --update-key to make this signature useful.",
binary.display()
);
}
sign::KeyMatch::NotAPackage => {
eprintln!(
"warning: {} is not an onelf package, so the signing key could \
not be checked against it.",
binary.display()
);
}
}
println!("Signature: {}", signed.path.display());
if let Some(url) = signed.sig_url {
println!("Publish it at: {url}");
}
}),
Commands::Cache { action } => match action {
CacheAction::List => cache::cache_list(),
CacheAction::Clear => cache::cache_clear(),
CacheAction::Gc { max_age } => cache::cache_gc(max_age),
},
Commands::BundleLibs {
directory,
target,
from_binary,
lib_dir,
exclude,
include,
search_path,
dry_run,
no_recursive,
gl,
dri,
vulkan,
wayland,
gtk,
no_gl,
no_dri,
no_vulkan,
no_wayland,
no_gtk,
strip,
strict_libc,
scan_dlopen,
dlopen,
trace_run,
trace_seconds,
trace_arg,
sysroot,
sysroot_optional,
platform_line,
policy,
trace,
keep,
} => scaffold_from_binary(&directory, from_binary.as_deref()).and_then(|_| {
let sysroot = match sysroot {
Some(root) => {
let Some(command) = target.as_ref().and_then(|t| t.to_str()) else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"--sysroot needs --target to name the entrypoint",
));
};
Some(bundle::SysrootOptions {
platform: bundle::sysroot::default_label(&root, None),
root,
command: command.to_string(),
optional: sysroot_optional,
platform_line,
policy,
trace,
keep,
platform_url: None,
platform_hash: None,
sets: Vec::new(),
})
}
None => None,
};
bundle::bundle_libs(&bundle::BundleOptions {
directory,
primary: target.clone(),
target,
lib_dir,
lib_dirs: vec![String::from("auto")],
recipe_pairs: Vec::new(),
exclude,
include,
search_path,
dry_run,
recursive: !no_recursive,
gl,
dri,
vulkan,
wayland,
gtk,
no_gl,
no_dri,
no_vulkan,
no_wayland,
no_gtk,
strip,
strict_libc,
scan_dlopen,
dlopen_extra: dlopen,
trace_run,
trace_seconds,
trace_args: trace_arg,
trim_inferred: false,
sysroot,
sets: Vec::new(),
entrypoint_selections: Vec::new(),
platform_pin: None,
rules: Default::default(),
strict: false,
})
}),
Commands::Sysroot { action } => match action {
SysrootAction::Fetch { source, dir, hash } => {
sysroot_cmd::fetch(&source, &dir, hash.as_deref())
}
SysrootAction::Info { dir } => sysroot_cmd::info(&dir),
SysrootAction::PackGl {
dir,
output,
sysroot,
package,
} => {
let built = match &sysroot {
Some(root) => std::fs::create_dir_all(&dir)
.and_then(|()| bundle::sysroot::populate_gl(&dir, root, &package))
.map(Some),
None => Ok(None),
};
built.and_then(|selection| {
if let Some(selection) = &selection {
let named: Vec<String> = selection
.packages
.iter()
.filter(|(n, _)| package.contains(n))
.map(|(n, v)| format!("{n} {v}"))
.collect();
eprintln!(
"Built from {}: {} files from {} packages ({} and their dependencies)",
sysroot
.as_deref()
.unwrap_or(std::path::Path::new("?"))
.display(),
selection.copied,
selection.packages.len(),
named.join(", ")
);
}
sysroot_cmd::pack_gl(
&dir,
&output,
sysroot.as_deref(),
assembly::RUNTIME_BINARY_SLIM,
)
})
}
SysrootAction::PackSet {
dir,
output,
name,
sysroot,
package,
platform_line,
} => {
let line =
bundle::sysroot::check_set_name(&name).and_then(|()| match &platform_line {
Some(p) => onelf_sysroot::PlatformLine::load(p),
None => Ok(onelf_sysroot::PlatformLine::from_prefixes(
onelf_format::drivers::DRIVER_FAMILIES
.iter()
.map(|s| s.to_string())
.collect(),
)),
});
let built = line.and_then(|line| match &sysroot {
Some(root) => std::fs::create_dir_all(&dir)
.and_then(|()| bundle::sysroot::populate_set(&dir, root, &package, &line))
.map(Some),
None => Ok(None),
});
built.and_then(|selection| {
if let Some(selection) = &selection {
eprintln!(
"Built set {name} from {}: {} files from {} packages",
sysroot
.as_deref()
.unwrap_or(std::path::Path::new("?"))
.display(),
selection.copied,
selection.packages.len(),
);
}
sysroot_cmd::pack_set(
&dir,
&output,
&name,
sysroot.as_deref(),
assembly::RUNTIME_BINARY_SLIM,
)
})
}
},
};
if let Err(e) = result {
eprintln!("error: {e}");
std::process::exit(1);
}
}
fn run_build(
path: &std::path::Path,
output_override: Option<&std::path::Path>,
) -> std::io::Result<()> {
let recipe_path = recipe::resolve(path)?;
let recipe = recipe::load(&recipe_path)?;
let request = assembly::AssemblyRequest::plan(&recipe_path, recipe, output_override)?;
let report = request.stage()?;
for line in report.summary_lines() {
eprintln!("{line}");
}
let update_key = request.update.read_key()?;
let runtime = assembly::embed_runtime(&request)?;
pack::require_store_runtime(&runtime.note, fetches_over_https(&request))?;
let profile = match &request.compression.profile {
Some(p) => pack::read_profile(p)?,
None => Vec::new(),
};
let mut options = request.pack_options(update_key, runtime.note, profile);
options.metadata = build_metadata(&request, &request.selections, &report.prepared)?;
options.inferred_pairs = report.inferred_pairs;
pack::pack(&options, runtime.bytes)
}
fn dry_run_build(
path: &std::path::Path,
output_override: Option<&std::path::Path>,
) -> std::io::Result<()> {
let _no_fetch = bundle::sysroot::forbid_fetch();
let recipe_path = recipe::resolve(path)?;
let recipe = recipe::load(&recipe_path)?;
let request = assembly::AssemblyRequest::plan(&recipe_path, recipe, output_override)?;
let report = request.dry_run()?;
for line in report.summary_lines() {
eprintln!("{line}");
}
let metadata = build_metadata(&request, &request.selections, &report.prepared)?;
let records: Vec<&str> = [
(metadata.runtime_rules.is_some(), "runtime rules"),
(metadata.entrypoint_sets.is_some(), "entrypoint selections"),
]
.into_iter()
.filter_map(|(carried, name)| carried.then_some(name))
.collect();
eprintln!(
"Would emit: {} ({})",
request.output.display(),
if records.is_empty() {
"no runtime metadata records".to_string()
} else {
format!("carrying {}", records.join(" and "))
}
);
let failures = report
.findings
.iter()
.filter(|f| f.outcome == bundle::Outcome::RequiredFailure)
.count();
if failures > 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("dry run: the build would fail on {failures} required failure(s)"),
));
}
eprintln!("Dry run: nothing was written, fetched or run");
Ok(())
}
fn fetches_over_https(request: &assembly::AssemblyRequest) -> bool {
let sets = request
.bundle
.sets
.iter()
.chain(
request
.bundle
.sysroot
.as_ref()
.map(|sysroot| sysroot.sets.iter())
.into_iter()
.flatten(),
)
.map(|set| set.url.as_str());
let pinned = request
.bundle
.platform_pin
.as_ref()
.map(|pin| pin.url.as_str())
.into_iter();
sets.chain(pinned).any(|url| url.starts_with("https://"))
}
fn build_metadata(
request: &assembly::AssemblyRequest,
table: &[assembly::ResolvedSelection],
prepared: &[bundle::PreparedPin],
) -> std::io::Result<pack::MetadataPayload> {
let rules = request
.bundle
.rules
.runtime_records(prepared)?
.unwrap_or_default();
let sets = selection_table(request, table)?;
pack::lower_runtime_metadata(rules, sets).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("the runtime metadata records cannot be written: {e}"),
)
})
}
fn selection_table(
request: &assembly::AssemblyRequest,
table: &[assembly::ResolvedSelection],
) -> std::io::Result<Option<onelf_format::runtime_metadata::EntrypointSets>> {
if !table.iter().any(assembly::ResolvedSelection::is_explicit) {
return Ok(None);
}
let declared = declared_set_pins(request)?;
let names: std::collections::BTreeMap<&str, onelf_format::rules::RuleId> = declared
.iter()
.map(|pin| (pin.name.as_str(), pin.name.clone()))
.collect();
let mut entrypoints = Vec::new();
for resolved in table {
let sets: Vec<_> = match &resolved.sets {
recipe::SetSelection::All => names.values().cloned().collect(),
recipe::SetSelection::Names(list) => {
let mut selected = Vec::with_capacity(list.len());
for name in list {
let Some(id) = names.get(name.as_str()) else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"entrypoint '{}': set '{name}' is not declared by this recipe",
resolved.name
),
));
};
selected.push(id.clone());
}
selected
}
};
entrypoints.push(onelf_format::runtime_metadata::EntrypointSelection {
name: resolved.name.clone(),
sets,
});
}
Ok(Some(onelf_format::runtime_metadata::EntrypointSets {
pins: declared,
entrypoints,
}))
}
fn declared_set_pins(
request: &assembly::AssemblyRequest,
) -> std::io::Result<Vec<onelf_format::runtime_metadata::SetPin>> {
let own = request.bundle.sets.iter();
let shared = request
.bundle
.sysroot
.as_ref()
.map(|sysroot| sysroot.sets.iter())
.into_iter()
.flatten();
let declared: std::collections::BTreeMap<&str, &str> = own
.chain(shared)
.map(|set| (set.name.as_str(), set.blake3.as_str()))
.collect();
declared
.into_iter()
.map(|(name, hash)| {
let name = onelf_format::rules::RuleId::new(name).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("set '{name}': the selection record needs an identifier: {e}"),
)
})?;
let parsed = blake3::Hash::from_hex(hash).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("set '{}': pin is not a blake3 digest", name.as_str()),
)
})?;
Ok(onelf_format::runtime_metadata::SetPin {
name,
hash: *parsed.as_bytes(),
})
})
.collect()
}
fn scaffold_from_binary(
dir: &std::path::Path,
src: Option<&std::path::Path>,
) -> std::io::Result<()> {
let Some(src) = src else {
return Ok(());
};
if !src.is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("--from-binary: {} is not a file", src.display()),
));
}
let bin_dir = dir.join("bin");
std::fs::create_dir_all(&bin_dir)?;
let name = src.file_name().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"--from-binary has no filename",
)
})?;
let dest = bin_dir.join(name);
std::fs::copy(src, &dest)?;
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))?;
eprintln!("Scaffolded {} -> {}", src.display(), dest.display());
Ok(())
}
#[cfg(test)]
mod block_size_tests {
use super::parse_block_size;
use crate::compress::{MAX_STIPPLE_BLOCK_SIZE, MIN_BLOCK_SIZE};
#[test]
fn accepts_plain_bytes_and_k_m_suffixes() {
assert_eq!(parse_block_size("262144").unwrap(), 256 * 1024);
assert_eq!(parse_block_size("256K").unwrap(), 256 * 1024);
assert_eq!(parse_block_size("256k").unwrap(), 256 * 1024);
assert_eq!(parse_block_size("4M").unwrap(), 4 * 1024 * 1024);
assert_eq!(parse_block_size(" 1M ").unwrap(), 1024 * 1024);
}
#[test]
fn rejects_sizes_outside_the_supported_range() {
assert!(parse_block_size(&(MIN_BLOCK_SIZE - 1).to_string()).is_err());
assert!(parse_block_size(&(MAX_STIPPLE_BLOCK_SIZE + 1).to_string()).is_err());
assert!(parse_block_size("0").is_err());
}
#[test]
fn rejects_garbage_rather_than_silently_defaulting() {
assert!(parse_block_size("").is_err());
assert!(parse_block_size("M").is_err());
assert!(parse_block_size("4G").is_err());
assert!(parse_block_size("-1M").is_err());
assert!(parse_block_size("18014398509481985M").is_err());
}
}
#[cfg(test)]
mod selection_tests {
use super::build_metadata;
use crate::assembly::{self, ResolvedSelection};
use crate::metadata;
use crate::recipe;
use onelf_format::elf::RuntimeNote;
use onelf_format::runtime_metadata::EntrypointSets;
const SETS: &str = "[sysroot.sets.gtk]\npackages = [\"gtk3\"]\nurl = \"https://e/gtk.onelf\"\nblake3 = \"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"\n\n[sysroot.sets.audio]\npackages = [\"pipewire\"]\nurl = \"https://e/audio.onelf\"\nblake3 = \"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210\"\n";
fn recipe(package: &str, entrypoints: &str) -> String {
format!("[package]\ncommand = \"bin/app\"\nmtime = 0\n{package}\n{SETS}\n{entrypoints}")
}
fn resolve(body: &str) -> std::io::Result<assembly::AssemblyRequest> {
let dir = std::env::temp_dir().join(format!(
"onelf-selection-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("onelf.toml"), body).unwrap();
let path = recipe::resolve(&dir).unwrap();
let recipe = recipe::load(&path).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
assembly::AssemblyRequest::plan(&path, recipe, None)
}
#[test]
fn an_omitted_selection_resolves_over_the_final_table() {
let request = resolve(&recipe(
"",
"[[entrypoint]]\nname = \"cli\"\npath = \"bin/app\"\n",
))
.unwrap();
let table = &request.selections;
assert_eq!(table.len(), 2);
assert_eq!(table[0].name, "app");
assert_eq!(table[0].sets, recipe::SetSelection::All);
assert!(!table[0].is_explicit());
assert_eq!(table[1].name, "cli");
assert_eq!(table[1].sets, recipe::SetSelection::All);
}
#[test]
fn explicit_lists_resolve_to_what_they_wrote() {
let request = resolve(&recipe(
"sets = []",
"[[entrypoint]]\nname = \"cli\"\npath = \"bin/app\"\nsets = [\"gtk\"]\n",
))
.unwrap();
let table = &request.selections;
assert_eq!(table[0].sets, recipe::SetSelection::Names(Vec::new()));
assert!(table[0].is_explicit());
assert_eq!(
table[1].sets,
recipe::SetSelection::Names(vec!["gtk".to_string()])
);
}
#[test]
fn a_named_omission_does_not_inherit_an_explicit_primary_list() {
let request = resolve(&recipe(
"sets = [\"gtk\"]",
"[[entrypoint]]\nname = \"cli\"\npath = \"bin/app\"\n",
))
.unwrap();
let table = &request.selections;
assert_eq!(
table[0].sets,
recipe::SetSelection::Names(vec!["gtk".to_string()])
);
assert_eq!(table[1].sets, recipe::SetSelection::All);
assert!(!table[1].is_explicit());
}
#[test]
fn the_default_entrypoint_carries_its_own_selection() {
let request = resolve(&recipe(
"",
"[[entrypoint]]\nname = \"cli\"\npath = \"bin/app\"\ndefault = true\nsets = [\"gtk\"]\n",
))
.unwrap();
let table = &request.selections;
assert_eq!(table.len(), 1);
assert_eq!(table[0].name, "cli");
assert_eq!(
table[0].sets,
recipe::SetSelection::Names(vec!["gtk".to_string()])
);
}
#[test]
fn a_duplicate_name_in_the_final_table_is_refused() {
let err = resolve(&recipe(
"",
"[[entrypoint]]\nname = \"app\"\npath = \"bin/app\"\n",
))
.unwrap_err();
assert!(
err.to_string().contains("duplicate entrypoint name 'app'"),
"{err}"
);
}
const GTK_PIN: [u8; 32] = [
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd,
0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab,
0xcd, 0xef,
];
const AUDIO_PIN: [u8; 32] = [
0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32,
0x10, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54,
0x32, 0x10,
];
fn planned(body: &str) -> (std::path::PathBuf, assembly::AssemblyRequest) {
let dir = std::env::temp_dir().join(format!(
"onelf-selection-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("onelf.toml"), body).unwrap();
let path = recipe::resolve(&dir).unwrap();
let recipe = recipe::load(&path).unwrap();
let request = assembly::AssemblyRequest::plan(&path, recipe, None).unwrap();
(dir, request)
}
fn selection<'a>(record: &'a EntrypointSets, name: &str) -> Vec<&'a str> {
record
.entrypoints
.iter()
.find(|e| e.name == name)
.map(|e| e.sets.iter().map(|s| s.as_str()).collect())
.unwrap_or_else(|| panic!("no record for {name}"))
}
#[test]
fn an_explicit_selection_lowers_the_complete_record() {
let (dir, request) = planned(&recipe(
"sets = [\"gtk\"]",
"[[entrypoint]]\nname = \"cli\"\npath = \"bin/app\"\nsets = []\n",
));
let payload = build_metadata(&request, &request.selections, &[]).unwrap();
assert!(
payload.runtime_rules.is_none(),
"selections carry no rule records"
);
let record = EntrypointSets::decode(payload.entrypoint_sets.as_deref().unwrap()).unwrap();
assert_eq!(
record
.pins
.iter()
.map(|p| p.name.as_str())
.collect::<Vec<_>>(),
["audio", "gtk"],
"every declared set is pinned, canonically ordered"
);
assert_eq!(record.pins[0].hash, AUDIO_PIN);
assert_eq!(record.pins[1].hash, GTK_PIN);
assert_eq!(selection(&record, "app"), ["gtk"]);
assert_eq!(selection(&record, "cli"), [] as [&str; 0]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_legacy_selection_lowers_no_record() {
let (dir, request) = planned(&recipe("", ""));
assert_eq!(
build_metadata(&request, &request.selections, &[]).unwrap(),
crate::pack::MetadataPayload::default()
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn an_omitted_list_expands_beside_a_written_one() {
let (dir, request) = planned(&recipe(
"sets = [\"audio\"]",
"[[entrypoint]]\nname = \"cli\"\npath = \"bin/app\"\n",
));
let payload = build_metadata(&request, &request.selections, &[]).unwrap();
let record = EntrypointSets::decode(payload.entrypoint_sets.as_deref().unwrap()).unwrap();
assert_eq!(selection(&record, "app"), ["audio"]);
assert_eq!(selection(&record, "cli"), ["audio", "gtk"]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_default_named_entrypoint_keeps_one_record_under_its_recipe_name() {
let (dir, request) = planned(&recipe(
"",
"[[entrypoint]]\nname = \"gui\"\npath = \"bin/gui\"\ndefault = true\nsets = [\"gtk\"]\n",
));
let payload = build_metadata(&request, &request.selections, &[]).unwrap();
let record = EntrypointSets::decode(payload.entrypoint_sets.as_deref().unwrap()).unwrap();
assert_eq!(
record
.entrypoints
.iter()
.map(|e| e.name.as_str())
.collect::<Vec<_>>(),
["gui"],
"the default names the record, once"
);
assert_eq!(selection(&record, "gui"), ["gtk"]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_record_names_only_declared_sets() {
let (dir, request) = planned(&recipe("", ""));
let hostile = vec![ResolvedSelection {
name: "app".to_string(),
sets: recipe::SetSelection::Names(vec!["ghost".to_string()]),
}];
let err = build_metadata(&request, &hostile, &[]).unwrap_err();
assert!(
err.to_string().contains("entrypoint 'app': set 'ghost'"),
"{err}"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_pack_gate_agrees_on_an_incapable_runtime() {
let dir = std::env::temp_dir().join(format!(
"onelf-selection-gate-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("bin")).unwrap();
std::fs::write(dir.join("bin/app"), "#!/bin/sh\necho hi\n").unwrap();
let body = recipe(
"sets = [\"gtk\"]\n\n[bundle]\nskip = true",
"[[entrypoint]]\nname = \"cli\"\npath = \"bin/app\"\nsets = []\n",
);
std::fs::write(dir.join("onelf.toml"), body).unwrap();
let path = recipe::resolve(&dir).unwrap();
let loaded = recipe::load(&path).unwrap();
let request = assembly::AssemblyRequest::plan(&path, loaded, None).unwrap();
let _report = request.stage().unwrap();
let payload = build_metadata(&request, &request.selections, &[]).unwrap();
let out = dir.join("pkg.onelf");
let mut opts = request.pack_options(None, RuntimeNote::Absent, Vec::new());
opts.output = out.clone();
opts.metadata = payload;
let err = crate::pack::pack(&opts, b"stub-runtime").unwrap_err();
let text = err.to_string();
assert!(text.contains("refusing to embed a runtime"), "{text}");
assert!(text.contains("selection-view"), "{text}");
assert!(!out.exists(), "the refusal must come before any writing");
let runtime = assembly::embed_runtime(&request).unwrap();
let mut opts = request.pack_options(None, runtime.note, Vec::new());
opts.output = out.clone();
opts.metadata = build_metadata(&request, &request.selections, &[]).unwrap();
crate::pack::pack(&opts, runtime.bytes).unwrap();
let carried = metadata::read_packed_file(&out, ".onelf/entrypoint-sets")
.unwrap()
.expect("the capable runtime carries the record");
assert!(EntrypointSets::decode(&carried).is_ok());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_run_build_emits_the_record_its_selections_write() {
let dir = std::env::temp_dir().join(format!(
"onelf-selection-run-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("bin")).unwrap();
std::fs::write(dir.join("bin/app"), "#!/bin/sh\necho hi\n").unwrap();
let explicit = recipe(
"sets = [\"gtk\"]\n\n[bundle]\nskip = true",
"[[entrypoint]]\nname = \"cli\"\npath = \"bin/app\"\nsets = []\n",
);
std::fs::write(dir.join("onelf.toml"), explicit).unwrap();
let out = dir.join("pkg.onelf");
super::run_build(&dir.join("onelf.toml"), Some(out.as_path())).unwrap();
let (_, manifest) = crate::info::read_footer_and_manifest(&out).unwrap();
assert_eq!(manifest.entrypoints.len(), 2);
let names: Vec<&str> = manifest
.entrypoints
.iter()
.map(|e| manifest.get_string(e.name))
.collect();
assert_eq!(
names,
["app", "cli"],
"the final table the record answers to"
);
let carried = metadata::read_packed_file(&out, ".onelf/entrypoint-sets")
.unwrap()
.expect("the explicit build carries the record");
let record = EntrypointSets::decode(&carried).unwrap();
assert_eq!(selection(&record, "app"), ["gtk"]);
assert_eq!(selection(&record, "cli"), [] as [&str; 0]);
assert!(
metadata::read_packed_file(&out, ".onelf/runtime-rules")
.unwrap()
.is_none(),
"a skipped tree lowers no rule records"
);
let legacy = recipe(
"\n[bundle]\nskip = true",
"[[entrypoint]]\nname = \"cli\"\npath = \"bin/app\"\n",
);
std::fs::write(dir.join("onelf.toml"), legacy).unwrap();
let plain = dir.join("plain.onelf");
super::run_build(&dir.join("onelf.toml"), Some(plain.as_path())).unwrap();
assert!(
metadata::read_packed_file(&plain, ".onelf/entrypoint-sets")
.unwrap()
.is_none(),
"the legacy build emits nothing"
);
std::fs::remove_dir_all(&dir).unwrap();
}
}