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