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,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub preload: Vec<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 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>,
}
#[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,
#[serde(default)]
pub dict: bool,
#[serde(default)]
pub store: bool,
}
impl Default for Compression {
fn default() -> Self {
Self {
level: default_level(),
dict: false,
store: false,
}
}
}
fn default_level() -> i32 {
12
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Update {
pub url: String,
pub key: Option<PathBuf>,
}
#[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 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'{' {
if 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 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}"
);
}
}