#![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,
}
#[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>,
}
#[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,
}
#[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 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>,
}
#[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()
}
}
#[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 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}");
}
}
#[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}");
}
}