use std::collections::BTreeMap;
use std::io::{Cursor, Read as _};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use apple_bom::ParsedBom;
use apple_xar::reader::XarReader;
use embala_pkg::{Error, FileSpec, PkgSpec, build};
const HELLO: &[u8] = b"#!/bin/sh\necho hello\n";
const README: &[u8] = b"read me\n";
fn write_src(dir: &Path, name: &str, bytes: &[u8], mode: u32) -> PathBuf {
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
path
}
fn spec(src_dir: &Path) -> PkgSpec {
PkgSpec {
name: "hello".to_string(),
display_name: "Embala Hello".to_string(),
identifier: "ca.hrndz.embala.hello".to_string(),
version: "0.1.0".to_string(),
install_location: "/usr/local".to_string(),
enable_user_home: true,
files: vec![
FileSpec {
src: write_src(src_dir, "hello.sh", HELLO, 0o755),
dest: "bin/embala-hello".to_string(),
},
FileSpec {
src: write_src(src_dir, "README", README, 0o644),
dest: "share/doc/hello/README".to_string(),
},
],
}
}
fn build_archive(dir: &Path, name: &str) -> Vec<u8> {
let out = dir.join(name);
build(&spec(dir), &out).unwrap();
std::fs::read(out).unwrap()
}
fn element_attrs(xml: &[u8], tag: &str) -> Vec<BTreeMap<String, String>> {
use quick_xml::events::Event;
let mut reader = quick_xml::Reader::from_str(std::str::from_utf8(xml).unwrap());
let mut found = Vec::new();
loop {
match reader.read_event().unwrap() {
Event::Start(e) | Event::Empty(e) if e.name().as_ref() == tag.as_bytes() => {
found.push(
e.attributes()
.map(|a| {
let a = a.unwrap();
(
String::from_utf8(a.key.as_ref().to_vec()).unwrap(),
String::from_utf8(a.value.to_vec()).unwrap(),
)
})
.collect(),
);
}
Event::Eof => break,
_ => {}
}
}
found
}
struct CpioEntry {
name: String,
mode: u64,
nlink: u64,
inode: u64,
data: Vec<u8>,
}
fn parse_odc(archive: &[u8]) -> Vec<CpioEntry> {
let mut entries = Vec::new();
let mut rest = archive;
loop {
let header = &rest[..76];
assert_eq!(&header[..6], b"070707", "odc magic");
let field = |lo: usize, hi: usize| {
u64::from_str_radix(std::str::from_utf8(&header[lo..hi]).unwrap(), 8).unwrap()
};
assert_eq!(field(6, 12), 0, "dev");
assert_eq!(field(24, 30), 0, "uid");
assert_eq!(field(30, 36), 0, "gid");
assert_eq!(field(42, 48), 0, "rdev");
let (inode, mode, nlink) = (field(12, 18), field(18, 24), field(36, 42));
let (name_size, file_size) = (field(59, 65) as usize, field(65, 76) as usize);
let name = std::str::from_utf8(&rest[76..76 + name_size - 1])
.unwrap()
.to_string();
let data = rest[76 + name_size..76 + name_size + file_size].to_vec();
rest = &rest[76 + name_size + file_size..];
let trailer = name == "TRAILER!!!";
entries.push(CpioEntry {
name,
mode,
nlink,
inode,
data,
});
if trailer {
assert!(rest.iter().all(|b| *b == 0), "trailer padding is zeros");
break;
}
}
entries
}
#[test]
fn product_archive_has_productbuild_layout() {
let dir = tempfile::tempdir().unwrap();
let archive = build_archive(dir.path(), "hello-0.1.0.pkg");
let mut reader = XarReader::new(Cursor::new(archive)).unwrap();
assert!(reader.verify_table_of_contents_checksum().unwrap());
let files = reader.files().unwrap();
let names: Vec<(String, String)> = files
.iter()
.map(|(name, file)| (name.clone(), file.file_type.to_string()))
.collect();
assert_eq!(
names,
vec![
("hello.pkg".to_string(), "directory".to_string()),
("hello.pkg/Bom".to_string(), "file".to_string()),
("hello.pkg/Payload".to_string(), "file".to_string()),
("hello.pkg/PackageInfo".to_string(), "file".to_string()),
("Distribution".to_string(), "file".to_string()),
]
);
}
#[test]
fn package_info_matches_pkgbuild_shape() {
let dir = tempfile::tempdir().unwrap();
let archive = build_archive(dir.path(), "hello-0.1.0.pkg");
let mut reader = XarReader::new(Cursor::new(archive)).unwrap();
let xml = reader
.get_file_data_from_path("hello.pkg/PackageInfo")
.unwrap()
.unwrap();
let pkg_info = &element_attrs(&xml, "pkg-info")[0];
for (key, value) in [
("overwrite-permissions", "true"),
("relocatable", "false"),
("identifier", "ca.hrndz.embala.hello"),
("postinstall-action", "none"),
("version", "0.1.0"),
("format-version", "2"),
("install-location", "/usr/local"),
("auth", "root"),
] {
assert_eq!(pkg_info.get(key).map(String::as_str), Some(value), "{key}");
}
let payload = &element_attrs(&xml, "payload")[0];
assert_eq!(payload.get("numberOfFiles").unwrap(), "7");
assert_eq!(payload.get("installKBytes").unwrap(), "3");
for tag in [
"bundle-version",
"upgrade-bundle",
"update-bundle",
"atomic-update-bundle",
"strict-identifier",
"relocate",
] {
assert_eq!(element_attrs(&xml, tag).len(), 1, "{tag}");
}
}
#[test]
fn distribution_enables_user_home_and_references_component() {
let dir = tempfile::tempdir().unwrap();
let archive = build_archive(dir.path(), "hello-0.1.0.pkg");
let mut reader = XarReader::new(Cursor::new(archive)).unwrap();
let xml = reader
.get_file_data_from_path("Distribution")
.unwrap()
.unwrap();
let script = &element_attrs(&xml, "installer-gui-script")[0];
assert_eq!(script.get("minSpecVersion").unwrap(), "2");
let domains = &element_attrs(&xml, "domains")[0];
assert_eq!(domains.get("enable_localSystem").unwrap(), "true");
assert_eq!(domains.get("enable_currentUserHome").unwrap(), "true");
let options = &element_attrs(&xml, "options")[0];
assert_eq!(options.get("hostArchitectures").unwrap(), "x86_64,arm64");
let pkg_refs = element_attrs(&xml, "pkg-ref");
assert_eq!(pkg_refs.len(), 2);
let full = &pkg_refs[1];
assert_eq!(full.get("id").unwrap(), "ca.hrndz.embala.hello");
assert_eq!(full.get("version").unwrap(), "0.1.0");
assert_eq!(full.get("onConclusion").unwrap(), "none");
assert_eq!(full.get("installKBytes").unwrap(), "3");
let text = std::str::from_utf8(&xml).unwrap();
assert!(text.contains("#hello.pkg</pkg-ref>"), "component reference");
let mut system_only = spec(dir.path());
system_only.enable_user_home = false;
let out = dir.path().join("system.pkg");
build(&system_only, &out).unwrap();
let mut reader = XarReader::new(Cursor::new(std::fs::read(out).unwrap())).unwrap();
let xml = reader
.get_file_data_from_path("Distribution")
.unwrap()
.unwrap();
let domains = &element_attrs(&xml, "domains")[0];
assert_eq!(domains.get("enable_localSystem").unwrap(), "true");
assert!(!domains.contains_key("enable_currentUserHome"));
}
#[test]
fn payload_is_pkgbuild_shaped_cpio() {
let dir = tempfile::tempdir().unwrap();
let archive = build_archive(dir.path(), "hello-0.1.0.pkg");
let mut reader = XarReader::new(Cursor::new(archive)).unwrap();
let gz = reader
.get_file_data_from_path("hello.pkg/Payload")
.unwrap()
.unwrap();
let mut cpio = Vec::new();
flate2::read::GzDecoder::new(&gz[..])
.read_to_end(&mut cpio)
.unwrap();
assert_eq!(cpio.len() % 512, 0, "payload padded to 512-byte blocks");
let entries = parse_odc(&cpio);
let listed: Vec<(&str, u64, u64)> = entries
.iter()
.map(|e| (e.name.as_str(), e.mode, e.nlink))
.collect();
assert_eq!(
listed,
vec![
(".", 0o40755, 4),
("./bin", 0o40755, 3),
("./bin/embala-hello", 0o100755, 1),
("./share", 0o40755, 3),
("./share/doc", 0o40755, 3),
("./share/doc/hello", 0o40755, 3),
("./share/doc/hello/README", 0o100644, 1),
("TRAILER!!!", 0, 1),
]
);
let inodes: Vec<u64> = entries.iter().map(|e| e.inode).collect();
assert_eq!(inodes, (0..8).collect::<Vec<u64>>());
assert_eq!(entries[2].data, HELLO);
assert_eq!(entries[6].data, README);
}
#[test]
fn bom_payload_and_pkginfo_agree() {
let dir = tempfile::tempdir().unwrap();
let archive = build_archive(dir.path(), "hello-0.1.0.pkg");
let mut reader = XarReader::new(Cursor::new(archive)).unwrap();
let bom = reader
.get_file_data_from_path("hello.pkg/Bom")
.unwrap()
.unwrap();
let bom_paths: Vec<String> = ParsedBom::parse(&bom)
.unwrap()
.paths()
.unwrap()
.into_iter()
.map(|p| p.path().to_string())
.collect();
let gz = reader
.get_file_data_from_path("hello.pkg/Payload")
.unwrap()
.unwrap();
let mut cpio = Vec::new();
flate2::read::GzDecoder::new(&gz[..])
.read_to_end(&mut cpio)
.unwrap();
let cpio_paths: Vec<String> = parse_odc(&cpio)
.iter()
.map(|e| e.name.clone())
.filter(|n| n != "TRAILER!!!")
.collect();
let mut bom_paths = bom_paths;
let mut cpio_paths = cpio_paths;
bom_paths.sort();
cpio_paths.sort();
assert_eq!(bom_paths, cpio_paths);
let xml = reader
.get_file_data_from_path("hello.pkg/PackageInfo")
.unwrap()
.unwrap();
let payload = &element_attrs(&xml, "payload")[0];
assert_eq!(
payload.get("numberOfFiles").unwrap(),
&bom_paths.len().to_string()
);
}
#[test]
fn builds_are_byte_stable() {
let dir = tempfile::tempdir().unwrap();
let first = build_archive(dir.path(), "first.pkg");
let second = build_archive(dir.path(), "second.pkg");
assert!(!first.is_empty());
assert_eq!(first, second);
}
#[test]
fn rejects_bad_specs() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("out.pkg");
let mut empty = spec(dir.path());
empty.files.clear();
assert!(matches!(build(&empty, &out), Err(Error::NoFiles)));
let mut relative = spec(dir.path());
relative.install_location = "usr/local".to_string();
assert!(matches!(
build(&relative, &out),
Err(Error::InvalidInstallLocation(_))
));
for bad in ["../evil", "bin//x", "/abs", "bin/.."] {
let mut traversal = spec(dir.path());
traversal.files[0].dest = bad.to_string();
assert!(
matches!(build(&traversal, &out), Err(Error::InvalidDest(_))),
"dest {bad:?}"
);
}
let mut duplicate = spec(dir.path());
duplicate.files[1].dest = duplicate.files[0].dest.clone();
assert!(matches!(
build(&duplicate, &out),
Err(Error::DuplicateDest(_))
));
let mut conflict = spec(dir.path());
conflict.files[1].dest = "bin".to_string();
assert!(matches!(
build(&conflict, &out),
Err(Error::DestConflict(_))
));
assert!(!out.exists());
}