use std::{
collections::{BTreeMap, BTreeSet},
io::{Cursor, Write},
path::{Path, PathBuf},
};
use apple_xar::{
format::XarHeader,
table_of_contents::{
Checksum, ChecksumType, File as XarTocFile, FileChecksum, FileData, FileEncoding, FileType,
TableOfContents,
},
};
use flate2::{Compression, write::ZlibEncoder};
use quick_xml::{
Writer as XmlWriter,
events::{BytesDecl, BytesText, Event},
};
use scroll::IOwrite;
mod bom_builder;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("xar error: {0}")]
Xar(#[from] apple_xar::Error),
#[error("BOM error: {0}")]
Bom(#[from] apple_bom::Error),
#[error("invalid BOM path: {0}")]
BadBomPath(String),
#[error("pkg: no files to package")]
NoFiles,
#[error("pkg dest {0:?} must be a relative /-separated path without empty or '..' segments")]
InvalidDest(String),
#[error("pkg: two entries share the dest {0:?}")]
DuplicateDest(String),
#[error("pkg dest {0:?} is both a file and a directory")]
DestConflict(String),
#[error("pkg dest {dest:?}: mode {mode:#o} must be permission bits only (0o000-0o777)")]
InvalidMode { dest: String, mode: u32 },
#[error("pkg install-location {0:?} must be an absolute path")]
InvalidInstallLocation(String),
#[error("pkg scripts {0} must be an existing directory")]
ScriptsNotDir(PathBuf),
#[error("pkg script {0} must be executable (installer cannot run it otherwise)")]
ScriptNotExecutable(PathBuf),
}
pub type Result<T> = std::result::Result<T, Error>;
const EPOCH_2000: i64 = 946_684_800;
const SHA1_LEN: u64 = 20;
#[derive(Debug, Clone)]
pub struct FileSpec {
pub src: PathBuf,
pub dest: String,
pub mode: Option<u32>,
}
#[derive(Debug, Clone)]
pub struct DirSpec {
pub dest: String,
pub mode: Option<u32>,
}
#[derive(Debug, Clone)]
pub struct PkgSpec {
pub name: String,
pub display_name: String,
pub identifier: String,
pub version: String,
pub install_location: String,
pub enable_user_home: bool,
pub files: Vec<FileSpec>,
pub directories: Vec<DirSpec>,
pub scripts: Option<PathBuf>,
}
enum Node {
Dir { mode: u32 },
File { bytes: Vec<u8>, mode: u32 },
}
const DIR_MODE: u32 = 0o755;
pub fn build(spec: &PkgSpec, out: &Path) -> Result<()> {
validate(spec)?;
let mut nodes: BTreeMap<String, Node> = BTreeMap::new();
let implied_dir = || Node::Dir { mode: DIR_MODE };
for file in &spec.files {
for ancestor in ancestors(&file.dest) {
nodes
.entry(ancestor.to_string())
.or_insert_with(implied_dir);
}
let bytes = std::fs::read(&file.src)?;
let mode = match file.mode {
Some(mode) => mode,
None if executable(&file.src)? => 0o755,
None => 0o644,
};
nodes.insert(file.dest.clone(), Node::File { bytes, mode });
}
for dir in &spec.directories {
for ancestor in ancestors(&dir.dest) {
nodes
.entry(ancestor.to_string())
.or_insert_with(implied_dir);
}
let mode = dir.mode.unwrap_or(DIR_MODE);
nodes.insert(dir.dest.clone(), Node::Dir { mode });
}
let mut blocks = 0u64;
for node in nodes.values() {
blocks += match node {
Node::Dir { .. } => 1,
Node::File { bytes, .. } => (bytes.len() as u64).div_ceil(512),
};
}
let number_of_files = 1 + nodes.len() as u32;
let install_kbytes = blocks.div_ceil(2);
let bom = bom_from_nodes(&nodes, 0, 0)?;
let payload = gzip(&payload_cpio(&nodes))?;
let scripts = spec.scripts.as_deref().map(script_nodes).transpose()?;
let script_archive = scripts
.as_ref()
.map(|s| gzip(&payload_cpio(s)))
.transpose()?;
let hooks: Vec<&str> = ["preinstall", "postinstall"]
.into_iter()
.filter(|name| {
scripts
.as_ref()
.is_some_and(|s| matches!(s.get(*name), Some(Node::File { .. })))
})
.collect();
let package_info = package_info_xml(spec, number_of_files, install_kbytes, &hooks)?;
let distribution = distribution_xml(spec, install_kbytes)?;
let component = format!("{}.pkg", spec.name);
let mut children = vec![
XarEntry {
name: "Bom",
kind: XarEntryKind::File {
data: &bom,
mode: 0o644,
store: false,
},
},
XarEntry {
name: "Payload",
kind: XarEntryKind::File {
data: &payload,
mode: 0o644,
store: true,
},
},
];
if let Some(script_archive) = &script_archive {
children.push(XarEntry {
name: "Scripts",
kind: XarEntryKind::File {
data: script_archive,
mode: 0o644,
store: true,
},
});
}
children.push(XarEntry {
name: "PackageInfo",
kind: XarEntryKind::File {
data: &package_info,
mode: 0o644,
store: false,
},
});
let entries = [
XarEntry {
name: &component,
kind: XarEntryKind::Directory {
mode: 0o700,
children,
},
},
XarEntry {
name: "Distribution",
kind: XarEntryKind::File {
data: &distribution,
mode: 0o644,
store: false,
},
},
];
if let Some(parent) = out.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let mut archive = Vec::new();
write_xar(&entries, &mut archive)?;
std::fs::write(out, archive)?;
Ok(())
}
pub fn validate(spec: &PkgSpec) -> Result<()> {
if spec.files.is_empty() {
return Err(Error::NoFiles);
}
if !spec.install_location.starts_with('/') {
return Err(Error::InvalidInstallLocation(spec.install_location.clone()));
}
let entries = spec
.files
.iter()
.map(|f| (&f.dest, f.mode))
.chain(spec.directories.iter().map(|d| (&d.dest, d.mode)));
let mut seen = BTreeSet::new();
for (dest, mode) in entries {
if dest.split('/').any(|s| s.is_empty() || s == "..") {
return Err(Error::InvalidDest(dest.clone()));
}
if !seen.insert(dest.as_str()) {
return Err(Error::DuplicateDest(dest.clone()));
}
if let Some(mode) = mode.filter(|m| *m > 0o777) {
return Err(Error::InvalidMode {
dest: dest.clone(),
mode,
});
}
}
let dirs: BTreeSet<&str> = spec
.directories
.iter()
.map(|d| d.dest.as_str())
.chain(spec.files.iter().flat_map(|f| ancestors(&f.dest)))
.chain(spec.directories.iter().flat_map(|d| ancestors(&d.dest)))
.collect();
for file in &spec.files {
if dirs.contains(file.dest.as_str()) {
return Err(Error::DestConflict(file.dest.clone()));
}
}
Ok(())
}
fn script_nodes(dir: &Path) -> Result<BTreeMap<String, Node>> {
if !dir.is_dir() {
return Err(Error::ScriptsNotDir(dir.to_path_buf()));
}
let mut nodes = BTreeMap::new();
collect_nodes(dir, "", &mut nodes)?;
for hook in ["preinstall", "postinstall"] {
if let Some(Node::File { mode, .. }) = nodes.get(hook) {
if mode & 0o111 == 0 {
return Err(Error::ScriptNotExecutable(dir.join(hook)));
}
}
}
Ok(nodes)
}
fn collect_nodes(dir: &Path, prefix: &str, nodes: &mut BTreeMap<String, Node>) -> Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
let rel = if prefix.is_empty() {
name
} else {
format!("{prefix}/{name}")
};
let path = entry.path();
if std::fs::metadata(&path)?.is_dir() {
nodes.insert(rel.clone(), Node::Dir { mode: DIR_MODE });
collect_nodes(&path, &rel, nodes)?;
} else {
let mode = if executable(&path)? { 0o755 } else { 0o644 };
let bytes = std::fs::read(&path)?;
nodes.insert(rel, Node::File { bytes, mode });
}
}
Ok(())
}
fn ancestors(dest: &str) -> impl Iterator<Item = &str> {
dest.char_indices()
.filter(|(_, c)| *c == '/')
.map(|(i, _)| &dest[..i])
}
#[cfg(unix)]
fn executable(path: &Path) -> Result<bool> {
use std::os::unix::fs::PermissionsExt;
Ok(std::fs::metadata(path)?.permissions().mode() & 0o111 != 0)
}
#[cfg(windows)]
fn executable(path: &Path) -> Result<bool> {
use std::io::Read;
let mut head = [0u8; 4];
let mut fh = std::fs::File::open(path)?;
let mut filled = 0;
while filled < head.len() {
match fh.read(&mut head[filled..])? {
0 => break,
n => filled += n,
}
}
Ok(looks_executable(&head[..filled]))
}
#[cfg(any(windows, test))]
fn looks_executable(head: &[u8]) -> bool {
if head.starts_with(b"#!") {
return true;
}
let Some(magic) = head.get(..4) else {
return false;
};
matches!(
u32::from_be_bytes(magic.try_into().expect("4 bytes")),
0xfeed_face | 0xcefa_edfe | 0xfeed_facf | 0xcffa_edfe | 0xcafe_babe | 0xbeba_feca
)
}
fn payload_cpio(nodes: &BTreeMap<String, Node>) -> Vec<u8> {
let mut child_count: BTreeMap<&str, u32> = BTreeMap::new();
for path in nodes.keys() {
let parent = match path.rfind('/') {
Some(i) => &path[..i],
None => "",
};
*child_count.entry(parent).or_insert(0) += 1;
}
let children = |path: &str| child_count.get(path).copied().unwrap_or(0);
let mtime = EPOCH_2000 as u64;
let mut out = Vec::new();
let mut inode = 0;
odc_record(&mut out, ".", 0o40755, 2 + children(""), inode, mtime, &[]);
for (path, node) in nodes {
inode += 1;
let name = format!("./{path}");
match node {
Node::Dir { mode } => {
let nlink = 2 + children(path.as_str());
odc_record(&mut out, &name, 0o40000 | mode, nlink, inode, mtime, &[]);
}
Node::File { bytes, mode } => {
odc_record(&mut out, &name, 0o100000 | mode, 1, inode, mtime, bytes);
}
}
}
odc_record(&mut out, "TRAILER!!!", 0, 1, inode + 1, 0, &[]);
let padded = out.len().div_ceil(512) * 512;
out.resize(padded, 0);
out
}
fn odc_record(
out: &mut Vec<u8>,
name: &str,
mode: u32,
nlink: u32,
inode: u32,
mtime: u64,
data: &[u8],
) {
let header = format!(
"070707{dev:06o}{inode:06o}{mode:06o}{uid:06o}{gid:06o}{nlink:06o}{rdev:06o}\
{mtime:011o}{namesize:06o}{filesize:011o}",
dev = 0,
uid = 0,
gid = 0,
rdev = 0,
namesize = name.len() + 1,
filesize = data.len(),
);
out.extend_from_slice(header.as_bytes());
out.extend_from_slice(name.as_bytes());
out.push(0);
out.extend_from_slice(data);
}
fn gzip(data: &[u8]) -> std::io::Result<Vec<u8>> {
let mut encoder = flate2::GzBuilder::new()
.mtime(0)
.write(Vec::new(), Compression::default());
encoder.write_all(data)?;
encoder.finish()
}
type XmlResult = std::result::Result<Vec<u8>, std::io::Error>;
fn xml_writer() -> std::result::Result<XmlWriter<Cursor<Vec<u8>>>, std::io::Error> {
let mut writer = XmlWriter::new_with_indent(Cursor::new(Vec::new()), b' ', 4);
writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("utf-8"), None)))?;
Ok(writer)
}
fn package_info_xml(
spec: &PkgSpec,
number_of_files: u32,
install_kbytes: u64,
hooks: &[&str],
) -> XmlResult {
let mut writer = xml_writer()?;
writer
.create_element("pkg-info")
.with_attribute(("overwrite-permissions", "true"))
.with_attribute(("relocatable", "false"))
.with_attribute(("identifier", spec.identifier.as_str()))
.with_attribute(("postinstall-action", "none"))
.with_attribute(("version", spec.version.as_str()))
.with_attribute(("format-version", "2"))
.with_attribute(("install-location", spec.install_location.as_str()))
.with_attribute(("auth", "root"))
.write_inner_content(|w| {
w.create_element("payload")
.with_attribute(("numberOfFiles", number_of_files.to_string().as_str()))
.with_attribute(("installKBytes", install_kbytes.to_string().as_str()))
.write_empty()?;
for tag in [
"bundle-version",
"upgrade-bundle",
"update-bundle",
"atomic-update-bundle",
"strict-identifier",
"relocate",
] {
w.create_element(tag).write_empty()?;
}
if !hooks.is_empty() {
w.create_element("scripts").write_inner_content(|w| {
for hook in hooks {
w.create_element(*hook)
.with_attribute(("file", format!("./{hook}").as_str()))
.with_attribute(("timeout", "600"))
.write_empty()?;
}
Ok::<(), std::io::Error>(())
})?;
}
Ok::<(), std::io::Error>(())
})?;
Ok(writer.into_inner().into_inner())
}
fn distribution_xml(spec: &PkgSpec, install_kbytes: u64) -> XmlResult {
let id = spec.identifier.as_str();
let mut writer = xml_writer()?;
writer
.create_element("installer-gui-script")
.with_attribute(("minSpecVersion", "2"))
.write_inner_content(|w| {
w.create_element("title")
.write_text_content(BytesText::new(&spec.display_name))?;
let domains = w
.create_element("domains")
.with_attribute(("enable_localSystem", "true"));
if spec.enable_user_home {
domains
.with_attribute(("enable_currentUserHome", "true"))
.write_empty()?;
} else {
domains.write_empty()?;
}
w.create_element("options")
.with_attribute(("customize", "never"))
.with_attribute(("require-scripts", "false"))
.with_attribute(("hostArchitectures", "x86_64,arm64"))
.write_empty()?;
w.create_element("choices-outline")
.write_inner_content(|w| {
w.create_element("line")
.with_attribute(("choice", "default"))
.write_inner_content(|w| {
w.create_element("line")
.with_attribute(("choice", id))
.write_empty()?;
Ok::<(), std::io::Error>(())
})?;
Ok::<(), std::io::Error>(())
})?;
w.create_element("choice")
.with_attribute(("id", "default"))
.write_empty()?;
w.create_element("choice")
.with_attribute(("id", id))
.with_attribute(("visible", "false"))
.write_inner_content(|w| {
w.create_element("pkg-ref")
.with_attribute(("id", id))
.write_empty()?;
Ok::<(), std::io::Error>(())
})?;
w.create_element("pkg-ref")
.with_attribute(("id", id))
.with_attribute(("version", spec.version.as_str()))
.with_attribute(("onConclusion", "none"))
.with_attribute(("installKBytes", install_kbytes.to_string().as_str()))
.with_attribute(("updateKBytes", "0"))
.write_text_content(BytesText::new(&format!("#{}.pkg", spec.name)))?;
Ok::<(), std::io::Error>(())
})?;
Ok(writer.into_inner().into_inner())
}
pub struct XarEntry<'a> {
pub name: &'a str,
pub kind: XarEntryKind<'a>,
}
pub enum XarEntryKind<'a> {
File {
data: &'a [u8],
mode: u32,
store: bool,
},
Directory {
mode: u32,
children: Vec<XarEntry<'a>>,
},
}
const TOC_TEMPLATE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<xar><toc>
<creation-time>2000-01-01T00:00:00</creation-time>
<checksum style="sha1"><offset>0</offset><size>20</size></checksum>
<file id="1"><type>file</type><name>placeholder</name></file>
</toc></xar>"#;
fn hex(data: &[u8]) -> String {
data.iter().map(|b| format!("{b:02x}")).collect()
}
fn zlib_compress(data: &[u8]) -> std::io::Result<Vec<u8>> {
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(data)?;
encoder.finish()
}
fn sha1_checksum(data: &[u8]) -> Result<FileChecksum> {
Ok(FileChecksum {
style: ChecksumType::Sha1,
checksum: hex(&ChecksumType::Sha1.digest_data(data)?),
})
}
pub fn write_xar<W: Write>(entries: &[XarEntry<'_>], mut writer: W) -> Result<()> {
let mut toc = TableOfContents::from_reader(TOC_TEMPLATE.as_bytes())?;
let mut heap = Vec::new();
let mut next_id = 1;
let mut files = Vec::with_capacity(entries.len());
for entry in entries {
files.push(toc_file(entry, &mut next_id, &mut heap)?);
}
toc.creation_time = "2000-01-01T00:00:00".to_string();
toc.checksum = Checksum {
style: ChecksumType::Sha1,
offset: 0,
size: SHA1_LEN,
};
toc.files = files;
toc.signature = None;
toc.x_signature = None;
let toc_xml = toc.to_xml()?;
let toc_compressed = zlib_compress(&toc_xml)?;
let toc_digest = ChecksumType::Sha1.digest_data(&toc_compressed)?;
let header = XarHeader {
magic: 0x7861_7221, size: 28,
version: 1,
toc_length_compressed: toc_compressed.len() as u64,
toc_length_uncompressed: toc_xml.len() as u64,
checksum_algorithm_id: 1, };
writer.iowrite_with(header, scroll::BE)?;
writer.write_all(&toc_compressed)?;
writer.write_all(&toc_digest)?;
writer.write_all(&heap)?;
Ok(())
}
fn toc_file(entry: &XarEntry<'_>, next_id: &mut u64, heap: &mut Vec<u8>) -> Result<XarTocFile> {
let id = *next_id;
*next_id += 1;
let blank = XarTocFile {
id,
ctime: None,
mtime: None,
atime: None,
names: vec![entry.name.to_string()],
file_type: FileType::File,
mode: None,
deviceno: None,
inode: None,
uid: Some(0),
gid: Some(0),
user: None,
group: None,
size: None,
data: None,
ea: None,
finder_create_time: None,
files: vec![],
};
match &entry.kind {
XarEntryKind::File { data, mode, store } => {
let (archived, encoding) = if *store {
(data.to_vec(), "application/octet-stream")
} else {
(zlib_compress(data)?, "application/x-gzip")
};
let file = XarTocFile {
mode: Some(format!("{:04o}", mode & 0o7777)),
size: Some(data.len() as u64),
data: Some(FileData {
offset: SHA1_LEN + heap.len() as u64,
size: data.len() as u64,
length: archived.len() as u64,
extracted_checksum: sha1_checksum(data)?,
archived_checksum: sha1_checksum(&archived)?,
encoding: FileEncoding {
style: encoding.to_string(),
},
}),
..blank
};
heap.extend_from_slice(&archived);
Ok(file)
}
XarEntryKind::Directory { mode, children } => {
let mut files = Vec::with_capacity(children.len());
for child in children {
files.push(toc_file(child, next_id, heap)?);
}
Ok(XarTocFile {
file_type: FileType::Directory,
mode: Some(format!("{:04o}", mode & 0o7777)),
files,
..blank
})
}
}
}
fn bom_from_nodes(nodes: &BTreeMap<String, Node>, uid: u32, gid: u32) -> Result<Vec<u8>> {
let mtime = chrono::DateTime::from_timestamp(EPOCH_2000, 0).expect("fixed timestamp is valid");
let mut builder = bom_builder::BomBuilder::default();
builder.default_user_id(uid);
builder.default_group_id(gid);
builder.default_mtime(mtime);
for (path, node) in nodes {
match node {
Node::Dir { mode } => {
builder.add_directory(path, (0o40000 | (mode & 0o7777)) as u16)?;
}
Node::File { bytes, mode } => {
let entry = builder.add_file_from_bytes(path, bytes)?;
entry.set_file_mode((0o100000 | (mode & 0o7777)) as u16);
entry.set_modified_time(mtime);
}
}
}
builder.build_bom()
}
#[cfg(unix)]
pub fn write_bom(root: &Path, uid: u32, gid: u32) -> Result<Vec<u8>> {
use std::os::unix::fs::PermissionsExt;
let mtime = chrono::DateTime::from_timestamp(EPOCH_2000, 0).expect("fixed timestamp is valid");
let mut builder = bom_builder::BomBuilder::default();
builder.default_user_id(uid);
builder.default_group_id(gid);
builder.default_mtime(mtime);
let mut files = Vec::new();
collect_files(root, PathBuf::new(), &mut files)?;
files.sort();
for relative in files {
let full = root.join(&relative);
let mode = full.metadata()?.permissions().mode();
let entry = builder.add_file_from_path(relative.to_string_lossy(), &full)?;
entry.set_file_mode((0o100000 | (mode & 0o7777)) as u16);
entry.set_modified_time(mtime);
}
builder.build_bom()
}
#[cfg(unix)]
fn collect_files(dir: &Path, prefix: PathBuf, files: &mut Vec<PathBuf>) -> Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let relative = prefix.join(entry.file_name());
let file_type = entry.file_type()?;
if file_type.is_dir() {
collect_files(&entry.path(), relative, files)?;
} else if file_type.is_file() {
files.push(relative);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::looks_executable;
#[test]
fn sniff_accepts_mach_o_and_scripts_only() {
assert!(looks_executable(&[0xcf, 0xfa, 0xed, 0xfe]));
assert!(looks_executable(&[0xca, 0xfe, 0xba, 0xbe]));
assert!(looks_executable(b"#!/bin/sh\necho hi\n"));
assert!(!looks_executable(b"read me\n"));
assert!(!looks_executable(b"#"), "a lone '#' is not a shebang");
assert!(!looks_executable(b""));
}
}