#![allow(dead_code)]
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Config {
pub package: Package,
pub msi: Option<MsiSection>,
pub app: Option<AppSection>,
pub pkg: Option<PkgSection>,
pub nupkg: Option<NupkgSection>,
pub setup: Option<SetupSection>,
pub sign: Option<SignSection>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Package {
pub name: String,
pub display_name: String,
pub version: String,
pub identifier: String,
pub publisher: String,
pub description: String,
pub homepage: Option<String>,
pub license: Option<String>,
pub copyright: Option<String>,
pub tags: Option<Vec<String>>,
pub icon: Option<PathBuf>,
#[serde(default)]
pub require_license_acceptance: bool,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileEntry {
pub src: PathBuf,
pub dest: String,
#[serde(default)]
pub optional: bool,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PkgFileEntry {
pub src: PathBuf,
pub dest: String,
#[serde(default, deserialize_with = "permission_bits")]
pub mode: Option<u32>,
#[serde(default)]
pub optional: bool,
}
trait Payload {
fn src(&self) -> &Path;
fn dest(&self) -> &str;
fn optional(&self) -> bool;
}
impl Payload for FileEntry {
fn src(&self) -> &Path {
&self.src
}
fn dest(&self) -> &str {
&self.dest
}
fn optional(&self) -> bool {
self.optional
}
}
impl Payload for PkgFileEntry {
fn src(&self) -> &Path {
&self.src
}
fn dest(&self) -> &str {
&self.dest
}
fn optional(&self) -> bool {
self.optional
}
}
struct Dropped {
dest: String,
src: PathBuf,
}
fn drop_absent<T: Payload>(section: &str, files: &mut Vec<T>, base: &Path) -> Result<Vec<Dropped>> {
let mut dropped = Vec::new();
let mut kept = Vec::with_capacity(files.len());
for file in files.drain(..) {
let src = base.join(file.src());
if file.optional() && optional_src_absent(&src)? {
eprintln!("{section}: skipped optional {} (not found)", src.display());
dropped.push(Dropped {
dest: file.dest().to_string(),
src,
});
} else {
kept.push(file);
}
}
*files = kept;
Ok(dropped)
}
fn optional_src_absent(src: &Path) -> Result<bool> {
use std::io::ErrorKind::NotFound;
let src = trim_trailing(src);
match std::fs::symlink_metadata(src) {
Ok(_) => {
let meta = std::fs::metadata(src).map_err(|e| match e.kind() {
NotFound => anyhow::anyhow!("optional src {} is a dangling symlink", src.display()),
_ => {
anyhow::Error::new(e).context(format!("reading optional src {}", src.display()))
}
})?;
if !meta.is_file() {
bail!("optional src {} is not a regular file", src.display());
}
Ok(false)
}
Err(e) if e.kind() == NotFound => {
let nearest = src
.ancestors()
.skip(1)
.find(|a| std::fs::symlink_metadata(a).is_ok());
if let Some(link) = nearest {
if link.is_symlink() && std::fs::metadata(link).is_err() {
bail!(
"optional src {}: ancestor {} is a dangling symlink",
src.display(),
link.display()
);
}
}
Ok(true)
}
Err(e) => Err(e).with_context(|| format!("reading optional src {}", src.display())),
}
}
fn trim_trailing(path: &Path) -> &Path {
use std::path::Component;
let bytes = path.as_os_str().as_encoded_bytes();
let (mut floor, verbatim) = match path.components().next() {
Some(Component::Prefix(p)) => (p.as_os_str().len(), p.kind().is_verbatim()),
_ => (0, false),
};
let is_sep = |b: u8| (b == b'\\' && cfg!(windows)) || (b == b'/' && !verbatim);
if bytes.get(floor).is_some_and(|&b| is_sep(b)) {
floor += 1;
}
let mut end = bytes.len();
while end > floor {
let last = bytes[end - 1];
let dot_component = last == b'.' && !verbatim && end >= 2 && is_sep(bytes[end - 2]);
if !is_sep(last) && !dot_component {
break;
}
end -= 1;
}
Path::new(unsafe { std::ffi::OsStr::from_encoded_bytes_unchecked(&bytes[..end]) })
}
fn ensure_not_dropped<'a>(
section: &str,
dropped: &[Dropped],
kept: &[&str],
refs: impl IntoIterator<Item = (String, &'a str)>,
) -> Result<()> {
for (field, dest) in refs {
if kept.contains(&dest) {
continue;
}
if let Some(d) = dropped.iter().find(|d| d.dest == dest) {
bail!(
"{section}: {field} {dest:?} is an optional file that was absent ({} not found)",
d.src.display()
);
}
}
Ok(())
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PkgDirEntry {
pub dest: String,
#[serde(default, deserialize_with = "permission_bits")]
pub mode: Option<u32>,
}
fn permission_bits<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Option<u32>, D::Error> {
let mode = u32::deserialize(deserializer)?;
if mode > 0o777 {
return Err(serde::de::Error::custom(format!(
"mode {mode:#o} must be permission bits only (0o000-0o777)"
)));
}
Ok(Some(mode))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
pub enum MsiArch {
#[serde(rename = "x86_64")]
X86_64,
#[serde(rename = "aarch64")]
Aarch64,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct MsiSection {
pub arch: MsiArch,
pub files: Vec<FileEntry>,
pub main_executable: String,
pub service: Option<MsiService>,
#[serde(default)]
pub add_to_path: bool,
}
impl MsiSection {
pub fn drop_absent_optional(&mut self, base: &Path) -> Result<()> {
let dropped = drop_absent("msi", &mut self.files, base)?;
let kept: Vec<&str> = self.files.iter().map(|f| f.dest.as_str()).collect();
let service = self.service.as_ref().and_then(|s| s.executable.as_deref());
ensure_not_dropped(
"msi",
&dropped,
&kept,
[("main-executable".to_string(), self.main_executable.as_str())]
.into_iter()
.chain(service.map(|e| ("service executable".to_string(), e))),
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ServiceStart {
Auto,
Demand,
Disabled,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct MsiService {
pub name: String,
pub display_name: Option<String>,
pub description: Option<String>,
pub start: ServiceStart,
pub arguments: Option<String>,
pub executable: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct AppSection {
pub executable: PathBuf,
pub icon: Option<PathBuf>,
pub minimum_system_version: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct PkgSection {
pub files: Vec<PkgFileEntry>,
#[serde(default)]
pub directories: Vec<PkgDirEntry>,
pub install_location: String,
#[serde(default)]
pub enable_user_home: bool,
pub scripts: Option<PathBuf>,
}
impl PkgSection {
pub fn drop_absent_optional(&mut self, base: &Path) -> Result<()> {
drop_absent("pkg", &mut self.files, base).map(drop)
}
pub fn spec(&self, package: &Package, base: &Path) -> embala_pkg::PkgSpec {
embala_pkg::PkgSpec {
name: package.name.clone(),
display_name: package.display_name.clone(),
identifier: package.identifier.clone(),
version: package.version.clone(),
install_location: self.install_location.clone(),
enable_user_home: self.enable_user_home,
files: self
.files
.iter()
.map(|f| embala_pkg::FileSpec {
src: base.join(&f.src),
dest: f.dest.clone(),
mode: f.mode,
})
.collect(),
directories: self
.directories
.iter()
.map(|d| embala_pkg::DirSpec {
dest: d.dest.clone(),
mode: d.mode,
})
.collect(),
scripts: self.scripts.as_ref().map(|s| base.join(s)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum NupkgStyle {
Embedded,
Download,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct NupkgSection {
pub style: NupkgStyle,
#[serde(default)]
pub files: Vec<FileEntry>,
pub url: Option<String>,
pub checksum: Option<String>,
pub project_source_url: Option<String>,
pub package_source_url: Option<String>,
pub release_notes: Option<String>,
pub icon_url: Option<String>,
pub license_url: Option<String>,
}
impl NupkgSection {
pub fn drop_absent_optional(&mut self, base: &Path) -> Result<()> {
drop_absent("nupkg", &mut self.files, base).map(drop)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, clap::ValueEnum)]
pub enum SetupArch {
#[serde(rename = "x86_64")]
#[value(name = "x86_64")]
X86_64,
#[serde(rename = "aarch64")]
#[value(name = "aarch64")]
Aarch64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum InstallMode {
#[default]
PerUser,
PerMachine,
UserChoice,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ShortcutLocation {
#[default]
StartMenu,
Desktop,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Component {
pub id: String,
pub label: String,
pub description: Option<String>,
#[serde(default)]
pub default: bool,
#[serde(default)]
pub files: Vec<FileEntry>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct UninstallOptionEntry {
pub id: String,
pub label: String,
#[serde(default)]
pub default: bool,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Shortcut {
pub name: String,
pub target: String,
pub args: Option<String>,
pub icon: Option<String>,
#[serde(default)]
pub location: ShortcutLocation,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct SetupSection {
pub arch: SetupArch,
pub files: Vec<FileEntry>,
pub main_executable: Option<String>,
pub icon: Option<PathBuf>,
pub banner: Option<PathBuf>,
#[serde(default)]
pub install_mode: InstallMode,
pub license: Option<PathBuf>,
#[serde(default)]
pub components: Vec<Component>,
#[serde(default)]
pub shortcuts: Vec<Shortcut>,
#[serde(default)]
pub uninstall_options: Vec<UninstallOptionEntry>,
pub script: Option<PathBuf>,
pub uninstall_script: Option<PathBuf>,
}
impl SetupSection {
fn installed_dests(&self) -> Vec<&str> {
self.files
.iter()
.chain(self.components.iter().flat_map(|c| c.files.iter()))
.map(|f| f.dest.as_str())
.collect()
}
pub fn drop_absent_optional(&mut self, base: &Path) -> Result<()> {
let mut dropped = drop_absent("setup", &mut self.files, base)?;
for component in &mut self.components {
dropped.extend(drop_absent("setup", &mut component.files, base)?);
}
let main = self
.main_executable
.as_deref()
.map(|m| ("main-executable".to_string(), m));
let targets = self
.shortcuts
.iter()
.map(|s| (format!("shortcut {:?} target", s.name), s.target.as_str()));
ensure_not_dropped(
"setup",
&dropped,
&self.installed_dests(),
main.into_iter().chain(targets),
)
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct SignSection {
pub windows: Option<WindowsSign>,
pub macos: Option<MacosSign>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct WindowsSign {
pub command: String,
pub signed_stub: Option<PathBuf>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct MacosSign {
pub command: String,
}
impl Config {
pub fn load(path: &Path, version: Option<&str>) -> Result<Self> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading config {}", path.display()))?;
let mut config: Config =
toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
if let Some(version) = version {
config.package.version = version.to_string();
}
config.validate()?;
Ok(config)
}
fn validate(&self) -> Result<()> {
let p = &self.package;
if p.name.is_empty()
|| !p
.name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
bail!("package.name must match [a-z0-9-]+ (got {:?})", p.name);
}
let version_parts: Vec<&str> = p.version.split('.').collect();
if version_parts.len() != 3
|| version_parts
.iter()
.any(|s| s.is_empty() || !s.chars().all(|c| c.is_ascii_digit()))
{
bail!(
"package.version must be numeric x.y.z — MSI requires it (got {:?})",
p.version
);
}
let id_segments: Vec<&str> = p.identifier.split('.').collect();
if id_segments.len() < 2
|| id_segments
.iter()
.any(|s| s.is_empty() || !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
{
bail!(
"package.identifier must be reverse-DNS-like, e.g. com.example.app (got {:?})",
p.identifier
);
}
for tag in p.tags.iter().flatten() {
if tag.is_empty() || tag.chars().any(char::is_whitespace) {
bail!(
"package.tags entries must be non-empty and contain no whitespace (got {:?})",
tag
);
}
}
if let Some(nupkg) = &self.nupkg {
match nupkg.style {
NupkgStyle::Download if nupkg.url.is_none() || nupkg.checksum.is_none() => {
bail!("nupkg: style \"download\" requires both url and checksum");
}
NupkgStyle::Embedded if nupkg.url.is_some() || nupkg.checksum.is_some() => {
bail!("nupkg: url/checksum are only valid with style \"download\"");
}
_ => {}
}
if nupkg.style == NupkgStyle::Download && nupkg.files.iter().any(|f| f.optional) {
bail!("nupkg: optional has no effect with style \"download\" (src is never read)");
}
if p.require_license_acceptance && nupkg.license_url.is_none() {
bail!(
"package.require-license-acceptance = true requires nupkg.license-url \
(Chocolatey rejects the package otherwise)"
);
}
}
if let Some(setup) = &self.setup {
validate_setup(setup)?;
}
if let Some(pkg) = &self.pkg {
embala_pkg::validate(&pkg.spec(p, Path::new("")))?;
}
if let Some(sign) = &self.sign {
for (platform, command) in [
("windows", sign.windows.as_ref().map(|w| &w.command)),
("macos", sign.macos.as_ref().map(|m| &m.command)),
] {
if let Some(command) = command {
if command.is_empty() || !command.contains("$f") {
bail!(
"sign.{platform}: command must be non-empty and contain $f (got {:?})",
command
);
}
}
}
}
Ok(())
}
}
fn validate_setup(setup: &SetupSection) -> Result<()> {
if setup.script.is_some()
&& (!setup.components.is_empty()
|| !setup.shortcuts.is_empty()
|| setup.main_executable.is_some())
{
bail!(
"setup: `script` cannot be combined with `components`, `shortcuts`, or \
`main-executable` — the raw script owns that behavior"
);
}
let mut ids = std::collections::BTreeSet::new();
for component in &setup.components {
if !ids.insert(component.id.as_str()) {
bail!("setup: duplicate component id {:?}", component.id);
}
}
let mut opt_ids = std::collections::BTreeSet::new();
for option in &setup.uninstall_options {
if option.id.is_empty() {
bail!("setup: uninstall-option id must be non-empty");
}
if option.label.is_empty() {
bail!(
"setup: uninstall-option {:?} label must be non-empty",
option.id
);
}
if !opt_ids.insert(option.id.as_str()) {
bail!("setup: duplicate uninstall-option id {:?}", option.id);
}
}
let dests = setup.installed_dests();
if let Some(main) = &setup.main_executable {
if !dests.contains(&main.as_str()) {
bail!(
"setup: main-executable {:?} is not among the installed file dests",
main
);
}
}
for shortcut in &setup.shortcuts {
if !dests.contains(&shortcut.target.as_str()) {
bail!(
"setup: shortcut {:?} target {:?} is not among the installed file dests",
shortcut.name,
shortcut.target
);
}
}
Ok(())
}
pub fn validate_setup_paths(setup: &SetupSection, base: &Path) -> Result<()> {
let srcs = setup
.files
.iter()
.chain(setup.components.iter().flat_map(|c| c.files.iter()))
.map(|f| &f.src);
for src in srcs {
let resolved = base.join(src);
if !resolved.exists() {
bail!("setup: payload src {} does not exist", resolved.display());
}
}
for (field, path) in [
("license", &setup.license),
("script", &setup.script),
("uninstall-script", &setup.uninstall_script),
("icon", &setup.icon),
("banner", &setup.banner),
] {
if let Some(path) = path {
let resolved = base.join(path);
if !resolved.exists() {
bail!("setup: {field} path {} does not exist", resolved.display());
}
}
}
if let Some(banner) = &setup.banner {
let resolved = base.join(banner);
let head = std::fs::read(&resolved)?;
if !head.starts_with(b"BM") {
bail!(
"setup: banner {} must be a BMP (magic \"BM\")",
resolved.display()
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_toml() -> String {
std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../fixtures/hello/embala.toml"
))
.expect("fixture config")
}
fn parse(text: &str) -> Result<Config> {
let config: Config = toml::from_str(text)?;
config.validate()?;
Ok(config)
}
#[test]
fn fixture_config_parses_with_all_sections() {
let config = parse(&fixture_toml()).expect("fixture must be valid");
assert_eq!(config.package.name, "hello");
assert_eq!(config.package.identifier, "ca.hrndz.embala.hello");
assert!(config.msi.is_some());
assert!(config.app.is_some());
assert!(config.pkg.is_some());
assert!(config.nupkg.is_some());
assert!(config.setup.is_some());
assert_eq!(
config.package.copyright.as_deref(),
Some("© 2026 Carlos Hernandez")
);
assert_eq!(
config.package.tags.as_deref(),
Some(&["cli".to_string(), "embala".to_string(), "demo".to_string()][..])
);
assert_eq!(config.package.icon.as_deref(), Some(Path::new("icon.png")));
assert!(!config.package.require_license_acceptance);
assert!(config.app.as_ref().unwrap().icon.is_none());
let nupkg = config.nupkg.as_ref().unwrap();
assert_eq!(
nupkg.license_url.as_deref(),
Some("https://github.com/hurricanehrndz/embala/blob/main/LICENSE")
);
assert_eq!(
nupkg.icon_url.as_deref(),
Some("https://github.com/hurricanehrndz/embala/raw/main/fixtures/hello/icon.png")
);
let msi = config.msi.unwrap();
assert_eq!(msi.arch, MsiArch::X86_64);
let service = msi.service.unwrap();
assert_eq!(service.name, "embala-hello");
assert_eq!(service.start, ServiceStart::Disabled);
assert!(msi.add_to_path);
assert_eq!(
config.pkg.unwrap().scripts.as_deref(),
Some(Path::new("scripts"))
);
let setup = config.setup.unwrap();
assert_eq!(setup.arch, SetupArch::X86_64);
assert_eq!(setup.install_mode, InstallMode::PerUser);
assert_eq!(setup.main_executable.as_deref(), Some("hello.exe"));
assert_eq!(setup.license.as_deref(), Some(Path::new("license.txt")));
assert!(setup.script.is_none());
assert_eq!(setup.components.len(), 1);
assert_eq!(setup.components[0].id, "docs");
assert!(setup.components[0].default);
assert_eq!(setup.components[0].files[0].dest, "readme.txt");
}
fn fixture_toml_without_metadata() -> String {
const STRIPPED: &[&str] = &[
"copyright =",
"tags =",
"icon =",
"project-source-url =",
"package-source-url =",
"release-notes =",
"icon-url =",
"license-url =",
];
fixture_toml()
.lines()
.filter(|line| {
let key = line.trim_start();
!STRIPPED.iter().any(|prefix| key.starts_with(prefix))
})
.collect::<Vec<_>>()
.join("\n")
}
fn with_metadata(package_keys: &str, nupkg_keys: &str) -> String {
fixture_toml_without_metadata()
.replace(
"license = \"MIT\"",
&format!("license = \"MIT\"\n{package_keys}"),
)
.replace(
"style = \"embedded\"",
&format!("style = \"embedded\"\n{nupkg_keys}"),
)
}
#[test]
fn new_metadata_keys_parse() {
let config = parse(&with_metadata(
"copyright = \"© 2026 Carlos Hernandez\"\n\
tags = [\"cli\", \"demo\"]\n\
icon = \"icon.png\"\n\
require-license-acceptance = true",
"project-source-url = \"https://github.com/hurricanehrndz/embala\"\n\
package-source-url = \"https://github.com/hurricanehrndz/embala-packages\"\n\
release-notes = \"First release.\"\n\
icon-url = \"https://example.com/icon.png\"\n\
license-url = \"https://example.com/LICENSE\"",
))
.expect("new metadata keys must parse");
let p = &config.package;
assert_eq!(p.copyright.as_deref(), Some("© 2026 Carlos Hernandez"));
assert_eq!(
p.tags.as_deref(),
Some(&["cli".to_string(), "demo".to_string()][..])
);
assert_eq!(p.icon.as_deref(), Some(Path::new("icon.png")));
assert!(p.require_license_acceptance);
let n = config.nupkg.unwrap();
assert_eq!(
n.project_source_url.as_deref(),
Some("https://github.com/hurricanehrndz/embala")
);
assert_eq!(
n.package_source_url.as_deref(),
Some("https://github.com/hurricanehrndz/embala-packages")
);
assert_eq!(n.release_notes.as_deref(), Some("First release."));
assert_eq!(n.icon_url.as_deref(), Some("https://example.com/icon.png"));
assert_eq!(
n.license_url.as_deref(),
Some("https://example.com/LICENSE")
);
}
#[test]
fn require_license_acceptance_needs_a_license_url() {
let err = parse(&with_metadata("require-license-acceptance = true", ""))
.unwrap_err()
.to_string();
assert!(err.contains("requires nupkg.license-url"), "{err}");
parse(&with_metadata(
"require-license-acceptance = true",
"license-url = \"https://example.com/LICENSE\"",
))
.expect("license-url satisfies the coupling rule");
}
#[test]
fn whitespace_in_a_tag_is_rejected() {
let err = parse(&with_metadata("tags = [\"cli tool\"]", ""))
.unwrap_err()
.to_string();
assert!(err.contains("no whitespace"), "{err}");
}
#[test]
fn non_numeric_version_is_rejected() {
let text = fixture_toml().replace("version = \"0.1.0\"", "version = \"0.1.0-beta\"");
let err = parse(&text).unwrap_err().to_string();
assert!(err.contains("numeric x.y.z"), "unexpected error: {err}");
}
#[test]
fn unknown_key_is_rejected() {
let text = format!("{}\n[msi2]\narch = \"x86_64\"\n", fixture_toml());
assert!(parse(&text).is_err());
}
#[test]
fn thirty_two_bit_arch_is_rejected() {
let text = fixture_toml().replace("arch = \"x86_64\"", "arch = \"x86\"");
assert!(parse(&text).is_err());
}
#[test]
fn unknown_service_start_is_rejected() {
let text = fixture_toml().replace("start = \"disabled\"", "start = \"boot\"");
assert!(parse(&text).is_err());
let text = fixture_toml().replace("start = \"disabled\"", "start = \"auto\"");
let service = parse(&text).unwrap().msi.unwrap().service.unwrap();
assert_eq!(service.start, ServiceStart::Auto);
}
#[test]
fn download_style_requires_url_and_checksum() {
let text = fixture_toml().replace("style = \"embedded\"", "style = \"download\"");
let err = parse(&text).unwrap_err().to_string();
assert!(
err.contains("requires both url and checksum"),
"unexpected error: {err}"
);
}
const SETUP_FULL: &str = r#"
[setup]
arch = "x86_64"
main-executable = "hello.exe"
install-mode = "user-choice"
files = [{ src = "dist/hello.exe", dest = "hello.exe" }]
components = [{ id = "core", label = "Core", default = true, files = [{ src = "dist/hello.exe", dest = "extra.exe" }] }]
shortcuts = [{ name = "Hello", target = "hello.exe", location = "desktop" }]
"#;
fn with_setup(block: &str) -> String {
let base = fixture_toml();
let cut = base.find("\n[setup]").unwrap_or(base.len());
format!("{}{}", &base[..cut], block)
}
#[test]
fn full_setup_section_parses() {
let config = parse(&with_setup(SETUP_FULL)).expect("full setup section must be valid");
let setup = config.setup.unwrap();
assert_eq!(setup.install_mode, InstallMode::UserChoice);
assert_eq!(setup.components.len(), 1);
assert_eq!(setup.shortcuts[0].location, ShortcutLocation::Desktop);
}
#[test]
fn unknown_setup_arch_is_rejected() {
let text = with_setup(SETUP_FULL).replace("arch = \"x86_64\"", "arch = \"x86\"");
assert!(parse(&text).is_err());
}
#[test]
fn main_executable_not_in_dests_is_rejected() {
let text = with_setup(SETUP_FULL).replace(
"main-executable = \"hello.exe\"",
"main-executable = \"nope.exe\"",
);
let err = parse(&text).unwrap_err().to_string();
assert!(err.contains("not among the installed file dests"), "{err}");
}
#[test]
fn duplicate_component_ids_are_rejected() {
let block = r#"
[setup]
arch = "x86_64"
files = [{ src = "dist/hello.exe", dest = "hello.exe" }]
components = [
{ id = "core", label = "A" },
{ id = "core", label = "B" },
]
"#;
let err = parse(&with_setup(block)).unwrap_err().to_string();
assert!(err.contains("duplicate component id"), "{err}");
}
#[test]
fn uninstall_options_parse() {
let block = r#"
[setup]
arch = "x86_64"
main-executable = "hello.exe"
files = [{ src = "dist/hello.exe", dest = "hello.exe" }]
uninstall-options = [
{ id = "purge-data", label = "Delete all data", default = false },
{ id = "keep-logs", label = "Keep logs" },
]
"#;
let config = parse(&with_setup(block)).expect("uninstall-options must parse");
let setup = config.setup.unwrap();
assert_eq!(setup.uninstall_options.len(), 2);
assert_eq!(setup.uninstall_options[0].id, "purge-data");
assert!(!setup.uninstall_options[0].default);
assert_eq!(setup.uninstall_options[1].label, "Keep logs");
}
#[test]
fn duplicate_uninstall_option_ids_are_rejected() {
let block = r#"
[setup]
arch = "x86_64"
files = [{ src = "dist/hello.exe", dest = "hello.exe" }]
uninstall-options = [
{ id = "purge-data", label = "A" },
{ id = "purge-data", label = "B" },
]
"#;
let err = parse(&with_setup(block)).unwrap_err().to_string();
assert!(err.contains("duplicate uninstall-option id"), "{err}");
}
#[test]
fn empty_uninstall_option_id_or_label_is_rejected() {
let empty_id = r#"
[setup]
arch = "x86_64"
files = [{ src = "dist/hello.exe", dest = "hello.exe" }]
uninstall-options = [{ id = "", label = "A" }]
"#;
assert!(
parse(&with_setup(empty_id))
.unwrap_err()
.to_string()
.contains("id must be non-empty")
);
let empty_label = r#"
[setup]
arch = "x86_64"
files = [{ src = "dist/hello.exe", dest = "hello.exe" }]
uninstall-options = [{ id = "purge-data", label = "" }]
"#;
assert!(
parse(&with_setup(empty_label))
.unwrap_err()
.to_string()
.contains("label must be non-empty")
);
}
#[test]
fn banner_without_bm_magic_is_rejected() {
let dir = std::env::temp_dir().join("embala-banner-magic-test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("dist")).unwrap();
std::fs::write(dir.join("dist/hello.exe"), b"MZ").unwrap();
std::fs::write(dir.join("bad.bmp"), b"NOTBMP").unwrap();
std::fs::write(dir.join("good.bmp"), b"BM and pixels").unwrap();
let block = r#"
[setup]
arch = "x86_64"
banner = "bad.bmp"
files = [{ src = "dist/hello.exe", dest = "hello.exe" }]
"#;
let config: Config = toml::from_str(&with_setup(block)).unwrap();
let err = validate_setup_paths(config.setup.as_ref().unwrap(), &dir)
.unwrap_err()
.to_string();
assert!(err.contains("must be a BMP"), "{err}");
let good = with_setup(block).replace("bad.bmp", "good.bmp");
let config: Config = toml::from_str(&good).unwrap();
validate_setup_paths(config.setup.as_ref().unwrap(), &dir).expect("BM magic accepted");
}
#[test]
fn script_with_sugar_is_rejected() {
let block = r#"
[setup]
arch = "x86_64"
script = "install.lua"
files = [{ src = "dist/hello.exe", dest = "hello.exe" }]
shortcuts = [{ name = "Hello", target = "hello.exe" }]
"#;
let err = parse(&with_setup(block)).unwrap_err().to_string();
assert!(err.contains("cannot be combined with"), "{err}");
}
fn with_pkg(files: &str, extra: &str) -> String {
fixture_toml().replace(
"files = [{ src = \"hello.sh\", dest = \"bin/embala-hello\" }]",
&format!("files = {files}\n{extra}"),
)
}
#[test]
fn pkg_modes_and_directories_parse() {
let text = with_pkg(
r#"[{ src = "hello.sh", dest = "etc/credentials", mode = 0o600 }, { src = "hello.sh", dest = "bin/embala-hello" }]"#,
r#"directories = [{ dest = "keys", mode = 0o700 }, { dest = "empty" }]"#,
);
let pkg = parse(&text).expect("pkg modes must parse").pkg.unwrap();
assert_eq!(pkg.files[0].mode, Some(0o600));
assert_eq!(
pkg.files[1].mode, None,
"absent mode keeps the exec-bit rule"
);
assert_eq!(pkg.directories[0].dest, "keys");
assert_eq!(pkg.directories[0].mode, Some(0o700));
assert_eq!(pkg.directories[1].mode, None);
}
#[test]
fn pkg_mode_beyond_permission_bits_is_rejected() {
for mode in ["0o4755", "0o2755", "0o1777"] {
let file = with_pkg(
&format!(r#"[{{ src = "hello.sh", dest = "bin/embala-hello", mode = {mode} }}]"#),
"",
);
let err = format!("{:#}", parse(&file).unwrap_err());
assert!(err.contains("permission bits only"), "{mode}: {err}");
let dir = with_pkg(
r#"[{ src = "hello.sh", dest = "bin/embala-hello" }]"#,
&format!(r#"directories = [{{ dest = "keys", mode = {mode} }}]"#),
);
assert!(parse(&dir).is_err(), "directory {mode}");
}
}
#[test]
fn mode_outside_pkg_fails_to_parse() {
let msi = fixture_toml().replace(
"files = [{ src = \"dist/hello.exe\", dest = \"hello.exe\" }]\n# Registers",
"files = [{ src = \"dist/hello.exe\", dest = \"hello.exe\", mode = 0o600 }]\n# Registers",
);
assert_ne!(msi, fixture_toml(), "replacement must hit [msi] files");
let err = format!("{:#}", parse(&msi).unwrap_err());
assert!(err.contains("unknown field `mode`"), "{err}");
}
#[test]
fn pkg_directory_conflicts_are_rejected() {
let files = r#"[{ src = "hello.sh", dest = "bin/embala-hello" }]"#;
for (directories, needle) in [
(
r#"[{ dest = "keys" }, { dest = "keys" }]"#,
"share the dest",
),
(r#"[{ dest = "bin/embala-hello" }]"#, "share the dest"),
(
r#"[{ dest = "bin/embala-hello/keys" }]"#,
"both a file and a directory",
),
(r#"[{ dest = "../keys" }]"#, "relative"),
] {
let text = with_pkg(files, &format!("directories = {directories}"));
let err = format!("{:#}", parse(&text).unwrap_err());
assert!(err.contains(needle), "{directories}: {err}");
}
}
fn optional_dir(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("embala-optional-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("dist")).unwrap();
std::fs::write(dir.join("dist/hello.exe"), b"MZ").unwrap();
dir
}
const ABSENT: &str = r#"{ src = "absent.exe", dest = "agent.exe", optional = true }"#;
#[test]
fn optional_parses_on_every_file_entry_and_defaults_off() {
let text = fixture_toml()
.replace(
r#"files = [{ src = "hello.sh", dest = "bin/embala-hello" }]"#,
r#"files = [{ src = "hello.sh", dest = "bin/embala-hello", mode = 0o600, optional = true }]"#,
)
.replace(
r#"{ src = "readme.txt", dest = "readme.txt" }"#,
r#"{ src = "readme.txt", dest = "readme.txt", optional = true }"#,
);
let config = parse(&text).expect("optional must parse");
let pkg = &config.pkg.as_ref().unwrap().files[0];
assert!(pkg.optional);
assert_eq!(pkg.mode, Some(0o600));
let setup = config.setup.as_ref().unwrap();
assert!(setup.components[0].files[0].optional);
assert!(!setup.files[0].optional, "optional defaults to false");
}
#[test]
fn optional_with_download_style_is_rejected() {
let text = fixture_toml()
.replace(
"style = \"embedded\"",
"style = \"download\"\nurl = \"https://e.x/a.exe\"\nchecksum = \"00\"",
)
.replace(
r#"files = [{ src = "dist/hello.exe", dest = "hello.exe" }]
[setup]"#,
r#"files = [{ src = "dist/hello.exe", dest = "hello.exe", optional = true }]
[setup]"#,
);
let err = parse(&text).unwrap_err().to_string();
assert!(err.contains("no effect with style \"download\""), "{err}");
}
#[test]
fn absent_optional_files_are_dropped_and_others_kept() {
let dir = optional_dir("drop");
let mut setup: SetupSection = toml::from_str(&format!(
r#"arch = "x86_64"
main-executable = "hello.exe"
files = [{{ src = "dist/hello.exe", dest = "hello.exe", optional = true }}, {ABSENT}]
components = [{{ id = "extra", label = "Extra", files = [{ABSENT}] }}]"#
))
.unwrap();
setup.drop_absent_optional(&dir).unwrap();
assert_eq!(setup.installed_dests(), ["hello.exe"]);
assert!(setup.components[0].files.is_empty());
}
#[test]
fn references_to_a_dropped_optional_file_are_rejected() {
let dir = optional_dir("refs");
let hello = r#"{ src = "dist/hello.exe", dest = "hello.exe" }"#;
for (field, setup) in [
(
"main-executable",
format!(
"arch = \"x86_64\"\nmain-executable = \"agent.exe\"\nfiles = [{hello}, {ABSENT}]"
),
),
(
"shortcut \"Agent\" target",
format!(
"arch = \"x86_64\"\nfiles = [{hello}]\n\
components = [{{ id = \"a\", label = \"A\", files = [{ABSENT}] }}]\n\
shortcuts = [{{ name = \"Agent\", target = \"agent.exe\" }}]"
),
),
] {
let mut setup: SetupSection = toml::from_str(&setup).unwrap();
let err = setup.drop_absent_optional(&dir).unwrap_err().to_string();
assert!(
err.contains(&format!("setup: {field} \"agent.exe\"")),
"{err}"
);
assert!(err.contains("optional file that was absent"), "{err}");
}
for (field, msi) in [
("main-executable", "main-executable = \"agent.exe\""),
(
"service executable",
"main-executable = \"hello.exe\"\n\
service = { name = \"a\", start = \"auto\", executable = \"agent.exe\" }",
),
] {
let mut msi: MsiSection = toml::from_str(&format!(
"arch = \"x86_64\"\n{msi}\nfiles = [{hello}, {ABSENT}]"
))
.unwrap();
let err = msi.drop_absent_optional(&dir).unwrap_err().to_string();
assert!(
err.contains(&format!("msi: {field} \"agent.exe\"")),
"{err}"
);
}
}
#[test]
fn a_reference_another_entry_still_provides_is_kept() {
let dir = optional_dir("shared-dest");
let mut msi: MsiSection = toml::from_str(
r#"arch = "x86_64"
main-executable = "hello.exe"
files = [{ src = "absent.exe", dest = "hello.exe", optional = true }, { src = "dist/hello.exe", dest = "hello.exe" }]"#,
)
.unwrap();
msi.drop_absent_optional(&dir).unwrap();
assert_eq!(msi.files.len(), 1);
}
#[test]
fn only_not_found_counts_as_absent() {
let dir = optional_dir("probe");
assert!(optional_src_absent(&dir.join("missing")).unwrap());
assert!(optional_src_absent(&dir.join("no/such/dir/file")).unwrap());
assert!(!optional_src_absent(&dir.join("dist/hello.exe")).unwrap());
let err = optional_src_absent(&dir.join("dist")).unwrap_err();
assert!(err.to_string().contains("not a regular file"), "{err}");
}
#[cfg(unix)]
#[test]
fn dangling_symlink_is_an_error_not_a_skip() {
let dir = optional_dir("dangling");
std::os::unix::fs::symlink(dir.join("gone"), dir.join("link")).unwrap();
let err = optional_src_absent(&dir.join("link")).unwrap_err();
assert!(err.to_string().contains("dangling symlink"), "{err}");
let err = optional_src_absent(&dir.join("link/credentials")).unwrap_err();
assert!(err.to_string().contains("dangling symlink"), "{err}");
for spelling in ["link/", "link/."] {
let err = optional_src_absent(&dir.join(spelling)).unwrap_err();
assert!(err.to_string().contains("dangling symlink"), "{err}");
}
std::os::unix::fs::symlink(dir.join("dist/hello.exe"), dir.join("good")).unwrap();
assert!(!optional_src_absent(&dir.join("good")).unwrap());
}
#[test]
fn sign_section_parses() {
let text = format!(
"{}\n[sign.windows]\ncommand = \"osslsigncode sign -in $f -out $f\"\n\
signed-stub = \"signed-stub.exe\"\n[sign.macos]\ncommand = \"rcodesign sign $f\"\n",
fixture_toml()
);
let config = parse(&text).expect("sign section must parse");
let sign = config.sign.unwrap();
assert_eq!(
sign.windows.as_ref().unwrap().signed_stub.as_deref(),
Some(Path::new("signed-stub.exe"))
);
assert_eq!(sign.macos.unwrap().command, "rcodesign sign $f");
}
#[test]
fn sign_command_without_placeholder_is_rejected() {
let text = format!(
"{}\n[sign.windows]\ncommand = \"osslsigncode sign cert.pfx\"\n",
fixture_toml()
);
let err = parse(&text).unwrap_err().to_string();
assert!(err.contains("must be non-empty and contain $f"), "{err}");
}
fn trimmed(path: &str) -> &str {
trim_trailing(Path::new(path)).to_str().unwrap()
}
#[test]
fn trim_trailing_strips_only_trailing_separators_and_dots() {
assert_eq!(trimmed("dir/link/"), "dir/link");
assert_eq!(trimmed("dir/link/."), "dir/link");
assert_eq!(trimmed("dir/link//././/"), "dir/link");
assert_eq!(trimmed("dir/./link"), "dir/./link");
assert_eq!(trimmed("dir/link"), "dir/link");
assert_eq!(trimmed("stage/c:credentials/."), "stage/c:credentials");
assert_eq!(trimmed("dir/.."), "dir/..");
assert_eq!(trimmed("dir/link."), "dir/link.");
assert_eq!(trimmed("/"), "/");
assert_eq!(trimmed("/."), "/");
assert_eq!(trimmed("."), ".");
assert_eq!(trimmed("./"), ".");
}
#[cfg(not(windows))]
#[test]
fn trim_trailing_keeps_backslash_on_unix() {
assert_eq!(trimmed("dir/link\\"), "dir/link\\");
}
#[cfg(windows)]
#[test]
fn trim_trailing_windows_paths() {
assert_eq!(trimmed(r"stage\link\"), r"stage\link");
assert_eq!(trimmed(r"stage\link\."), r"stage\link");
assert_eq!(trimmed(r"stage\link/.\"), r"stage\link");
assert_eq!(
trimmed(r"C:\stage\c:credentials\"),
r"C:\stage\c:credentials"
);
assert_eq!(
trimmed(r"C:\stage\c:credentials\."),
r"C:\stage\c:credentials"
);
assert_eq!(trimmed(r"C:\"), r"C:\");
assert_eq!(trimmed(r"C:\."), r"C:\");
assert_eq!(trimmed("C:/"), "C:/");
assert_eq!(trimmed(r"C:link\"), r"C:link");
assert_eq!(trimmed(r"C:link\."), r"C:link");
assert_eq!(trimmed("C:"), "C:");
assert_eq!(trimmed("C:."), "C:.");
assert_eq!(
trimmed(r"\\server\share\dir\link\."),
r"\\server\share\dir\link"
);
assert_eq!(trimmed(r"\\server\share\"), r"\\server\share\");
assert_eq!(
trimmed(r"\\?\C:\stage\c:credentials\"),
r"\\?\C:\stage\c:credentials"
);
assert_eq!(trimmed(r"\\?\C:\"), r"\\?\C:\");
assert_eq!(trimmed(r"\\?\C:\dir\link/"), r"\\?\C:\dir\link/");
assert_eq!(trimmed(r"\\?\C:\dir\."), r"\\?\C:\dir\.");
assert_eq!(
trimmed(r"\\?\UNC\server\share\link\"),
r"\\?\UNC\server\share\link"
);
}
}