Skip to main content

embala_pkg/
lib.rs

1//! macOS flat-package (`.pkg`) builder — product archives on any OS.
2//!
3//! [`build`] writes the same shape `productbuild` emits: a xar archive
4//! holding a `Distribution` script plus a `<name>.pkg/` component directory
5//! with `PackageInfo`, `Bom`, a gzipped odc-cpio `Payload`, and (with
6//! [`PkgSpec::scripts`]) a `Scripts` archive in the same format. Every layer
7//! was pinned against Apple tooling on macOS 26.5 (pkgbuild/productbuild
8//! output dissected byte-by-byte; results verified with `pkgutil
9//! --expand-full`, `lsbom`, and `installer`).
10//!
11//! Lower-level pieces are public for reuse and testing: [`write_xar`]
12//! (xar archives with nested directories) and [`write_bom`] (Bill of
13//! Materials over a staged tree).
14//!
15//! Spike outcomes the implementation rests on:
16//!
17//! - **xar**: `apple-xar` 0.20 has no turn-key writer, but its primitives
18//!   compose: `TableOfContents`/`File`/`FileData` are public with
19//!   `to_xml()`, `ChecksumType::digest_data` provides digests, and
20//!   `XarHeader` derives scroll `IOwrite`. The only wrinkle is that
21//!   `TableOfContents`'s inner field is private with no constructor, so we
22//!   bootstrap one by parsing a minimal template TOC via `from_reader` and
23//!   mutating it through its `DerefMut<Target = XarToC>` impl. No
24//!   hand-rolled writer needed.
25//! - **BOM**: `apple-bom` 0.3's shipped `BomBuilder` is unusable — its
26//!   `build_bom()` panics unconditionally, and beneath that lie a dozen
27//!   more writer bugs (wrong checksum algorithm, wrong B-tree order,
28//!   missing free list, ...; see `bom_builder.rs` for the catalog).
29//!   Bounded fallback taken: `bom_builder` ports the assembly on top of
30//!   apple-bom's public `format` types with those fixes; the output
31//!   matches both `mkbom` and pkgbuild's embedded `Bom` under
32//!   `lsbom -p fmugsc` (paths, modes, uid, gid, size, cksum). Per-file
33//!   modes must include the file-type bits (e.g. `0o100755`).
34//! - **cpio**: the Payload is odc/ASCII cpio (magic `070707`), hand-rolled
35//!   here because the format is a 76-byte octal header and pkgbuild's
36//!   conventions (sequential inodes, dir nlink = 2 + children, zero pad to
37//!   a 512-byte boundary) need exact control no crate offered.
38
39use std::{
40    collections::{BTreeMap, BTreeSet},
41    io::{Cursor, Write},
42    path::{Path, PathBuf},
43};
44
45use apple_xar::{
46    format::XarHeader,
47    table_of_contents::{
48        Checksum, ChecksumType, File as XarTocFile, FileChecksum, FileData, FileEncoding, FileType,
49        TableOfContents,
50    },
51};
52use flate2::{Compression, write::ZlibEncoder};
53use quick_xml::{
54    Writer as XmlWriter,
55    events::{BytesDecl, BytesText, Event},
56};
57use scroll::IOwrite;
58
59mod bom_builder;
60
61#[derive(Debug, thiserror::Error)]
62pub enum Error {
63    #[error("I/O error: {0}")]
64    Io(#[from] std::io::Error),
65
66    #[error("xar error: {0}")]
67    Xar(#[from] apple_xar::Error),
68
69    #[error("BOM error: {0}")]
70    Bom(#[from] apple_bom::Error),
71
72    #[error("invalid BOM path: {0}")]
73    BadBomPath(String),
74
75    #[error("pkg: no files to package")]
76    NoFiles,
77
78    #[error("pkg dest {0:?} must be a relative /-separated path without empty or '..' segments")]
79    InvalidDest(String),
80
81    #[error("pkg: two entries share the dest {0:?}")]
82    DuplicateDest(String),
83
84    #[error("pkg dest {0:?} is both a file and a directory")]
85    DestConflict(String),
86
87    #[error("pkg dest {dest:?}: mode {mode:#o} must be permission bits only (0o000-0o777)")]
88    InvalidMode { dest: String, mode: u32 },
89
90    #[error("pkg install-location {0:?} must be an absolute path")]
91    InvalidInstallLocation(String),
92
93    #[error("pkg scripts {0} must be an existing directory")]
94    ScriptsNotDir(PathBuf),
95
96    #[error("pkg script {0} must be executable (installer cannot run it otherwise)")]
97    ScriptNotExecutable(PathBuf),
98}
99
100pub type Result<T> = std::result::Result<T, Error>;
101
102/// Fixed timestamp used for deterministic output (2000-01-01T00:00:00Z).
103const EPOCH_2000: i64 = 946_684_800;
104
105/// SHA-1 digest length; the xar TOC checksum occupies heap `[0, 20)`.
106const SHA1_LEN: u64 = 20;
107
108/// A payload file: `src` on disk (already resolved to a real path), `dest`
109/// the `/`-separated path relative to the install location.
110#[derive(Debug, Clone)]
111pub struct FileSpec {
112    pub src: PathBuf,
113    pub dest: String,
114    /// Permission bits (`0o000`-`0o777`). `None` installs `0o755` if the
115    /// source is executable, else `0o644`.
116    pub mode: Option<u32>,
117}
118
119/// A payload directory declared explicitly, to give it a mode or to ship it
120/// with nothing under it. Directories only implied by file dests are `0o755`.
121#[derive(Debug, Clone)]
122pub struct DirSpec {
123    pub dest: String,
124    /// Permission bits (`0o000`-`0o777`); `None` means `0o755`.
125    pub mode: Option<u32>,
126}
127
128/// Everything needed to author one product archive. Deliberately independent
129/// of the embala config types so the crate is usable on its own.
130#[derive(Debug, Clone)]
131pub struct PkgSpec {
132    /// Package name; names the artifact and the inner `<name>.pkg` component.
133    pub name: String,
134    /// Human name: the Distribution `<title>` Installer.app displays.
135    pub display_name: String,
136    /// Reverse-DNS package identifier (`pkgutil` receipt id).
137    pub identifier: String,
138    pub version: String,
139    /// Absolute path the payload tree installs under (e.g. `/usr/local`).
140    pub install_location: String,
141    /// Adds `enable_currentUserHome` to the Distribution's `<domains>` so
142    /// `installer -target CurrentUserHomeDirectory` is legal (the sudo-free
143    /// install path).
144    pub enable_user_home: bool,
145    pub files: Vec<FileSpec>,
146    pub directories: Vec<DirSpec>,
147    /// Directory packed as the component's `Scripts` archive, like
148    /// `pkgbuild --scripts`: every file ships (helpers included), and a
149    /// top-level `preinstall`/`postinstall` is referenced from PackageInfo.
150    pub scripts: Option<PathBuf>,
151}
152
153/// One node of the payload tree, keyed by `/`-separated relative path.
154enum Node {
155    Dir { mode: u32 },
156    File { bytes: Vec<u8>, mode: u32 },
157}
158
159/// pkgbuild's mode for a directory nothing else describes.
160const DIR_MODE: u32 = 0o755;
161
162/// Build the product-archive `.pkg` described by `spec` at `out` (parent
163/// dirs are created). Output is deterministic: fixed timestamps everywhere
164/// (xar TOC, cpio, gzip) and sorted payload entries.
165pub fn build(spec: &PkgSpec, out: &Path) -> Result<()> {
166    validate(spec)?;
167
168    // Payload tree model: relative path -> node, sorted (BTreeMap order
169    // matches pkgbuild's cpio/BOM ordering: parents before children).
170    let mut nodes: BTreeMap<String, Node> = BTreeMap::new();
171    let implied_dir = || Node::Dir { mode: DIR_MODE };
172    for file in &spec.files {
173        for ancestor in ancestors(&file.dest) {
174            nodes
175                .entry(ancestor.to_string())
176                .or_insert_with(implied_dir);
177        }
178        let bytes = std::fs::read(&file.src)?;
179        let mode = match file.mode {
180            Some(mode) => mode,
181            None if executable(&file.src)? => 0o755,
182            None => 0o644,
183        };
184        nodes.insert(file.dest.clone(), Node::File { bytes, mode });
185    }
186    // Declared directories override the implied mode; validation already
187    // ruled out a declared directory landing on a file.
188    for dir in &spec.directories {
189        for ancestor in ancestors(&dir.dest) {
190            nodes
191                .entry(ancestor.to_string())
192                .or_insert_with(implied_dir);
193        }
194        let mode = dir.mode.unwrap_or(DIR_MODE);
195        nodes.insert(dir.dest.clone(), Node::Dir { mode });
196    }
197
198    // pkg-info bookkeeping. numberOfFiles counts every BOM path including
199    // the root ".". installKBytes uses pkgbuild's rule (pinned empirically
200    // against eight pkgbuild runs): every file occupies ceil(size/512)
201    // 512-byte blocks, every directory except the root occupies one block,
202    // and the total is rounded up to whole KiB.
203    let mut blocks = 0u64;
204    for node in nodes.values() {
205        blocks += match node {
206            Node::Dir { .. } => 1,
207            Node::File { bytes, .. } => (bytes.len() as u64).div_ceil(512),
208        };
209    }
210    let number_of_files = 1 + nodes.len() as u32;
211    let install_kbytes = blocks.div_ceil(2);
212
213    // BOM over the payload tree (root:wheel, 0:0).
214    let bom = bom_from_nodes(&nodes, 0, 0)?;
215
216    let payload = gzip(&payload_cpio(&nodes))?;
217    let scripts = spec.scripts.as_deref().map(script_nodes).transpose()?;
218    let script_archive = scripts
219        .as_ref()
220        .map(|s| gzip(&payload_cpio(s)))
221        .transpose()?;
222    // pkgbuild references only these two names; other files are helpers.
223    let hooks: Vec<&str> = ["preinstall", "postinstall"]
224        .into_iter()
225        .filter(|name| {
226            scripts
227                .as_ref()
228                .is_some_and(|s| matches!(s.get(*name), Some(Node::File { .. })))
229        })
230        .collect();
231    let package_info = package_info_xml(spec, number_of_files, install_kbytes, &hooks)?;
232    let distribution = distribution_xml(spec, install_kbytes)?;
233
234    // Product-archive layout, mirroring productbuild: the component
235    // directory first (children in Apple's order: Bom, Payload, Scripts,
236    // PackageInfo), Distribution last. Payload and Scripts are already
237    // gzipped, so they are stored raw (application/octet-stream) exactly as
238    // Apple does.
239    let component = format!("{}.pkg", spec.name);
240    let mut children = vec![
241        XarEntry {
242            name: "Bom",
243            kind: XarEntryKind::File {
244                data: &bom,
245                mode: 0o644,
246                store: false,
247            },
248        },
249        XarEntry {
250            name: "Payload",
251            kind: XarEntryKind::File {
252                data: &payload,
253                mode: 0o644,
254                store: true,
255            },
256        },
257    ];
258    if let Some(script_archive) = &script_archive {
259        children.push(XarEntry {
260            name: "Scripts",
261            kind: XarEntryKind::File {
262                data: script_archive,
263                mode: 0o644,
264                store: true,
265            },
266        });
267    }
268    children.push(XarEntry {
269        name: "PackageInfo",
270        kind: XarEntryKind::File {
271            data: &package_info,
272            mode: 0o644,
273            store: false,
274        },
275    });
276    let entries = [
277        XarEntry {
278            name: &component,
279            kind: XarEntryKind::Directory {
280                mode: 0o700,
281                children,
282            },
283        },
284        XarEntry {
285            name: "Distribution",
286            kind: XarEntryKind::File {
287                data: &distribution,
288                mode: 0o644,
289                store: false,
290            },
291        },
292    ];
293
294    if let Some(parent) = out.parent() {
295        if !parent.as_os_str().is_empty() {
296            std::fs::create_dir_all(parent)?;
297        }
298    }
299    let mut archive = Vec::new();
300    write_xar(&entries, &mut archive)?;
301    std::fs::write(out, archive)?;
302    Ok(())
303}
304
305/// Check `spec` without touching the filesystem: dests are relative and
306/// unique, no file sits where a directory must be, and modes are permission
307/// bits only. [`build`] runs this first; it is public so callers can reject a
308/// bad layout before building anything.
309pub fn validate(spec: &PkgSpec) -> Result<()> {
310    if spec.files.is_empty() {
311        return Err(Error::NoFiles);
312    }
313    if !spec.install_location.starts_with('/') {
314        return Err(Error::InvalidInstallLocation(spec.install_location.clone()));
315    }
316    let entries = spec
317        .files
318        .iter()
319        .map(|f| (&f.dest, f.mode))
320        .chain(spec.directories.iter().map(|d| (&d.dest, d.mode)));
321    let mut seen = BTreeSet::new();
322    for (dest, mode) in entries {
323        if dest.split('/').any(|s| s.is_empty() || s == "..") {
324            return Err(Error::InvalidDest(dest.clone()));
325        }
326        if !seen.insert(dest.as_str()) {
327            return Err(Error::DuplicateDest(dest.clone()));
328        }
329        // setuid/setgid/sticky are out of scope: a payload that grants
330        // privilege should be a deliberate feature, not a stray digit.
331        if let Some(mode) = mode.filter(|m| *m > 0o777) {
332            return Err(Error::InvalidMode {
333                dest: dest.clone(),
334                mode,
335            });
336        }
337    }
338    // Every path that must be a directory: declared ones and the ancestors
339    // of every dest. A file on any of them is a conflict.
340    let dirs: BTreeSet<&str> = spec
341        .directories
342        .iter()
343        .map(|d| d.dest.as_str())
344        .chain(spec.files.iter().flat_map(|f| ancestors(&f.dest)))
345        .chain(spec.directories.iter().flat_map(|d| ancestors(&d.dest)))
346        .collect();
347    for file in &spec.files {
348        if dirs.contains(file.dest.as_str()) {
349            return Err(Error::DestConflict(file.dest.clone()));
350        }
351    }
352    Ok(())
353}
354
355/// Load a `--scripts` directory as a cpio tree. Like pkgbuild, every entry
356/// ships (helper files and subdirectories too), keeping its exec bit. Unlike
357/// pkgbuild, which archives a non-executable hook that `installer` then fails
358/// to run, a non-executable `preinstall`/`postinstall` is rejected here.
359fn script_nodes(dir: &Path) -> Result<BTreeMap<String, Node>> {
360    if !dir.is_dir() {
361        return Err(Error::ScriptsNotDir(dir.to_path_buf()));
362    }
363    let mut nodes = BTreeMap::new();
364    collect_nodes(dir, "", &mut nodes)?;
365    for hook in ["preinstall", "postinstall"] {
366        if let Some(Node::File { mode, .. }) = nodes.get(hook) {
367            if mode & 0o111 == 0 {
368                return Err(Error::ScriptNotExecutable(dir.join(hook)));
369            }
370        }
371    }
372    Ok(nodes)
373}
374
375fn collect_nodes(dir: &Path, prefix: &str, nodes: &mut BTreeMap<String, Node>) -> Result<()> {
376    for entry in std::fs::read_dir(dir)? {
377        let entry = entry?;
378        let name = entry.file_name().to_string_lossy().into_owned();
379        let rel = if prefix.is_empty() {
380            name
381        } else {
382            format!("{prefix}/{name}")
383        };
384        // Follows symlinks: a linked script ships as the file it points to.
385        let path = entry.path();
386        if std::fs::metadata(&path)?.is_dir() {
387            nodes.insert(rel.clone(), Node::Dir { mode: DIR_MODE });
388            collect_nodes(&path, &rel, nodes)?;
389        } else {
390            let mode = if executable(&path)? { 0o755 } else { 0o644 };
391            let bytes = std::fs::read(&path)?;
392            nodes.insert(rel, Node::File { bytes, mode });
393        }
394    }
395    Ok(())
396}
397
398/// Proper ancestors of a `/`-separated relative path: `"a/b/c"` -> `a`, `a/b`.
399fn ancestors(dest: &str) -> impl Iterator<Item = &str> {
400    dest.char_indices()
401        .filter(|(_, c)| *c == '/')
402        .map(|(i, _)| &dest[..i])
403}
404
405/// Does this payload file install as `0o755` rather than `0o644`?
406///
407/// The source's own exec bit is authoritative wherever the host records one.
408#[cfg(unix)]
409fn executable(path: &Path) -> Result<bool> {
410    use std::os::unix::fs::PermissionsExt;
411    Ok(std::fs::metadata(path)?.permissions().mode() & 0o111 != 0)
412}
413
414/// Windows has no exec bit to read, so fall back to sniffing the content for
415/// the two things a macOS payload actually needs `+x` on: a Mach-O image
416/// (thin or universal) and a `#!` script.
417///
418/// SHORTCUT: this is a heuristic, and it makes the mode host-dependent — a
419/// non-Mach-O, non-script file that is `chmod +x` on Unix installs as `0o644`
420/// when the same config is built on Windows. A payload file with an explicit
421/// [`FileSpec::mode`] skips it; scripts and mode-less files still rely on it.
422#[cfg(windows)]
423fn executable(path: &Path) -> Result<bool> {
424    use std::io::Read;
425
426    let mut head = [0u8; 4];
427    let mut fh = std::fs::File::open(path)?;
428    let mut filled = 0;
429    while filled < head.len() {
430        match fh.read(&mut head[filled..])? {
431            0 => break,
432            n => filled += n,
433        }
434    }
435    Ok(looks_executable(&head[..filled]))
436}
437
438/// The sniff itself, kept host-independent so it stays under test on hosts
439/// that never call it.
440#[cfg(any(windows, test))]
441fn looks_executable(head: &[u8]) -> bool {
442    if head.starts_with(b"#!") {
443        return true;
444    }
445    let Some(magic) = head.get(..4) else {
446        return false;
447    };
448    matches!(
449        u32::from_be_bytes(magic.try_into().expect("4 bytes")),
450        // Mach-O thin, 32- and 64-bit, both endiannesses, plus the universal
451        // ("fat") wrappers — including the 0xcafebabe form Java class files
452        // share, which is why a `.class` payload would false-positive here.
453        0xfeed_face | 0xcefa_edfe | 0xfeed_facf | 0xcffa_edfe | 0xcafe_babe | 0xbeba_feca
454    )
455}
456
457// ---------------------------------------------------------------------------
458// Payload: gzipped odc/ASCII cpio, matching pkgbuild's conventions.
459
460/// Serialize the payload tree as odc cpio the way pkgbuild does: entries
461/// `.`/`./path` in sorted order, ownership 0:0, sequential inode numbers,
462/// directory nlink = 2 + direct children, root `0o755`, fixed mtime, `TRAILER!!!`
463/// terminator (mtime 0), zero padding to a 512-byte boundary.
464fn payload_cpio(nodes: &BTreeMap<String, Node>) -> Vec<u8> {
465    // Direct-child counts drive the directory nlink values ("" = root).
466    let mut child_count: BTreeMap<&str, u32> = BTreeMap::new();
467    for path in nodes.keys() {
468        let parent = match path.rfind('/') {
469            Some(i) => &path[..i],
470            None => "",
471        };
472        *child_count.entry(parent).or_insert(0) += 1;
473    }
474    let children = |path: &str| child_count.get(path).copied().unwrap_or(0);
475
476    let mtime = EPOCH_2000 as u64;
477    let mut out = Vec::new();
478    let mut inode = 0;
479    odc_record(&mut out, ".", 0o40755, 2 + children(""), inode, mtime, &[]);
480    for (path, node) in nodes {
481        inode += 1;
482        let name = format!("./{path}");
483        match node {
484            Node::Dir { mode } => {
485                let nlink = 2 + children(path.as_str());
486                odc_record(&mut out, &name, 0o40000 | mode, nlink, inode, mtime, &[]);
487            }
488            Node::File { bytes, mode } => {
489                odc_record(&mut out, &name, 0o100000 | mode, 1, inode, mtime, bytes);
490            }
491        }
492    }
493    odc_record(&mut out, "TRAILER!!!", 0, 1, inode + 1, 0, &[]);
494    let padded = out.len().div_ceil(512) * 512;
495    out.resize(padded, 0);
496    out
497}
498
499/// One odc record: 76-byte octal ASCII header, NUL-terminated name, data.
500fn odc_record(
501    out: &mut Vec<u8>,
502    name: &str,
503    mode: u32,
504    nlink: u32,
505    inode: u32,
506    mtime: u64,
507    data: &[u8],
508) {
509    let header = format!(
510        "070707{dev:06o}{inode:06o}{mode:06o}{uid:06o}{gid:06o}{nlink:06o}{rdev:06o}\
511         {mtime:011o}{namesize:06o}{filesize:011o}",
512        dev = 0,
513        uid = 0,
514        gid = 0,
515        rdev = 0,
516        namesize = name.len() + 1,
517        filesize = data.len(),
518    );
519    out.extend_from_slice(header.as_bytes());
520    out.extend_from_slice(name.as_bytes());
521    out.push(0);
522    out.extend_from_slice(data);
523}
524
525/// gzip with a pinned zero mtime and no name, for byte-stable output.
526fn gzip(data: &[u8]) -> std::io::Result<Vec<u8>> {
527    let mut encoder = flate2::GzBuilder::new()
528        .mtime(0)
529        .write(Vec::new(), Compression::default());
530    encoder.write_all(data)?;
531    encoder.finish()
532}
533
534// ---------------------------------------------------------------------------
535// PackageInfo and Distribution (quick-xml writer; shapes mirror the exact
536// output of pkgbuild/productbuild on macOS 26.5).
537
538type XmlResult = std::result::Result<Vec<u8>, std::io::Error>;
539
540fn xml_writer() -> std::result::Result<XmlWriter<Cursor<Vec<u8>>>, std::io::Error> {
541    let mut writer = XmlWriter::new_with_indent(Cursor::new(Vec::new()), b' ', 4);
542    writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("utf-8"), None)))?;
543    Ok(writer)
544}
545
546fn package_info_xml(
547    spec: &PkgSpec,
548    number_of_files: u32,
549    install_kbytes: u64,
550    hooks: &[&str],
551) -> XmlResult {
552    let mut writer = xml_writer()?;
553    writer
554        .create_element("pkg-info")
555        .with_attribute(("overwrite-permissions", "true"))
556        .with_attribute(("relocatable", "false"))
557        .with_attribute(("identifier", spec.identifier.as_str()))
558        .with_attribute(("postinstall-action", "none"))
559        .with_attribute(("version", spec.version.as_str()))
560        .with_attribute(("format-version", "2"))
561        .with_attribute(("install-location", spec.install_location.as_str()))
562        .with_attribute(("auth", "root"))
563        .write_inner_content(|w| {
564            w.create_element("payload")
565                .with_attribute(("numberOfFiles", number_of_files.to_string().as_str()))
566                .with_attribute(("installKBytes", install_kbytes.to_string().as_str()))
567                .write_empty()?;
568            for tag in [
569                "bundle-version",
570                "upgrade-bundle",
571                "update-bundle",
572                "atomic-update-bundle",
573                "strict-identifier",
574                "relocate",
575            ] {
576                w.create_element(tag).write_empty()?;
577            }
578            // pkgbuild's shape: `<scripts>` only when a hook exists, each
579            // with its default 600 s timeout.
580            if !hooks.is_empty() {
581                w.create_element("scripts").write_inner_content(|w| {
582                    for hook in hooks {
583                        w.create_element(*hook)
584                            .with_attribute(("file", format!("./{hook}").as_str()))
585                            .with_attribute(("timeout", "600"))
586                            .write_empty()?;
587                    }
588                    Ok::<(), std::io::Error>(())
589                })?;
590            }
591            Ok::<(), std::io::Error>(())
592        })?;
593    Ok(writer.into_inner().into_inner())
594}
595
596fn distribution_xml(spec: &PkgSpec, install_kbytes: u64) -> XmlResult {
597    let id = spec.identifier.as_str();
598    let mut writer = xml_writer()?;
599    writer
600        .create_element("installer-gui-script")
601        .with_attribute(("minSpecVersion", "2"))
602        .write_inner_content(|w| {
603            w.create_element("title")
604                .write_text_content(BytesText::new(&spec.display_name))?;
605            let domains = w
606                .create_element("domains")
607                .with_attribute(("enable_localSystem", "true"));
608            if spec.enable_user_home {
609                domains
610                    .with_attribute(("enable_currentUserHome", "true"))
611                    .write_empty()?;
612            } else {
613                domains.write_empty()?;
614            }
615            // Without hostArchitectures Installer assumes the payload is
616            // x86_64-only and demands Rosetta on Apple Silicon; productbuild
617            // emits exactly this options line.
618            w.create_element("options")
619                .with_attribute(("customize", "never"))
620                .with_attribute(("require-scripts", "false"))
621                .with_attribute(("hostArchitectures", "x86_64,arm64"))
622                .write_empty()?;
623            w.create_element("choices-outline")
624                .write_inner_content(|w| {
625                    w.create_element("line")
626                        .with_attribute(("choice", "default"))
627                        .write_inner_content(|w| {
628                            w.create_element("line")
629                                .with_attribute(("choice", id))
630                                .write_empty()?;
631                            Ok::<(), std::io::Error>(())
632                        })?;
633                    Ok::<(), std::io::Error>(())
634                })?;
635            w.create_element("choice")
636                .with_attribute(("id", "default"))
637                .write_empty()?;
638            w.create_element("choice")
639                .with_attribute(("id", id))
640                .with_attribute(("visible", "false"))
641                .write_inner_content(|w| {
642                    w.create_element("pkg-ref")
643                        .with_attribute(("id", id))
644                        .write_empty()?;
645                    Ok::<(), std::io::Error>(())
646                })?;
647            w.create_element("pkg-ref")
648                .with_attribute(("id", id))
649                .with_attribute(("version", spec.version.as_str()))
650                .with_attribute(("onConclusion", "none"))
651                .with_attribute(("installKBytes", install_kbytes.to_string().as_str()))
652                .with_attribute(("updateKBytes", "0"))
653                .write_text_content(BytesText::new(&format!("#{}.pkg", spec.name)))?;
654            Ok::<(), std::io::Error>(())
655        })?;
656    Ok(writer.into_inner().into_inner())
657}
658
659// ---------------------------------------------------------------------------
660// xar writer
661
662/// An entry to store in a xar archive.
663pub struct XarEntry<'a> {
664    /// Name within the parent (no `/`), e.g. `PackageInfo`.
665    pub name: &'a str,
666    pub kind: XarEntryKind<'a>,
667}
668
669pub enum XarEntryKind<'a> {
670    /// Regular file. `mode` holds the permission bits (e.g. `0o755`; only
671    /// the low 12 bits are used). With `store` set the data goes into the
672    /// heap verbatim (`application/octet-stream`) instead of
673    /// zlib-compressed — Apple stores the already-gzipped Payload that way.
674    File {
675        data: &'a [u8],
676        mode: u32,
677        store: bool,
678    },
679    /// Directory holding child entries.
680    Directory {
681        mode: u32,
682        children: Vec<XarEntry<'a>>,
683    },
684}
685
686/// Minimal TOC used to bootstrap a mutable [`TableOfContents`]: the struct's
687/// inner field is private, but `from_reader` + `DerefMut` lets us replace
688/// every part of it.
689const TOC_TEMPLATE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
690<xar><toc>
691  <creation-time>2000-01-01T00:00:00</creation-time>
692  <checksum style="sha1"><offset>0</offset><size>20</size></checksum>
693  <file id="1"><type>file</type><name>placeholder</name></file>
694</toc></xar>"#;
695
696fn hex(data: &[u8]) -> String {
697    data.iter().map(|b| format!("{b:02x}")).collect()
698}
699
700fn zlib_compress(data: &[u8]) -> std::io::Result<Vec<u8>> {
701    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
702    encoder.write_all(data)?;
703    encoder.finish()
704}
705
706fn sha1_checksum(data: &[u8]) -> Result<FileChecksum> {
707    Ok(FileChecksum {
708        style: ChecksumType::Sha1,
709        checksum: hex(&ChecksumType::Sha1.digest_data(data)?),
710    })
711}
712
713/// Write a xar archive holding `entries` (files and directories).
714///
715/// Layout matches Apple `xar(1)` defaults: SHA-1 TOC checksum at heap
716/// offset 0, zlib-compressed file data (`application/x-gzip`) following it
717/// in TOC document order. IDs are assigned in pre-order, the way
718/// productbuild numbers a product archive.
719pub fn write_xar<W: Write>(entries: &[XarEntry<'_>], mut writer: W) -> Result<()> {
720    let mut toc = TableOfContents::from_reader(TOC_TEMPLATE.as_bytes())?;
721
722    let mut heap = Vec::new();
723    let mut next_id = 1;
724    let mut files = Vec::with_capacity(entries.len());
725    for entry in entries {
726        files.push(toc_file(entry, &mut next_id, &mut heap)?);
727    }
728
729    toc.creation_time = "2000-01-01T00:00:00".to_string();
730    toc.checksum = Checksum {
731        style: ChecksumType::Sha1,
732        offset: 0,
733        size: SHA1_LEN,
734    };
735    toc.files = files;
736    toc.signature = None;
737    toc.x_signature = None;
738
739    let toc_xml = toc.to_xml()?;
740    let toc_compressed = zlib_compress(&toc_xml)?;
741    let toc_digest = ChecksumType::Sha1.digest_data(&toc_compressed)?;
742
743    let header = XarHeader {
744        magic: 0x7861_7221, // `xar!`
745        size: 28,
746        version: 1,
747        toc_length_compressed: toc_compressed.len() as u64,
748        toc_length_uncompressed: toc_xml.len() as u64,
749        checksum_algorithm_id: 1, // SHA-1
750    };
751
752    writer.iowrite_with(header, scroll::BE)?;
753    writer.write_all(&toc_compressed)?;
754    writer.write_all(&toc_digest)?;
755    writer.write_all(&heap)?;
756
757    Ok(())
758}
759
760/// Convert one entry (and, for directories, its subtree) into a TOC file
761/// record, appending file data to the heap in document order.
762fn toc_file(entry: &XarEntry<'_>, next_id: &mut u64, heap: &mut Vec<u8>) -> Result<XarTocFile> {
763    let id = *next_id;
764    *next_id += 1;
765
766    let blank = XarTocFile {
767        id,
768        ctime: None,
769        mtime: None,
770        atime: None,
771        names: vec![entry.name.to_string()],
772        file_type: FileType::File,
773        mode: None,
774        deviceno: None,
775        inode: None,
776        uid: Some(0),
777        gid: Some(0),
778        user: None,
779        group: None,
780        size: None,
781        data: None,
782        ea: None,
783        finder_create_time: None,
784        files: vec![],
785    };
786
787    match &entry.kind {
788        XarEntryKind::File { data, mode, store } => {
789            let (archived, encoding) = if *store {
790                (data.to_vec(), "application/octet-stream")
791            } else {
792                (zlib_compress(data)?, "application/x-gzip")
793            };
794            let file = XarTocFile {
795                mode: Some(format!("{:04o}", mode & 0o7777)),
796                size: Some(data.len() as u64),
797                data: Some(FileData {
798                    offset: SHA1_LEN + heap.len() as u64,
799                    size: data.len() as u64,
800                    length: archived.len() as u64,
801                    extracted_checksum: sha1_checksum(data)?,
802                    archived_checksum: sha1_checksum(&archived)?,
803                    encoding: FileEncoding {
804                        style: encoding.to_string(),
805                    },
806                }),
807                ..blank
808            };
809            heap.extend_from_slice(&archived);
810            Ok(file)
811        }
812        XarEntryKind::Directory { mode, children } => {
813            let mut files = Vec::with_capacity(children.len());
814            for child in children {
815                files.push(toc_file(child, next_id, heap)?);
816            }
817            Ok(XarTocFile {
818                file_type: FileType::Directory,
819                mode: Some(format!("{:04o}", mode & 0o7777)),
820                files,
821                ..blank
822            })
823        }
824    }
825}
826
827// ---------------------------------------------------------------------------
828// BOM writer
829
830/// Build a BOM over the in-memory payload tree, recording `uid:gid` as the
831/// owner of every entry.
832///
833/// Modes come from the tree itself rather than from a staged copy on disk:
834/// the host filesystem cannot round-trip them (Windows has no mode bits) and
835/// its path separators are not BOM path separators.
836fn bom_from_nodes(nodes: &BTreeMap<String, Node>, uid: u32, gid: u32) -> Result<Vec<u8>> {
837    let mtime = chrono::DateTime::from_timestamp(EPOCH_2000, 0).expect("fixed timestamp is valid");
838
839    let mut builder = bom_builder::BomBuilder::default();
840    builder.default_user_id(uid);
841    builder.default_group_id(gid);
842    builder.default_mtime(mtime);
843
844    // BTreeMap order already matches the BOM's sorted-path convention.
845    // Directories are added explicitly so their modes (and empty declared
846    // directories) match the cpio.
847    for (path, node) in nodes {
848        match node {
849            Node::Dir { mode } => {
850                builder.add_directory(path, (0o40000 | (mode & 0o7777)) as u16)?;
851            }
852            Node::File { bytes, mode } => {
853                let entry = builder.add_file_from_bytes(path, bytes)?;
854                entry.set_file_mode((0o100000 | (mode & 0o7777)) as u16);
855                entry.set_modified_time(mtime);
856            }
857        }
858    }
859
860    builder.build_bom()
861}
862
863/// Build a BOM over the file tree rooted at `root`, recording `uid:gid` as
864/// the owner of every entry (pkgbuild's convention is `0:0`, root:wheel).
865///
866/// Files record their on-disk permission bits (plus `S_IFREG`), size, and
867/// CRC32; directories are derived from file paths and recorded as `40755`.
868/// Output is deterministic: a fixed mtime is used and entries are sorted.
869///
870/// Unix-only: it reads mode bits off the filesystem, which is exactly what
871/// makes it a faithful oracle against `mkbom` and exactly what no other host
872/// can supply. [`build`] uses [`bom_from_nodes`] instead.
873#[cfg(unix)]
874pub fn write_bom(root: &Path, uid: u32, gid: u32) -> Result<Vec<u8>> {
875    use std::os::unix::fs::PermissionsExt;
876
877    let mtime = chrono::DateTime::from_timestamp(EPOCH_2000, 0).expect("fixed timestamp is valid");
878
879    let mut builder = bom_builder::BomBuilder::default();
880    builder.default_user_id(uid);
881    builder.default_group_id(gid);
882    builder.default_mtime(mtime);
883
884    let mut files = Vec::new();
885    collect_files(root, PathBuf::new(), &mut files)?;
886    files.sort();
887
888    for relative in files {
889        let full = root.join(&relative);
890        let mode = full.metadata()?.permissions().mode();
891
892        let entry = builder.add_file_from_path(relative.to_string_lossy(), &full)?;
893        entry.set_file_mode((0o100000 | (mode & 0o7777)) as u16);
894        entry.set_modified_time(mtime);
895    }
896
897    builder.build_bom()
898}
899
900/// Collect regular files under `dir` as paths relative to the walk root.
901#[cfg(unix)]
902fn collect_files(dir: &Path, prefix: PathBuf, files: &mut Vec<PathBuf>) -> Result<()> {
903    for entry in std::fs::read_dir(dir)? {
904        let entry = entry?;
905        let relative = prefix.join(entry.file_name());
906        let file_type = entry.file_type()?;
907
908        if file_type.is_dir() {
909            collect_files(&entry.path(), relative, files)?;
910        } else if file_type.is_file() {
911            files.push(relative);
912        }
913    }
914
915    Ok(())
916}
917
918#[cfg(test)]
919mod tests {
920    use super::looks_executable;
921
922    /// The mode a Windows-hosted build assigns is decided entirely by this
923    /// sniff — on a host with no exec bit to read, a miss here ships a
924    /// `.pkg` whose binary installs non-executable.
925    #[test]
926    fn sniff_accepts_mach_o_and_scripts_only() {
927        // Mach-O 64-bit little-endian (the shape every arm64 binary has).
928        assert!(looks_executable(&[0xcf, 0xfa, 0xed, 0xfe]));
929        // Universal binary.
930        assert!(looks_executable(&[0xca, 0xfe, 0xba, 0xbe]));
931        assert!(looks_executable(b"#!/bin/sh\necho hi\n"));
932
933        assert!(!looks_executable(b"read me\n"));
934        assert!(!looks_executable(b"#"), "a lone '#' is not a shebang");
935        assert!(!looks_executable(b""));
936    }
937}