use crate::bundle::format_size;
use crate::payload::{ONELF_ENV_SONAME, ONELF_ENV32_SONAME};
use onelf_format::HostLibsPolicy;
use std::borrow::Cow;
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{self, BufWriter, Read, Write};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use indicatif::{ProgressBar, ProgressStyle};
use jwalk::WalkDir;
use rayon::prelude::*;
use onelf_format::{
Entry, EntryKind, EntryPoint, EntryPointFlags, Flags, Footer, Manifest, ManifestHeader,
StringTableBuilder, WorkingDir,
};
use crate::compress;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HostLibs {
#[default]
Auto,
Always,
Never,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Payload {
#[default]
Blocks,
Stipple,
}
impl Payload {
pub fn default_block_size(self) -> u64 {
match self {
Payload::Blocks => compress::DEFAULT_BLOCK_SIZE,
Payload::Stipple => compress::DEFAULT_STIPPLE_BLOCK_SIZE,
}
}
fn max_block_size(self) -> u64 {
match self {
Payload::Blocks => compress::MAX_BLOCK_SIZE,
Payload::Stipple => compress::MAX_STIPPLE_BLOCK_SIZE,
}
}
}
pub struct PackOptions {
pub directory: PathBuf,
pub output: PathBuf,
pub command: String,
pub name: Option<String>,
pub entrypoints: Vec<(String, String, Vec<String>)>,
pub default_entrypoint: Option<String>,
pub lib_dirs: Vec<String>,
pub level: i32,
pub block_size: u64,
pub payload: Payload,
pub use_dict: bool,
pub no_compress: bool,
pub memfd: Option<bool>,
pub working_dir: WorkingDir,
pub host_libs: HostLibs,
pub cache: bool,
pub prefer_cache: bool,
pub update_url: Option<String>,
pub embed_updater: bool,
pub update_key: Option<Vec<u8>>,
pub update_revision: Option<u64>,
pub exclude: Vec<String>,
pub package_info: Option<String>,
pub mtime: Option<u64>,
pub env: Vec<(String, String)>,
pub paths: Vec<(String, String)>,
pub preload: Vec<String>,
pub needs_setuid: bool,
pub profile: Vec<(String, u64)>,
}
const PREFETCH_FILE: &str = ".onelf/prefetch";
pub fn read_profile(path: &Path) -> io::Result<Vec<(String, u64)>> {
let text = fs::read_to_string(path)
.map_err(|e| io::Error::new(e.kind(), format!("{}: {e}", path.display())))?;
parse_profile(&text).map_err(|(line, what)| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("{}:{line}: {what}", path.display()),
)
})
}
fn parse_profile(text: &str) -> Result<Vec<(String, u64)>, (usize, &'static str)> {
let mut blocks = Vec::new();
for (n, line) in text.lines().enumerate() {
if line.trim().is_empty() || line.starts_with('#') {
continue;
}
let (path, offset) = line
.split_once('\t')
.ok_or((n + 1, "expected a path and an offset separated by a tab"))?;
let offset = offset
.trim()
.parse::<u64>()
.map_err(|_| (n + 1, "the offset is not a number"))?;
blocks.push((path.to_string(), offset));
}
Ok(blocks)
}
fn prefetch_lines(
files: &[CollectedFile],
profile: &[(String, u64)],
block_size: u64,
) -> Vec<String> {
let sizes: HashMap<&Path, u64> = files
.iter()
.map(|f| (f.rel_path.as_path(), f.size))
.collect();
let mut seen = std::collections::HashSet::new();
let mut lines = Vec::new();
for (path, offset) in profile {
let rel = package_relative(path);
if !sizes.get(rel.as_path()).is_some_and(|&size| *offset < size) {
continue;
}
let Some(rel) = rel.to_str() else {
continue;
};
let block = offset / block_size;
if seen.insert((rel.to_string(), block)) {
lines.push(format!("{rel}\t{block}"));
}
}
lines
}
enum FileSource {
Disk(PathBuf),
Memory(Vec<u8>),
}
fn open_walked(path: &Path) -> io::Result<std::fs::File> {
let fd = rustix::fs::open(
path,
rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::NONBLOCK,
rustix::fs::Mode::empty(),
)?;
let file = std::fs::File::from(fd);
let meta = file.metadata()?;
if !meta.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{}: changed while packing; no longer a regular file",
path.display()
),
));
}
Ok(file)
}
impl FileSource {
fn read(&self) -> io::Result<Cow<'_, [u8]>> {
match self {
FileSource::Disk(path) => {
let mut file = open_walked(path)?;
let mut buf = Vec::new();
file.read_to_end(&mut buf)?;
Ok(Cow::Owned(buf))
}
FileSource::Memory(bytes) => Ok(Cow::Borrowed(bytes)),
}
}
}
struct CollectedFile {
rel_path: PathBuf,
source: FileSource,
size: u64,
mode: u32,
mtime_secs: u64,
mtime_nsec: u32,
}
struct CompressedFile {
rel_path: PathBuf,
blocks: Vec<onelf_format::Block>,
content_hash: [u8; 32],
mode: u32,
mtime_secs: u64,
mtime_nsec: u32,
}
struct CollectedDir {
rel_path: PathBuf,
mode: u32,
mtime_secs: u64,
mtime_nsec: u32,
}
struct CollectedSymlink {
rel_path: PathBuf,
target: PathBuf,
mode: u32,
mtime_secs: u64,
mtime_nsec: u32,
}
fn default_entrypoint_path(opts: &PackOptions) -> PathBuf {
opts.default_entrypoint
.as_deref()
.and_then(|name| opts.entrypoints.iter().find(|(n, _, _)| n == name))
.map(|(_, path, _)| PathBuf::from(path))
.unwrap_or_else(|| PathBuf::from(&opts.command))
}
fn compression_budget() -> u64 {
const DEFAULT: u64 = 64 * 1024 * 1024;
std::env::var("ONELF_PACK_CHUNK_BYTES")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|&v| v > 0)
.unwrap_or(DEFAULT)
}
fn size_bounded_chunks(files: &[CollectedFile], budget: u64) -> Vec<(usize, usize)> {
let mut runs = Vec::new();
let mut start = 0usize;
let mut acc = 0u64;
for (i, f) in files.iter().enumerate() {
if i > start && acc + f.size > budget {
runs.push((start, i));
start = i;
acc = 0;
}
acc += f.size;
}
if start < files.len() {
runs.push((start, files.len()));
}
runs
}
const DICT_SAMPLE_BUDGET: usize = 100 * 1024 * 1024;
const DICT_SAMPLE_PER_FILE: usize = 1024 * 1024;
fn build_dictionary_sampled(files: &[CollectedFile], dict_size: usize) -> io::Result<Vec<u8>> {
let mut samples: Vec<Vec<u8>> = Vec::new();
let mut total = 0usize;
for f in files {
if total >= DICT_SAMPLE_BUDGET {
break;
}
let content = f.source.read()?;
if content.is_empty() {
continue;
}
let take = content.len().min(DICT_SAMPLE_PER_FILE);
samples.push(content[..take].to_vec());
total += take;
}
if samples.len() < 2 {
return Err(io::Error::other("not enough sample data"));
}
compress::build_dictionary(&samples, dict_size)
}
fn auto_detect_lib_dirs(directory: &Path) -> Vec<String> {
let mut lib_dirs = Vec::new();
let Ok(canonical) = directory.canonicalize() else {
return lib_dirs;
};
for entry in WalkDir::new(&canonical).skip_hidden(false).sort(true) {
let Ok(entry) = entry else { continue };
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !name.contains(".so") {
continue;
}
let rel = path
.parent()
.and_then(|p| p.strip_prefix(&canonical).ok())
.map(|p| p.to_string_lossy().to_string());
if let Some(dir) = rel {
if dir.is_empty() {
continue;
}
if dir.starts_with(".onelf")
|| dir.starts_with("share/")
|| dir.starts_with("bin/")
|| dir.starts_with("etc/")
{
continue;
}
if !lib_dirs.contains(&dir) {
lib_dirs.push(dir);
}
}
}
lib_dirs
}
fn check_libgl_conflicts(directory: &Path, lib_dirs: &mut Vec<String>) {
let mut glvnd_dirs = Vec::new();
let mut legacy_dirs = Vec::new();
for dir in lib_dirs.iter() {
let full = directory.join(dir);
let gl_path = full.join("libGL.so.1");
if !gl_path.exists() {
continue;
}
let resolved = std::fs::canonicalize(&gl_path).unwrap_or(gl_path.clone());
if let Ok(data) = std::fs::read(&resolved) {
let has_gldispatch = data.windows(14).any(|w| w == b"libGLdispatch\0")
|| data.windows(7).any(|w| w == b"libGLX\0");
if has_gldispatch {
glvnd_dirs.push(dir.clone());
} else {
legacy_dirs.push(dir.clone());
}
}
}
if !glvnd_dirs.is_empty() && !legacy_dirs.is_empty() {
eprintln!(" warning: conflicting libGL.so detected");
eprintln!(" glvnd (modern): {}", glvnd_dirs.join(", "));
eprintln!(" legacy Mesa: {}", legacy_dirs.join(", "));
eprintln!(" Removing legacy dirs to avoid GL initialization failures");
lib_dirs.retain(|d| !legacy_dirs.contains(d));
}
}
fn utf8_file_name(p: &Path) -> io::Result<&str> {
p.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("non-UTF-8 file name: {}", p.display()),
)
})
}
fn utf8_str(p: &Path) -> io::Result<&str> {
p.to_str().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("non-UTF-8 path: {}", p.display()),
)
})
}
fn inject_onelf_file(
dirs: &mut Vec<CollectedDir>,
files: &mut Vec<CollectedFile>,
rel_path: &str,
content: Vec<u8>,
mtime: u64,
) {
if !dirs.iter().any(|d| d.rel_path == Path::new(".onelf")) {
dirs.push(CollectedDir {
rel_path: PathBuf::from(".onelf"),
mode: 0o755,
mtime_secs: mtime,
mtime_nsec: 0,
});
}
let size = content.len() as u64;
files.push(CollectedFile {
rel_path: PathBuf::from(rel_path),
source: FileSource::Memory(content),
size,
mode: 0o644,
mtime_secs: mtime,
mtime_nsec: 0,
});
}
fn compile_gsettings_schemas(
dirs: &[CollectedDir],
files: &mut Vec<CollectedFile>,
directory: &Path,
inject_mtime: u64,
) {
const SCHEMAS_DIR: &str = "share/glib-2.0/schemas";
let schemas_rel = Path::new(SCHEMAS_DIR);
let compiled_rel = schemas_rel.join("gschemas.compiled");
if !dirs.iter().any(|d| d.rel_path == schemas_rel) {
return;
}
if files.iter().any(|f| f.rel_path == compiled_rel) {
return;
}
let xml_count = files
.iter()
.filter(|f| {
f.rel_path.parent() == Some(schemas_rel)
&& f.rel_path.extension().is_some_and(|e| e == "xml")
})
.count();
if xml_count == 0 {
return;
}
let compiler = crate::bundle::gpu::find_glib_compile_schemas();
let tmp = match crate::bundle::private_temp_dir("schemas") {
Ok(tmp) => tmp,
Err(e) => {
eprintln!("warning: no scratch directory to compile GSettings schemas in: {e}");
return;
}
};
let run = std::process::Command::new(&compiler)
.arg("--targetdir")
.arg(&tmp)
.arg(directory.join(schemas_rel))
.output();
let blob = match run {
Ok(out) if out.status.success() => fs::read(tmp.join("gschemas.compiled")),
Ok(out) => {
eprintln!(
"warning: glib-compile-schemas failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
Ok(Vec::new())
}
Err(e) => {
eprintln!(
"warning: glib-compile-schemas not runnable ({e}); ship a compiled \
gschemas.compiled or install glib development tools"
);
Ok(Vec::new())
}
};
let _ = fs::remove_dir_all(&tmp);
match blob {
Ok(bytes) if !bytes.is_empty() => {
eprintln!(
" Compiled GSettings schemas ({} bytes, {} sources)",
bytes.len(),
xml_count
);
let size = bytes.len() as u64;
files.push(CollectedFile {
rel_path: compiled_rel,
source: FileSource::Memory(bytes),
size,
mode: 0o644,
mtime_secs: inject_mtime,
mtime_nsec: 0,
});
}
Ok(_) => {}
Err(e) => eprintln!("warning: could not read compiled schemas: {e}"),
}
}
fn package_relative(path: &str) -> PathBuf {
Path::new(path)
.components()
.filter(|c| !matches!(c, std::path::Component::CurDir))
.collect()
}
fn sibling_tmp(output: &Path, tag: &str) -> PathBuf {
let mut name = output.as_os_str().to_os_string();
name.push(format!(".onelf-{tag}-{}.tmp", std::process::id()));
PathBuf::from(name)
}
fn is_onelf_package(path: &Path, len: u64) -> bool {
use std::io::{Seek, SeekFrom};
if len < onelf_format::FOOTER_SIZE as u64 {
return false;
}
let Ok(mut f) = open_walked(path) else {
return false;
};
let mut head = [0u8; 16];
if f.read_exact(&mut head).is_err() || head[0..4] != *b"\x7fELF" || head[9..15] != *b"ONELF\0" {
return false;
}
let mut tail = [0u8; 8];
f.seek(SeekFrom::End(-8)).is_ok()
&& f.read_exact(&mut tail).is_ok()
&& tail == onelf_format::END_MAGIC
}
struct Scratch(Vec<PathBuf>);
impl Drop for Scratch {
fn drop(&mut self) {
for path in &self.0 {
let _ = fs::remove_file(path);
}
}
}
fn is_scratch_name(name: &str) -> bool {
name.ends_with(".tmp") && name.contains(".onelf-")
}
pub fn pack(opts: &PackOptions, runtime_binary: &[u8]) -> io::Result<()> {
let max_block_size = opts.payload.max_block_size();
if !(compress::MIN_BLOCK_SIZE..=max_block_size).contains(&opts.block_size) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"block size {} is outside {}..={} bytes",
opts.block_size,
compress::MIN_BLOCK_SIZE,
max_block_size
),
));
}
if opts.payload == Payload::Stipple {
if !opts.block_size.is_power_of_two() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"a stipplefs payload needs a power-of-two block size, got {}",
opts.block_size
),
));
}
if opts.use_dict {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--dict trains a dictionary for a blocks payload; a stipplefs payload trains its own",
));
}
}
if let Some(ref url) = opts.update_url
&& !url.starts_with("https://")
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("update URL must use https://, got {url}"),
));
}
if let Some(ref key) = opts.update_key {
check_update_key(key)?;
}
let dir = opts.directory.canonicalize()?;
let mut lib_dirs = if opts.lib_dirs.iter().any(|d| d == "auto") {
let mut dirs: Vec<String> = opts
.lib_dirs
.iter()
.filter(|d| *d != "auto")
.cloned()
.collect();
let auto = auto_detect_lib_dirs(&dir);
for d in auto {
if !dirs.contains(&d) {
dirs.push(d);
}
}
if !dirs.is_empty() {
eprintln!(" Auto-detected lib dirs: {}", dirs.join(", "));
}
dirs
} else {
opts.lib_dirs.clone()
};
check_libgl_conflicts(&dir, &mut lib_dirs);
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.green} {msg}")
.unwrap(),
);
pb.set_message("Scanning directory...");
let mut dirs: Vec<CollectedDir> = Vec::new();
let mut files: Vec<CollectedFile> = Vec::new();
let mut symlinks: Vec<CollectedSymlink> = Vec::new();
let mut dropped_special: Vec<(PathBuf, &'static str)> = Vec::new();
let mut dropped_scratch: Vec<PathBuf> = Vec::new();
let mut dropped_packages: Vec<PathBuf> = Vec::new();
let dir_abs = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
let output_abs = opts.output.parent().and_then(|parent| {
let parent = if parent.as_os_str().is_empty() {
Path::new(".")
} else {
parent
};
Some(parent.canonicalize().ok()?.join(opts.output.file_name()?))
});
for entry in WalkDir::new(&dir).skip_hidden(false).sort(true) {
let entry = entry.map_err(io::Error::other)?;
let abs_path = entry.path();
let rel_path = abs_path.strip_prefix(&dir).unwrap().to_path_buf();
if rel_path.as_os_str().is_empty() {
continue;
}
if output_abs.as_deref() == Some(dir_abs.join(&rel_path).as_path()) {
continue;
}
if rel_path == Path::new(crate::bundle::sysroot::GENERATED_FILE) {
continue;
}
if is_scratch_name(&entry.file_name().to_string_lossy()) {
dropped_scratch.push(rel_path);
continue;
}
if !opts.exclude.is_empty() {
let excluded = rel_path.components().any(|c| {
let name = c.as_os_str().to_string_lossy();
opts.exclude.iter().any(|pat| {
if let Some(ext) = pat.strip_prefix("*.") {
name.ends_with(&format!(".{ext}"))
} else {
name == pat.as_str()
}
})
});
if excluded {
continue;
}
}
let symlink_meta = fs::symlink_metadata(&abs_path)?;
let (mtime_secs, mtime_nsec) = get_mtime(&symlink_meta, opts.mtime);
let mode = symlink_meta.permissions().mode();
if symlink_meta.is_symlink() {
let target = fs::read_link(&abs_path)?;
symlinks.push(CollectedSymlink {
rel_path,
target,
mode,
mtime_secs,
mtime_nsec,
});
} else if symlink_meta.is_dir() {
dirs.push(CollectedDir {
rel_path,
mode,
mtime_secs,
mtime_nsec,
});
} else if symlink_meta.is_file() {
if is_onelf_package(&abs_path, symlink_meta.len()) {
dropped_packages.push(rel_path);
continue;
}
files.push(CollectedFile {
rel_path,
source: FileSource::Disk(abs_path.clone()),
size: symlink_meta.len(),
mode,
mtime_secs,
mtime_nsec,
});
} else {
use std::os::unix::fs::FileTypeExt;
let ft = symlink_meta.file_type();
let kind = if ft.is_fifo() {
"fifo"
} else if ft.is_socket() {
"socket"
} else if ft.is_char_device() {
"char device"
} else if ft.is_block_device() {
"block device"
} else {
"special file"
};
dropped_special.push((rel_path, kind));
}
}
for (path, kind) in dropped_special.iter().take(10) {
eprintln!(
"warning: dropping {kind} {} (not representable in a package)",
path.display()
);
}
if dropped_special.len() > 10 {
eprintln!(
"warning: dropping {} more special files (not representable in a package)",
dropped_special.len() - 10
);
}
for path in dropped_scratch.iter().take(10) {
eprintln!(
"warning: dropping {} (scratch name left by a crashed pack; rename it if it is content)",
path.display()
);
}
if dropped_scratch.len() > 10 {
eprintln!(
"warning: dropping {} more scratch-named files",
dropped_scratch.len() - 10
);
}
for path in &dropped_packages {
eprintln!(
"warning: dropping {} (a onelf package, such as an earlier output)",
path.display()
);
}
let inject_mtime = opts.mtime.unwrap_or(0);
let reserved = Path::new(".onelf");
let collision = files
.iter()
.map(|f| &f.rel_path)
.chain(dirs.iter().map(|d| &d.rel_path))
.chain(symlinks.iter().map(|s| &s.rel_path))
.find(|p| {
p.starts_with(reserved)
&& p.as_path() != reserved
&& !p.starts_with(".onelf/icons")
&& !p.starts_with(".onelf/desktop")
&& p.as_path() != Path::new(crate::bundle::sysroot::PROVENANCE_FILE)
&& p.as_path() != Path::new(crate::bundle::sysroot::PLATFORM_FILE)
&& p.as_path() != Path::new(crate::bundle::sysroot::SETS_FILE)
&& p.as_path() != Path::new(crate::bundle::sysroot::SHIPPED_FILE)
&& p.as_path() != Path::new(crate::bundle::sysroot::PATHS_FILE)
});
if let Some(p) = collision {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!(
"source contains reserved path {}; the .onelf/ namespace is injected by onelf",
p.display()
),
));
}
if let Some(ref url) = opts.update_url {
inject_onelf_file(
&mut dirs,
&mut files,
".onelf/update-url",
url.as_bytes().to_vec(),
inject_mtime,
);
}
if let Some(rev) = opts.update_revision {
inject_onelf_file(
&mut dirs,
&mut files,
".onelf/update-revision",
rev.to_string().into_bytes(),
inject_mtime,
);
}
if let Some(ref key) = opts.update_key {
inject_onelf_file(
&mut dirs,
&mut files,
".onelf/update-key",
key.clone(),
inject_mtime,
);
}
if let Some(ref info) = opts.package_info {
inject_onelf_file(
&mut dirs,
&mut files,
".onelf/package-info.toml",
info.as_bytes().to_vec(),
inject_mtime,
);
}
let package_name = opts
.name
.as_deref()
.unwrap_or_else(|| opts.command.split('/').next_back().unwrap_or("app"));
compile_gsettings_schemas(&dirs, &mut files, &opts.directory, inject_mtime);
{
let entry_target = default_entrypoint_path(opts);
let original_interp = files
.iter()
.find(|f| f.rel_path == entry_target)
.and_then(|f| f.source.read().ok())
.and_then(|c| elf_interp(&c));
let bundled_relpath = original_interp.as_ref().and_then(|interp| {
let interp_name = Path::new(interp).file_name()?.to_str()?;
let match_name = |p: &Path| p.file_name().and_then(|n| n.to_str()) == Some(interp_name);
files
.iter()
.find(|f| match_name(&f.rel_path))
.map(|f| f.rel_path.to_string_lossy().into_owned())
.or_else(|| {
symlinks
.iter()
.find(|s| match_name(&s.rel_path))
.map(|s| s.rel_path.to_string_lossy().into_owned())
})
});
if let Some(bundled_rel) = bundled_relpath {
inject_onelf_file(
&mut dirs,
&mut files,
".onelf/interp",
bundled_rel.into_bytes(),
inject_mtime,
);
}
}
{
let user_sets_path = opts.env.iter().any(|(k, _)| k == "PATH");
let mut env_lines: Vec<String> = Vec::new();
if !user_sets_path {
env_lines.push("PATH=${ONELF_DIR}/bin:${PATH:-/usr/bin:/bin}".to_string());
}
for (k, v) in &opts.env {
env_lines.push(format!("{k}={v}"));
}
inject_onelf_file(
&mut dirs,
&mut files,
".onelf/env",
env_lines.join("\n").into_bytes(),
inject_mtime,
);
}
if !opts.paths.is_empty() {
let lines: Vec<String> = opts
.paths
.iter()
.map(|(abs, rel)| format!("{abs}\t{rel}"))
.collect();
inject_onelf_file(
&mut dirs,
&mut files,
crate::bundle::sysroot::PATHS_FILE,
lines.join("\n").into_bytes(),
inject_mtime,
);
}
if !opts.preload.is_empty() {
inject_onelf_file(
&mut dirs,
&mut files,
".onelf/preload",
opts.preload.join("\n").into_bytes(),
inject_mtime,
);
}
if opts.needs_setuid {
inject_onelf_file(
&mut dirs,
&mut files,
".onelf/needs-setuid",
Vec::new(),
inject_mtime,
);
}
if !opts.profile.is_empty() {
let unit = match opts.payload {
Payload::Blocks => opts.block_size,
Payload::Stipple => onelf_format::payload::SLICE_SIZE,
};
let lines = prefetch_lines(&files, &opts.profile, unit);
if lines.is_empty() {
eprintln!("warning: the profile names no block of this tree; nothing to prefetch");
} else {
eprintln!(" Prefetch: {} blocks from the profile", lines.len());
inject_onelf_file(
&mut dirs,
&mut files,
PREFETCH_FILE,
lines.join("\n").into_bytes(),
inject_mtime,
);
}
}
pb.finish_with_message(format!(
"Found {} dirs, {} files, {} symlinks",
dirs.len(),
files.len(),
symlinks.len()
));
if let Some(dup) = opts
.entrypoints
.iter()
.enumerate()
.find(|(i, (n, _, _))| opts.entrypoints[..*i].iter().any(|(m, _, _)| m == n))
.map(|(_, (n, _, _))| n)
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("duplicate entrypoint name '{dup}'"),
));
}
if let Some(name) = opts.default_entrypoint.as_deref()
&& !opts.entrypoints.iter().any(|(n, _, _)| n == name)
{
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("default entrypoint '{name}' matches no declared entrypoint"),
));
}
{
let walked: std::collections::HashSet<&Path> = files
.iter()
.map(|f| f.rel_path.as_path())
.chain(dirs.iter().map(|d| d.rel_path.as_path()))
.chain(symlinks.iter().map(|s| s.rel_path.as_path()))
.collect();
if !walked.contains(package_relative(&opts.command).as_path()) {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("command path '{}' not found in directory", opts.command),
));
}
if let Some((_, path, _)) = opts
.entrypoints
.iter()
.find(|(_, p, _)| !walked.contains(package_relative(p).as_path()))
{
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("entrypoint path '{}' not found in directory", path),
));
}
}
let total_content_size: u64 = files
.iter()
.map(|f| match &f.source {
FileSource::Disk(p) => fs::metadata(p).map(|m| m.len()).unwrap_or(0),
FileSource::Memory(b) => b.len() as u64,
})
.sum();
let dict = if opts.no_compress {
if opts.use_dict {
eprintln!("note: store mode overrides dictionary; payload stored raw");
}
None
} else if opts.use_dict && files.len() > 1 && total_content_size > 4096 {
let pb = ProgressBar::new_spinner();
pb.set_message("Building dictionary...");
let dict_size = 1_048_576.min(total_content_size as usize / 2);
match build_dictionary_sampled(&files, dict_size) {
Ok(dict) => {
pb.finish_with_message("Dictionary built");
Some(dict)
}
Err(e) => {
pb.finish_with_message(format!("Dictionary skipped: {e}"));
None
}
}
} else {
None
};
let pb = ProgressBar::new(files.len() as u64);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
.unwrap()
.progress_chars("=> "),
);
pb.set_message(if opts.no_compress {
"Storing files..."
} else {
"Compressing files..."
});
let payload_tmp_path = sibling_tmp(&opts.output, "payload");
let staged = sibling_tmp(&opts.output, "staged");
let _scratch = Scratch(vec![payload_tmp_path.clone(), staged.clone()]);
let mut payload_tmp = BufWriter::new(File::create(&payload_tmp_path)?);
let mut payload_offset: u64 = 0;
let mut compressed_files: Vec<CompressedFile> = Vec::with_capacity(files.len());
let mut seen: HashMap<[u8; 32], Vec<onelf_format::Block>> = HashMap::new();
let mut deduped_bytes: u64 = 0;
let mut blocks_payload = || -> io::Result<()> {
for (from, to) in size_bounded_chunks(&files, compression_budget()) {
let chunk = &files[from..to];
let done: Vec<(usize, [u8; 32], Vec<compress::CompressedBlock>)> = chunk
.par_iter()
.enumerate()
.map(|(i, f)| -> io::Result<_> {
let content = f.source.read()?;
let hash: [u8; 32] = *blake3::hash(&content).as_bytes();
let blocks = if opts.no_compress {
compress::store_in_blocks(&content, opts.block_size)
} else {
compress::compress_in_blocks(
&content,
opts.level,
dict.as_deref(),
opts.block_size,
)
.map_err(|e| {
io::Error::other(format!("compress {}: {e}", f.rel_path.display()))
})?
};
pb.inc(1);
Ok((i, hash, blocks))
})
.collect::<io::Result<Vec<_>>>()?;
let mut done = done;
done.sort_by_key(|(i, _, _)| *i);
for (i, content_hash, raw) in done {
let f = &chunk[i];
let blocks = match seen.get(&content_hash) {
Some(shared) => {
deduped_bytes += raw.iter().map(|b| b.data.len() as u64).sum::<u64>();
shared.clone()
}
None => {
let mut blocks = Vec::with_capacity(raw.len());
for b in &raw {
payload_tmp.write_all(&b.data)?;
blocks.push(onelf_format::Block {
payload_offset,
compressed_size: b.data.len() as u64,
original_size: b.original_size,
content_hash: b.content_hash,
});
payload_offset += b.data.len() as u64;
}
seen.insert(content_hash, blocks.clone());
blocks
}
};
compressed_files.push(CompressedFile {
rel_path: f.rel_path.clone(),
blocks,
content_hash,
mode: f.mode,
mtime_secs: f.mtime_secs,
mtime_nsec: f.mtime_nsec,
});
}
}
payload_tmp.flush()
};
let result = match opts.payload {
Payload::Blocks => blocks_payload(),
Payload::Stipple => stipple_payload(&files, opts, &mut payload_tmp, &payload_tmp_path, &pb)
.map(|(built, size)| {
compressed_files = built;
payload_offset = size;
}),
};
drop(payload_tmp);
result?;
pb.finish_with_message(if opts.no_compress {
"Files stored (uncompressed)"
} else {
"Compression complete"
});
let mut strings = StringTableBuilder::new();
let mut path_to_index: HashMap<PathBuf, u32> = HashMap::new();
let mut entries: Vec<Entry> = Vec::new();
let root_name = strings.add("");
let name_offset = u16::try_from(strings.add(package_name)).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
"package name offset exceeds u16 (string table too large)",
)
})?;
entries.push(Entry {
kind: EntryKind::Dir,
parent: u32::MAX,
name: root_name,
mode: 0o755,
mtime_secs: 0,
mtime_nsec: 0,
content_hash: [0; 32],
blocks: Vec::new(),
symlink_target: 0,
});
path_to_index.insert(PathBuf::new(), 0);
let mut sorted_dirs = dirs;
sorted_dirs.sort_by_key(|d| d.rel_path.components().count());
for d in &sorted_dirs {
let name_str = utf8_file_name(&d.rel_path)?;
let name = strings.add(name_str);
let parent_path = d.rel_path.parent().unwrap_or(Path::new(""));
let parent = *path_to_index.get(parent_path).unwrap_or(&0);
let idx = entries.len() as u32;
entries.push(Entry {
kind: EntryKind::Dir,
parent,
name,
mode: d.mode,
mtime_secs: d.mtime_secs,
mtime_nsec: d.mtime_nsec,
content_hash: [0; 32],
blocks: Vec::new(),
symlink_target: 0,
});
path_to_index.insert(d.rel_path.clone(), idx);
}
for cf in &compressed_files {
let name_str = utf8_file_name(&cf.rel_path)?;
let name = strings.add(name_str);
let parent_path = cf.rel_path.parent().unwrap_or(Path::new(""));
let parent = *path_to_index.get(parent_path).unwrap_or(&0);
let idx = entries.len() as u32;
let blocks = cf.blocks.clone();
entries.push(Entry {
kind: EntryKind::File,
parent,
name,
mode: cf.mode,
mtime_secs: cf.mtime_secs,
mtime_nsec: cf.mtime_nsec,
content_hash: cf.content_hash,
blocks,
symlink_target: 0,
});
path_to_index.insert(cf.rel_path.clone(), idx);
}
for sl in &symlinks {
let name_str = utf8_file_name(&sl.rel_path)?;
let name = strings.add(name_str);
let target_str = utf8_str(&sl.target)?;
let target = strings.add(target_str);
let parent_path = sl.rel_path.parent().unwrap_or(Path::new(""));
let parent = *path_to_index.get(parent_path).unwrap_or(&0);
let idx = entries.len() as u32;
entries.push(Entry {
kind: EntryKind::Symlink,
parent,
name,
mode: sl.mode,
mtime_secs: sl.mtime_secs,
mtime_nsec: sl.mtime_nsec,
content_hash: [0; 32],
blocks: Vec::new(),
symlink_target: target,
});
path_to_index.insert(sl.rel_path.clone(), idx);
}
let mut entrypoints: Vec<EntryPoint> = Vec::new();
let command_path = package_relative(&opts.command);
let command_entry_idx = *path_to_index.get(&command_path).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("command path '{}' not found in directory", opts.command),
)
})?;
let default_name = opts
.default_entrypoint
.as_deref()
.or_else(|| command_path.file_name().and_then(|n| n.to_str()))
.unwrap_or("main");
let default_decl = opts
.default_entrypoint
.as_deref()
.and_then(|dn| opts.entrypoints.iter().find(|(n, _, _)| n == dn));
let ep_name = strings.add(default_name);
let empty_args = strings.add("");
let (ep0_target_idx, ep0_target_path) = match default_decl {
Some((_, path, _)) => {
let p = package_relative(path);
let idx = *path_to_index.get(&p).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("entrypoint path '{}' not found in directory", path),
)
})?;
(idx, p)
}
None => (command_entry_idx, command_path.clone()),
};
let ep0_args = match default_decl {
Some((_, _, args)) if !args.is_empty() => strings.add(&args.join("\x1f")),
_ => empty_args,
};
let memfd_flag = match opts.memfd {
Some(false) => EntryPointFlags::empty(),
requested => {
let target_content = files
.iter()
.find(|f| f.rel_path == ep0_target_path)
.and_then(|f| f.source.read().ok());
if requested == Some(true)
&& target_content.as_deref().is_some_and(elf_needs_bundled_lib)
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"--memfd cannot be used with '{}': it is linked \
against {ONELF_ENV_SONAME}, which exists only inside the \
package, and memfd mode runs the entrypoint without \
extracting anything. Drop --memfd, or pack a target that \
bundle-libs has not processed.",
ep0_target_path.display()
),
));
}
if requested == Some(true) || target_content.as_deref().is_some_and(elf_has_no_deps) {
EntryPointFlags::MEMFD_ELIGIBLE
} else {
EntryPointFlags::empty()
}
}
};
entrypoints.push(EntryPoint {
name: ep_name,
target_entry: ep0_target_idx,
args: ep0_args,
working_dir: opts.working_dir,
flags: memfd_flag,
});
for (name, path, args) in &opts.entrypoints {
if opts.default_entrypoint.as_deref() == Some(name.as_str()) {
continue;
}
let ep_path = package_relative(path);
let ep_entry_idx = *path_to_index.get(&ep_path).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("entrypoint path '{}' not found in directory", path),
)
})?;
let ep_name = strings.add(name);
let ep_args = if args.is_empty() {
empty_args
} else {
strings.add(&args.join("\x1f"))
};
entrypoints.push(EntryPoint {
name: ep_name,
target_entry: ep_entry_idx,
args: ep_args,
working_dir: opts.working_dir,
flags: EntryPointFlags::empty(),
});
}
let lib_dir_offsets: Vec<u32> = lib_dirs.iter().map(|d| strings.add(d)).collect();
let string_table = strings.finish();
let mut runtime_patched = runtime_binary.to_vec();
if runtime_patched.len() >= 16 {
runtime_patched[9..15].copy_from_slice(b"ONELF\x00");
}
let manifest = Manifest {
header: ManifestHeader {
version: onelf_format::manifest::MANIFEST_VERSION,
entry_count: entries.len() as u32,
string_table_size: string_table.len() as u32,
entrypoint_count: entrypoints.len() as u16,
default_entrypoint: 0,
lib_dir_count: lib_dir_offsets.len() as u16,
name_offset,
package_id: [0; 32], prefix_hash: blake3::hash(&runtime_patched).into(),
footer_hash: [0; 32], },
entrypoints,
entries,
lib_dir_offsets,
string_table,
};
let mut manifest_bytes = manifest.serialize()?;
let package_id: [u8; 32] = *blake3::hash(&manifest_bytes).as_bytes();
manifest_bytes[18..50].copy_from_slice(&package_id);
let manifest_checksum = onelf_format::manifest::manifest_checksum(&manifest_bytes)?;
let total_payload: u64 = payload_offset;
let mut flags = Flags::empty();
if dict.is_some() {
flags |= Flags::HAS_DICT;
}
if opts.memfd == Some(true) {
flags |= Flags::MEMFD_HINT;
}
match opts.payload {
Payload::Blocks if opts.no_compress => flags |= Flags::STORED,
Payload::Blocks => {}
Payload::Stipple => flags |= Flags::STIPPLE_PAYLOAD,
}
if opts.update_url.is_some() && !opts.embed_updater {
flags |= Flags::EXTERNAL_UPDATER;
}
let policy = match opts.host_libs {
HostLibs::Always => HostLibsPolicy::Always,
HostLibs::Never => HostLibsPolicy::Never,
HostLibs::Auto if bundle_needs_host_libs(&files) => HostLibsPolicy::Auto,
HostLibs::Auto => HostLibsPolicy::Never,
};
flags |= policy.to_flags();
if opts.cache || opts.prefer_cache {
flags |= Flags::CACHE_REQUESTED;
}
if opts.prefer_cache {
flags |= Flags::CACHE_PREFERRED;
}
let pb = ProgressBar::new_spinner();
pb.set_message("Writing output...");
let runtime_size = runtime_binary.len() as u64;
let manifest_offset = runtime_size;
let dict_size = dict.as_ref().map(|d| d.len() as u32).unwrap_or(0);
let mut footer = Footer {
format_version: onelf_format::footer::FORMAT_VERSION,
flags,
manifest_offset,
manifest_compressed: 0,
manifest_original: 0,
payload_offset: 0,
payload_size: total_payload,
dict_offset: 0,
dict_size,
manifest_checksum,
};
let footer_hash = footer.structure_hash();
manifest_bytes[82..114].copy_from_slice(&footer_hash);
let manifest_compressed = compress::compress_manifest(&manifest_bytes)?;
footer.manifest_compressed = manifest_compressed.len() as u64;
footer.manifest_original = manifest_bytes.len() as u64;
footer.payload_offset = manifest_offset + manifest_compressed.len() as u64;
if dict.is_some() {
footer.dict_offset = footer.payload_offset + total_payload;
}
let out = File::create(&staged)?;
let mut w = BufWriter::new(out);
w.write_all(&runtime_patched)?;
w.write_all(&manifest_compressed)?;
let mut payload_src = File::open(&payload_tmp_path)?;
io::copy(&mut payload_src, &mut w)?;
drop(payload_src);
if let Some(ref d) = dict {
w.write_all(d)?;
}
footer.write_to(&mut w)?;
w.flush()?;
drop(w);
let _ = fs::remove_file(&payload_tmp_path);
let perms = fs::Permissions::from_mode(0o755);
fs::set_permissions(&staged, perms)?;
fs::rename(&staged, &opts.output)?;
let output_size = fs::metadata(&opts.output).map(|m| m.len()).unwrap_or(0);
pb.finish_with_message(format!("Written to {}", opts.output.display()));
let total_input = total_content_size;
let file_count = compressed_files.len();
let dir_count = sorted_dirs.len();
let symlink_count = symlinks.len();
eprintln!();
eprintln!(
" {} {} files, {} dirs, {} symlinks",
bold("Input:"),
file_count,
dir_count,
symlink_count
);
eprintln!(" {} {}", bold("Content:"), format_size(total_input));
if deduped_bytes > 0 {
eprintln!(
" {} {} saved by sharing identical content",
bold("Dedup:"),
format_size(deduped_bytes)
);
}
if opts.payload == Payload::Stipple {
eprintln!(
" {} {} (stipplefs image, {} blocks, {})",
bold("Payload:"),
format_size(total_payload),
format_size(opts.block_size),
if opts.no_compress {
"stored".to_string()
} else {
format!("zstd level {}", opts.level)
}
);
} else if opts.no_compress {
eprintln!(
" {} {} (stored, uncompressed)",
bold("Payload:"),
format_size(total_payload)
);
} else {
eprintln!(
" {} {} (zstd level {})",
bold("Payload:"),
format_size(total_payload),
opts.level
);
}
if let Some(ref d) = dict {
eprintln!(" {} {}", bold("Dict:"), format_size(d.len() as u64));
}
eprintln!(" {} {}", bold("Runtime:"), format_size(runtime_size));
eprintln!(
" {} {} (ratio: {:.2}x)",
bold("Output:"),
format_size(output_size),
if output_size > 0 {
total_input as f64 / output_size as f64
} else {
0.0
}
);
Ok(())
}
fn stipple_payload(
files: &[CollectedFile],
opts: &PackOptions,
out: &mut BufWriter<File>,
out_path: &Path,
pb: &ProgressBar,
) -> io::Result<(Vec<CompressedFile>, u64)> {
use std::os::unix::ffi::OsStrExt;
use stipplefs::format::Codec;
use stipplefs::write::{Builder, FileOrder, Node, Options, Timestamps};
let stipple_error = |e: stipplefs::error::Error| io::Error::other(format!("stipplefs: {e}"));
let codecs = if opts.no_compress {
Codec::Stored.bit()
} else {
Codec::Stored.bit()
| Codec::Zstd.bit()
| Codec::ZstdDict.bit()
| Codec::ZstdBcj.bit()
| Codec::ZstdDictBcj.bit()
};
let mut builder = Builder::new(Options {
block_size: opts.block_size as u32,
chunk_average: 4096,
codecs,
level: opts.level,
dictionary_size: if opts.no_compress || opts.block_size > 1 << 20 {
0
} else {
112 * 1024
},
threads: rayon::current_num_threads(),
memory_limit: None,
timestamps: Timestamps::Clamp,
compress_metadata: !opts.no_compress,
})
.map_err(stipple_error)?;
builder.set_order(FileOrder::Extension);
builder.set_hot_list(
opts.profile
.iter()
.map(|(path, _)| package_relative(path).into_os_string().into_encoded_bytes()),
);
for f in files {
let path = f.rel_path.as_os_str().as_bytes();
let mode = f.mode & 0o7777;
match &f.source {
FileSource::Disk(source) => builder.insert_file_at(path, source, mode, 0, 0),
FileSource::Memory(content) => builder.insert(
path,
Node::File {
content: content.clone(),
mode,
uid: 0,
gid: 0,
link_group: None,
},
),
}
.map_err(stipple_error)?;
}
pb.set_message("Building the stipplefs image...");
builder.build_into(out).map_err(stipple_error)?;
out.flush()?;
pb.set_message("Hashing files...");
let image = stipplefs::read::Image::open(out_path).map_err(stipple_error)?;
let reader = image.reader().map_err(stipple_error)?;
let inodes = files
.iter()
.map(|f| {
reader
.resolve(f.rel_path.as_os_str().as_bytes())
.ok_or_else(|| {
io::Error::other(format!("{}: not in the image", f.rel_path.display()))
})
})
.collect::<io::Result<Vec<u32>>>()?;
let mut order: Vec<usize> = (0..files.len()).collect();
order.sort_by_cached_key(|&i| {
reader
.file_extents(inodes[i])
.first()
.map_or(u64::MAX, |e| e.0)
});
let mut built: Vec<Option<CompressedFile>> = files.iter().map(|_| None).collect();
let mut buf = vec![0u8; onelf_format::payload::SLICE_SIZE as usize];
for i in order {
let (f, ino) = (&files[i], inodes[i]);
let size = reader.inode(ino).map_or(0, |n| n.size);
let mut whole = blake3::Hasher::new();
let mut blocks = Vec::new();
let mut offset = 0;
while offset < size {
let n = reader.read(ino, offset, &mut buf).map_err(stipple_error)?;
if n == 0 {
return Err(io::Error::other(format!(
"{}: the image ends the file early",
f.rel_path.display()
)));
}
let slice = &buf[..n];
whole.update(slice);
blocks.push(onelf_format::Block {
payload_offset: offset,
compressed_size: ino as u64,
original_size: n as u64,
content_hash: *blake3::hash(slice).as_bytes(),
});
offset += n as u64;
}
built[i] = Some(CompressedFile {
rel_path: f.rel_path.clone(),
blocks,
content_hash: *whole.finalize().as_bytes(),
mode: f.mode,
mtime_secs: f.mtime_secs,
mtime_nsec: f.mtime_nsec,
});
pb.inc(1);
}
let size = fs::metadata(out_path)?.len();
Ok((built.into_iter().flatten().collect(), size))
}
fn check_update_key(key: &[u8]) -> io::Result<()> {
use ed25519_compact::{KeyPair, Seed};
let invalid = |msg: String| Err(io::Error::new(io::ErrorKind::InvalidInput, msg));
if key.is_empty() || !key.len().is_multiple_of(32) {
return invalid(format!(
"update key must be one or more 32-byte Ed25519 public keys \
(a multiple of 32 bytes), got {} bytes",
key.len()
));
}
let (chunks, _) = key.as_chunks::<32>();
for (i, pair) in chunks.windows(2).enumerate() {
if *KeyPair::from_seed(Seed::new(pair[0])).pk == pair[1] {
return invalid(format!(
"update key bytes {}..{} look like an Ed25519 secret key, which \
must never ship in a package; pass the public key from \
`onelf key new` instead",
i * 32,
i * 32 + 64
));
}
}
for (i, chunk) in chunks.iter().enumerate() {
if chunks[..i].contains(chunk) {
return invalid(format!("update key {} repeats an earlier key", i + 1));
}
}
Ok(())
}
fn bold(s: &str) -> String {
if std::io::IsTerminal::is_terminal(&std::io::stderr()) {
format!("\x1b[1m{s}\x1b[0m")
} else {
s.to_string()
}
}
fn get_mtime(meta: &fs::Metadata, pin: Option<u64>) -> (u64, u32) {
if let Some(ts) = pin {
return (ts, 0);
}
let raw = meta
.modified()
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
if let Some(epoch) = source_date_epoch() {
return (raw.min(epoch), 0);
}
(raw, 0)
}
pub(crate) fn source_date_epoch() -> Option<u64> {
let value = std::env::var("SOURCE_DATE_EPOCH").ok()?;
let parsed = value.trim().parse().ok();
if parsed.is_none() {
static WARNED: std::sync::Once = std::sync::Once::new();
WARNED.call_once(|| {
eprintln!(
"warning: SOURCE_DATE_EPOCH={value:?} is not a whole number of \
seconds, so it is ignored"
)
});
}
parsed
}
fn elf_has_no_deps(data: &[u8]) -> bool {
if data.len() < 4 || data[0..4] != *b"\x7fELF" {
return false;
}
match goblin::elf::Elf::parse(data) {
Ok(elf) => elf.libraries.is_empty(),
Err(_) => false,
}
}
fn elf_needs_bundled_lib(data: &[u8]) -> bool {
if data.len() < 4 || data[0..4] != *b"\x7fELF" {
return false;
}
match goblin::elf::Elf::parse(data) {
Ok(elf) => elf
.libraries
.iter()
.any(|l| *l == ONELF_ENV_SONAME || *l == ONELF_ENV32_SONAME),
Err(_) => false,
}
}
fn elf_interp(data: &[u8]) -> Option<String> {
let (offset, size) = onelf_format::elf::pt_interp_slot(data)?;
let end = offset.checked_add(size)?;
let raw = data.get(offset..end)?;
let text = match raw.iter().position(|&b| b == 0) {
Some(nul) => &raw[..nul],
None => raw,
};
std::str::from_utf8(text).ok().map(String::from)
}
fn bundle_needs_host_libs(files: &[CollectedFile]) -> bool {
use onelf_format::drivers::DRIVER_FAMILIES;
for file in files {
let rel = file.rel_path.as_path();
if rel == Path::new(crate::bundle::sysroot::PLATFORM_FILE)
|| rel == Path::new(crate::bundle::sysroot::SETS_FILE)
{
return true;
}
let name = file
.rel_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if DRIVER_FAMILIES.iter().any(|f| name.contains(f)) {
return true;
}
let data = match &file.source {
FileSource::Disk(path) => {
let Ok(mut f) = open_walked(path) else {
continue;
};
let mut magic = [0u8; 4];
if std::io::Read::read_exact(&mut f, &mut magic).is_err() || magic != *b"\x7fELF" {
continue;
}
let mut data = magic.to_vec();
if std::io::Read::read_to_end(&mut f, &mut data).is_err() {
continue;
}
Cow::Owned(data)
}
FileSource::Memory(bytes) => {
if !bytes.starts_with(b"\x7fELF") {
continue;
}
Cow::Borrowed(bytes.as_slice())
}
};
if name == "libc.so.6" && !contains_bytes(&data, b"GLIBC_2.34") {
return true;
}
if DRIVER_FAMILIES
.iter()
.any(|f| contains_bytes(&data, f.as_bytes()))
{
return true;
}
}
false
}
fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
let Some((&first, rest)) = needle.split_first() else {
return true;
};
let mut i = 0;
while let Some(off) = haystack[i..].iter().position(|&b| b == first) {
let start = i + off;
let after = start + 1;
if haystack.len() >= after + rest.len() && &haystack[after..after + rest.len()] == rest {
return true;
}
i = after;
if i >= haystack.len() {
break;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn tmpdir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("onelf-pack-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn a_swapped_in_path_is_refused_not_hung() {
let dir = tmpdir("toctou");
let real = dir.join("real.bin");
fs::write(&real, b"content").unwrap();
let link = dir.join("link.bin");
std::os::unix::fs::symlink(&real, &link).unwrap();
assert!(open_walked(&link).is_err(), "a symlink must be refused");
let sock = dir.join("sock.bin");
let _listener = std::os::unix::net::UnixListener::bind(&sock).unwrap();
assert!(open_walked(&sock).is_err(), "a socket must be refused");
let mut f = open_walked(&real).unwrap();
let mut buf = Vec::new();
f.read_to_end(&mut buf).unwrap();
assert_eq!(buf, b"content");
fs::remove_dir_all(&dir).unwrap();
}
fn base_opts(dir: &Path, out: &Path, command: &str) -> PackOptions {
PackOptions {
directory: dir.to_path_buf(),
output: out.to_path_buf(),
command: command.to_string(),
name: None,
paths: Vec::new(),
entrypoints: Vec::new(),
default_entrypoint: None,
lib_dirs: Vec::new(),
level: 3,
block_size: compress::DEFAULT_BLOCK_SIZE,
payload: Payload::Blocks,
use_dict: false,
no_compress: false,
memfd: Some(false),
working_dir: WorkingDir::Inherit,
host_libs: HostLibs::default(),
cache: false,
prefer_cache: false,
update_url: None,
embed_updater: true,
update_key: None,
update_revision: None,
exclude: Vec::new(),
package_info: None,
mtime: Some(0),
env: Vec::new(),
preload: Vec::new(),
needs_setuid: false,
profile: Vec::new(),
}
}
#[test]
fn name_round_trips_past_64kib_string_table() {
let dir = tmpdir("name64k");
let app = dir.join("app");
fs::create_dir_all(app.join("bin")).unwrap();
fs::write(app.join("bin/run"), b"#!/bin/sh\n").unwrap();
let pad = "n".repeat(210);
for i in 0..400 {
fs::write(app.join(format!("file_{i:04}_{pad}")), b"x").unwrap();
}
let out = dir.join("pkg.onelf");
let mut opts = base_opts(&app, &out, "bin/run");
opts.name = Some("round-trip-name".to_string());
pack(&opts, b"stub-runtime").unwrap();
let (_footer, manifest) = crate::info::read_footer_and_manifest(&out).unwrap();
assert!(
manifest.string_table.len() > 0x1_0000,
"test must exceed a 64 KiB string table, got {}",
manifest.string_table.len()
);
assert_eq!(manifest.name(), "round-trip-name");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn default_entrypoint_uses_declared_path_and_args_without_duplicate() {
let dir = tmpdir("default-ep");
let app = dir.join("app");
fs::create_dir_all(app.join("bin")).unwrap();
fs::write(app.join("bin/a"), b"#!/bin/sh\necho a\n").unwrap();
fs::write(app.join("bin/b"), b"#!/bin/sh\necho b\n").unwrap();
let out = dir.join("pkg.onelf");
let mut opts = base_opts(&app, &out, "bin/a");
opts.entrypoints = vec![(
"b".to_string(),
"bin/b".to_string(),
vec!["--flag".to_string(), "x".to_string()],
)];
opts.default_entrypoint = Some("b".to_string());
pack(&opts, b"stub-runtime").unwrap();
let (_footer, manifest) = crate::info::read_footer_and_manifest(&out).unwrap();
assert_eq!(manifest.entrypoints.len(), 1);
let ep = &manifest.entrypoints[0];
assert_eq!(manifest.get_string(ep.name), "b");
assert_eq!(manifest.entry_path(ep.target_entry as usize), "bin/b");
assert_eq!(manifest.get_string(ep.args), "--flag\x1fx");
assert_eq!(manifest.header.default_entrypoint, 0);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn non_utf8_file_name_errors_instead_of_panicking() {
use std::os::unix::ffi::OsStrExt;
let dir = tmpdir("nonutf8");
let app = dir.join("app");
fs::create_dir_all(app.join("bin")).unwrap();
fs::write(app.join("bin/run"), b"#!/bin/sh\n").unwrap();
let bad = std::ffi::OsStr::from_bytes(b"bad-\xff\xfe-name");
fs::write(app.join(bad), b"x").unwrap();
let out = dir.join("pkg.onelf");
let opts = base_opts(&app, &out, "bin/run");
let err = pack(&opts, b"stub-runtime").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
let _ = fs::remove_dir_all(&dir);
}
const SCHEMA_XML: &str = concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
"<schemalist>\n",
" <schema id=\"com.example.pack-test\" path=\"/com/example/pack-test/\">\n",
" <key name=\"greeting\" type=\"s\">\n",
" <default>'hi'</default>\n",
" <summary>Greeting</summary>\n",
" </key>\n",
" </schema>\n",
"</schemalist>\n",
);
fn schemas_tree(tag: &str) -> (PathBuf, Vec<CollectedDir>, Vec<CollectedFile>) {
let dir = tmpdir(tag);
let schemas = dir.join("share/glib-2.0/schemas");
fs::create_dir_all(&schemas).unwrap();
fs::write(
schemas.join("com.example.pack-test.gschema.xml"),
SCHEMA_XML,
)
.unwrap();
let dirs = vec![CollectedDir {
rel_path: PathBuf::from("share/glib-2.0/schemas"),
mode: 0o755,
mtime_secs: 0,
mtime_nsec: 0,
}];
let files = vec![CollectedFile {
rel_path: PathBuf::from("share/glib-2.0/schemas/com.example.pack-test.gschema.xml"),
source: FileSource::Disk(schemas.join("com.example.pack-test.gschema.xml")),
size: SCHEMA_XML.len() as u64,
mode: 0o644,
mtime_secs: 0,
mtime_nsec: 0,
}];
(dir, dirs, files)
}
fn glib_tool_available() -> bool {
std::process::Command::new("which")
.arg("glib-compile-schemas")
.output()
.is_ok_and(|o| o.status.success())
}
#[test]
fn pack_compiles_missing_gsettings_schemas() {
if !glib_tool_available() {
eprintln!("skip: glib-compile-schemas not available");
return;
}
let (dir, dirs, mut files) = schemas_tree("compile");
compile_gsettings_schemas(&dirs, &mut files, &dir, 0);
let blob = files
.iter()
.find(|f| f.rel_path == Path::new("share/glib-2.0/schemas/gschemas.compiled"))
.expect("compiled schemas entry injected");
let FileSource::Memory(bytes) = &blob.source else {
panic!("compiled schemas must be an in-memory entry");
};
assert!(!bytes.is_empty());
assert!(
!dir.join("share/glib-2.0/schemas/gschemas.compiled")
.exists()
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn pack_keeps_shipped_gsettings_blob() {
if !glib_tool_available() {
eprintln!("skip: glib-compile-schemas not available");
return;
}
let (dir, dirs, mut files) = schemas_tree("shipped");
let sentinel = b"packager-provided blob".to_vec();
let blob_path = dir.join("share/glib-2.0/schemas/gschemas.compiled");
fs::write(&blob_path, &sentinel).unwrap();
files.push(CollectedFile {
rel_path: PathBuf::from("share/glib-2.0/schemas/gschemas.compiled"),
source: FileSource::Disk(blob_path.clone()),
size: sentinel.len() as u64,
mode: 0o644,
mtime_secs: 0,
mtime_nsec: 0,
});
compile_gsettings_schemas(&dirs, &mut files, &dir, 0);
assert_eq!(files.len(), 2, "no entry injected past the shipped blob");
assert_eq!(fs::read(&blob_path).unwrap(), sentinel);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn pack_ignores_schema_dir_without_xml() {
let (dir, dirs, mut files) = schemas_tree("noxml");
files.clear();
let readme = dir.join("share/glib-2.0/schemas/README");
fs::write(&readme, b"not a schema").unwrap();
files.push(CollectedFile {
rel_path: PathBuf::from("share/glib-2.0/schemas/README"),
source: FileSource::Disk(readme),
size: 12,
mode: 0o644,
mtime_secs: 0,
mtime_nsec: 0,
});
compile_gsettings_schemas(&dirs, &mut files, &dir, 0);
assert!(files.len() == 1, "nothing injected without schema XML");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn update_key_refuses_a_secret_key_and_repeats() {
use ed25519_compact::{KeyPair, Seed};
let a = KeyPair::from_seed(Seed::new([7u8; 32]));
let b = KeyPair::from_seed(Seed::new([9u8; 32]));
let err = check_update_key(&a.sk[..]).unwrap_err().to_string();
assert!(err.contains("secret key"), "{err}");
let ring_with_secret = [&b.pk[..], &a.sk[..]].concat();
assert!(check_update_key(&ring_with_secret).is_err());
let repeated = [&a.pk[..], &b.pk[..], &a.pk[..]].concat();
let err = check_update_key(&repeated).unwrap_err().to_string();
assert!(err.contains("repeats"), "{err}");
check_update_key(&a.pk[..]).unwrap();
check_update_key(&[&a.pk[..], &b.pk[..]].concat()).unwrap();
assert!(check_update_key(&[]).is_err());
assert!(check_update_key(&[0u8; 33]).is_err());
}
#[test]
fn plain_http_update_url_is_refused() {
let dir = tmpdir("http-url");
let app = dir.join("app");
fs::create_dir_all(app.join("bin")).unwrap();
fs::write(app.join("bin/run"), b"#!/bin/sh\n").unwrap();
let out = dir.join("pkg.onelf");
let mut opts = base_opts(&app, &out, "bin/run");
opts.update_url = Some("http://example.com/app.onelf.zsync".to_string());
let err = pack(&opts, b"stub-runtime").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
assert!(!out.exists());
opts.update_url = Some("https://example.com/app.onelf.zsync".to_string());
pack(&opts, b"stub-runtime").unwrap();
let _ = fs::remove_dir_all(&dir);
}
fn sized(path: &str, size: u64) -> CollectedFile {
CollectedFile {
rel_path: PathBuf::from(path),
source: FileSource::Memory(Vec::new()),
size,
mode: 0o644,
mtime_secs: 0,
mtime_nsec: 0,
}
}
#[test]
fn a_profile_reads_as_paths_and_offsets() {
let text = "# a comment\nlib/libfoo.so\t0\n\nlib/libfoo.so\t65536\nbin/app\t4096\n";
assert_eq!(
parse_profile(text).unwrap(),
vec![
("lib/libfoo.so".to_string(), 0),
("lib/libfoo.so".to_string(), 65536),
("bin/app".to_string(), 4096),
]
);
assert_eq!(parse_profile("lib/libfoo.so\n").unwrap_err().0, 1);
assert_eq!(parse_profile("bin/app\t0\nlib/x\tten\n").unwrap_err().0, 2);
}
#[test]
fn the_prefetch_list_follows_the_profile_through_this_tree() {
let files = vec![sized("bin/app", 100_000), sized("lib/libfoo.so", 300_000)];
let profile: Vec<(String, u64)> = [
("lib/libfoo.so", 200_000),
("bin/app", 0),
("./lib/libfoo.so", 0),
("lib/libfoo.so", 70_000),
("lib/libgone.so", 0),
("bin/app", 100_000),
("lib/libfoo.so", 1),
]
.iter()
.map(|(p, o)| (p.to_string(), *o))
.collect();
assert_eq!(
prefetch_lines(&files, &profile, 64 * 1024),
[
"lib/libfoo.so\t3",
"bin/app\t0",
"lib/libfoo.so\t0",
"lib/libfoo.so\t1"
]
);
assert_eq!(
prefetch_lines(&files, &profile, 256 * 1024),
["lib/libfoo.so\t0", "bin/app\t0"]
);
}
}