use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use onelf_format::WorkingDir;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Recipe {
pub package: Package,
#[serde(default)]
pub entrypoint: Vec<Entrypoint>,
#[serde(default)]
pub compression: Compression,
pub update: Option<Update>,
#[serde(default)]
pub bundle: Bundle,
pub sysroot: Option<Sysroot>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub preload: Vec<String>,
#[serde(default)]
pub paths: BTreeMap<String, String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Package {
pub name: Option<String>,
pub command: String,
pub output: Option<PathBuf>,
#[serde(default)]
pub working_dir: WorkingDirSpec,
pub host_libs: Option<HostLibsSpec>,
#[serde(default)]
pub cache: bool,
#[serde(default)]
pub prefer_cache: bool,
pub memfd: Option<bool>,
#[serde(default)]
pub exclude: Vec<String>,
pub mtime: Option<u64>,
pub version: Option<String>,
pub description: Option<String>,
pub license: Option<String>,
pub homepage: Option<String>,
#[serde(default)]
pub needs_setuid: bool,
}
#[derive(Debug, Default, Clone, Copy, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WorkingDirSpec {
#[default]
Inherit,
Package,
Command,
}
impl From<WorkingDirSpec> for WorkingDir {
fn from(s: WorkingDirSpec) -> Self {
match s {
WorkingDirSpec::Inherit => WorkingDir::Inherit,
WorkingDirSpec::Package => WorkingDir::PackageRoot,
WorkingDirSpec::Command => WorkingDir::EntrypointParent,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Entrypoint {
pub name: String,
pub path: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub default: bool,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Compression {
#[serde(default = "default_level")]
pub level: i32,
pub block_size: Option<u64>,
#[serde(default)]
pub payload: PayloadSpec,
#[serde(default)]
pub dict: bool,
#[serde(default)]
pub store: bool,
pub prefetch: Option<PathBuf>,
}
impl Default for Compression {
fn default() -> Self {
Self {
level: default_level(),
block_size: None,
payload: PayloadSpec::default(),
dict: false,
store: false,
prefetch: None,
}
}
}
fn default_level() -> i32 {
19
}
#[derive(Debug, Clone, Copy, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PayloadSpec {
#[default]
Blocks,
Stipple,
}
impl From<PayloadSpec> for crate::pack::Payload {
fn from(v: PayloadSpec) -> Self {
match v {
PayloadSpec::Blocks => crate::pack::Payload::Blocks,
PayloadSpec::Stipple => crate::pack::Payload::Stipple,
}
}
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HostLibsSpec {
Auto,
Always,
Never,
}
impl From<HostLibsSpec> for crate::pack::HostLibs {
fn from(v: HostLibsSpec) -> Self {
match v {
HostLibsSpec::Auto => crate::pack::HostLibs::Auto,
HostLibsSpec::Always => crate::pack::HostLibs::Always,
HostLibsSpec::Never => crate::pack::HostLibs::Never,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Sysroot {
pub path: PathBuf,
pub archive: Option<PathBuf>,
pub hash: Option<String>,
pub platform: Option<String>,
#[serde(default)]
pub optional: Vec<String>,
pub platform_line: Option<PathBuf>,
pub policy: Option<PathBuf>,
pub trace: Option<PathBuf>,
pub keep: Option<PathBuf>,
pub platform_url: Option<String>,
pub platform_hash: Option<String>,
#[serde(default)]
pub sets: BTreeMap<String, SharedSet>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct SharedSet {
pub packages: Vec<String>,
pub url: String,
pub blake3: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Update {
pub url: String,
pub key: Option<PathBuf>,
pub revision: Option<u64>,
#[serde(default)]
pub unsigned: bool,
pub embed: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Bundle {
pub lib_dirs: Option<Vec<String>>,
#[serde(default)]
pub search_paths: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub gl: bool,
#[serde(default)]
pub dri: bool,
#[serde(default)]
pub vulkan: bool,
#[serde(default)]
pub wayland: bool,
#[serde(default)]
pub gtk: bool,
#[serde(default)]
pub no_gl: bool,
#[serde(default)]
pub no_dri: bool,
#[serde(default)]
pub no_vulkan: bool,
#[serde(default)]
pub no_wayland: bool,
#[serde(default)]
pub no_gtk: bool,
#[serde(default)]
pub strip: bool,
#[serde(default)]
pub strict_libc: bool,
#[serde(default)]
pub scan_dlopen: bool,
#[serde(default)]
pub dlopen: Vec<String>,
#[serde(default)]
pub trace_run: bool,
#[serde(default)]
pub trace_seconds: Option<u64>,
#[serde(default)]
pub trace_args: Vec<String>,
#[serde(default)]
pub skip: bool,
}
pub fn load(path: &Path) -> std::io::Result<Recipe> {
let raw = std::fs::read_to_string(path).map_err(|e| match e.kind() {
std::io::ErrorKind::InvalidData => std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"{}: not a valid recipe (expected a TOML file)",
path.display()
),
),
_ => std::io::Error::new(e.kind(), format!("{}: {e}", path.display())),
})?;
let mut value: toml::Value = toml::from_str(&raw).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{}: {e}", path.display()),
)
})?;
expand_value(&mut value);
value.try_into().map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{}: {e}", path.display()),
)
})
}
fn expand_value(value: &mut toml::Value) {
match value {
toml::Value::String(s) => *s = expand_env(s),
toml::Value::Array(a) => a.iter_mut().for_each(expand_value),
toml::Value::Table(t) => t.iter_mut().for_each(|(_, v)| expand_value(v)),
_ => {}
}
}
pub fn resolve(spec: &Path) -> std::io::Result<PathBuf> {
if spec.is_dir() {
return Ok(spec.join("onelf.toml"));
}
if !spec.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("{}: no such file or directory", spec.display()),
));
}
if spec.extension().and_then(|s| s.to_str()) != Some("toml") {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"{}: expected a directory containing onelf.toml or a .toml file",
spec.display()
),
));
}
Ok(spec.to_path_buf())
}
pub fn expand_env(s: &str) -> String {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(s.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'$' {
out.push(b'$');
i += 2;
continue;
}
if bytes[i] == b'$'
&& i + 1 < bytes.len()
&& bytes[i + 1] == b'{'
&& let Some(end) = bytes[i + 2..].iter().position(|&b| b == b'}')
{
let name = std::str::from_utf8(&bytes[i + 2..i + 2 + end]).unwrap_or("");
match std::env::var(name) {
Ok(val) => out.extend_from_slice(val.as_bytes()),
Err(_) => out.extend_from_slice(&bytes[i..i + 3 + end]),
}
i += 3 + end;
continue;
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
}
#[cfg(test)]
mod package_tests {
use super::load;
fn load_str(body: &str) -> super::Recipe {
let dir = std::env::temp_dir().join(format!(
"onelf-recipe-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("onelf.toml");
std::fs::write(&path, body).unwrap();
let recipe = load(&path).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
recipe
}
#[test]
fn a_sysroot_section_parses_with_relative_paths() {
let recipe = load_str(
"[package]\ncommand = \"bin/app\"\n\n[sysroot]\npath = \"sysroot\"\narchive = \"root.tar.zst\"\noptional = [\"extra\"]\nplatform-line = \"platform.txt\"\npolicy = \"policy.txt\"\n",
);
let sr = recipe.sysroot.expect("section present");
assert_eq!(sr.path, std::path::PathBuf::from("sysroot"));
assert_eq!(
sr.archive.as_deref(),
Some(std::path::Path::new("root.tar.zst"))
);
assert_eq!(sr.optional, ["extra"]);
assert!(sr.trace.is_none());
assert!(sr.platform.is_none());
let recipe = load_str(
"[package]\ncommand = \"bin/app\"\n\n[sysroot]\npath = \"sysroot\"\nplatform = \"platform-1\"\n",
);
let sr = recipe.sysroot.unwrap();
assert_eq!(sr.platform.as_deref(), Some("platform-1"));
assert!(sr.platform_url.is_none() && sr.platform_hash.is_none());
let recipe = load_str(
"[package]\ncommand = \"bin/app\"\n\n[sysroot]\npath = \"sysroot\"\nplatform-url = \"https://e/gl.onelf\"\nplatform-hash = \"abc\"\n",
);
let sr = recipe.sysroot.unwrap();
assert_eq!(sr.platform_url.as_deref(), Some("https://e/gl.onelf"));
assert_eq!(sr.platform_hash.as_deref(), Some("abc"));
assert!(
load_str("[package]\ncommand = \"bin/app\"\n")
.sysroot
.is_none()
);
}
#[test]
fn cache_defaults_off_and_parses_on() {
let recipe = load_str("[package]\ncommand = \"bin/app\"\n");
assert!(!recipe.package.cache);
let recipe = load_str("[package]\ncommand = \"bin/app\"\ncache = true\n");
assert!(recipe.package.cache);
assert!(!recipe.package.prefer_cache);
let recipe = load_str("[package]\ncommand = \"bin/app\"\nprefer-cache = true\n");
assert!(recipe.package.prefer_cache);
}
}
#[cfg(test)]
mod expand_tests {
use super::expand_env;
#[test]
fn dollar_dollar_escapes_and_defers_to_runtime() {
assert_eq!(expand_env("a$$b"), "a$b");
assert_eq!(
expand_env("${ONELF_DIR}/bin:$${PATH}"),
"${ONELF_DIR}/bin:${PATH}"
);
assert_eq!(
expand_env("${ONELF_DIR}/bin:$${PATH:-/usr/bin:/bin}"),
"${ONELF_DIR}/bin:${PATH:-/usr/bin:/bin}"
);
}
#[test]
fn unset_vars_are_preserved_for_runtime() {
assert_eq!(expand_env("x${ONELF_DIR}y"), "x${ONELF_DIR}y");
assert_eq!(
expand_env("${ONELF_THIS_IS_NOT_SET_4f3a}"),
"${ONELF_THIS_IS_NOT_SET_4f3a}"
);
}
#[test]
fn expand_preserves_non_ascii() {
assert_eq!(
expand_env("café ☕ Ω ${ONELF_UNSET_9q}"),
"café ☕ Ω ${ONELF_UNSET_9q}"
);
}
}