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 files share the dest {0:?}")]
DuplicateDest(String),
#[error("pkg dest {0:?} is both a file and a directory of another dest")]
DestConflict(String),
#[error("pkg install-location {0:?} must be an absolute path")]
InvalidInstallLocation(String),
}
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,
}
#[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>,
}
enum Node {
Dir,
File { bytes: Vec<u8>, mode: u32 },
}
pub fn build(spec: &PkgSpec, out: &Path) -> Result<()> {
validate(spec)?;
let mut nodes: BTreeMap<String, Node> = BTreeMap::new();
for file in &spec.files {
for ancestor in ancestors(&file.dest) {
nodes.insert(ancestor.to_string(), Node::Dir);
}
let bytes = std::fs::read(&file.src)?;
let mode = if executable(&file.src)? { 0o755 } else { 0o644 };
nodes.insert(file.dest.clone(), Node::File { bytes, 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 staging = tempfile::tempdir()?;
stage_tree(staging.path(), &nodes)?;
let bom = write_bom(staging.path(), 0, 0)?;
let payload = gzip(&payload_cpio(&nodes))?;
let package_info = package_info_xml(spec, number_of_files, install_kbytes)?;
let distribution = distribution_xml(spec, install_kbytes)?;
let component = format!("{}.pkg", spec.name);
let entries = [
XarEntry {
name: &component,
kind: XarEntryKind::Directory {
mode: 0o700,
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,
},
},
XarEntry {
name: "PackageInfo",
kind: XarEntryKind::File {
data: &package_info,
mode: 0o644,
store: false,
},
},
],
},
},
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(())
}
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 mut seen = BTreeSet::new();
for file in &spec.files {
if file.dest.split('/').any(|s| s.is_empty() || s == "..") {
return Err(Error::InvalidDest(file.dest.clone()));
}
if !seen.insert(file.dest.as_str()) {
return Err(Error::DuplicateDest(file.dest.clone()));
}
}
let dirs: BTreeSet<&str> = spec.files.iter().flat_map(|f| ancestors(&f.dest)).collect();
for file in &spec.files {
if dirs.contains(file.dest.as_str()) {
return Err(Error::DestConflict(file.dest.clone()));
}
}
Ok(())
}
fn ancestors(dest: &str) -> impl Iterator<Item = &str> {
dest.char_indices()
.filter(|(_, c)| *c == '/')
.map(|(i, _)| &dest[..i])
}
fn executable(path: &Path) -> Result<bool> {
use std::os::unix::fs::PermissionsExt;
Ok(std::fs::metadata(path)?.permissions().mode() & 0o111 != 0)
}
fn stage_tree(root: &Path, nodes: &BTreeMap<String, Node>) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
for (path, node) in nodes {
let full = root.join(path);
match node {
Node::Dir => std::fs::create_dir_all(&full)?,
Node::File { bytes, mode } => {
std::fs::write(&full, bytes)?;
std::fs::set_permissions(&full, std::fs::Permissions::from_mode(*mode))?;
}
}
}
Ok(())
}
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 => {
let nlink = 2 + children(path.as_str());
odc_record(&mut out, &name, 0o40755, 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) -> 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()?;
}
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
})
}
}
}
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()
}
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(())
}