pkgedit
Build macOS flat installer packages (.pkg) from any Unix host — pure Rust,
deterministic, no Apple tooling. The installer-side sibling of machoedit /
elfedit.
A flat component package is a xar archive of four members:
PackageInfo— XML metadata (identifier, version, install-location, scripts);Bom— the Bill Of Materialsinstallerreads to lay down and receipt the files (bomutils-compatible binary format, independent impl);Payload— gzip’d cpio (odc) of the installed tree;Scripts— gzip’d cpio ofpreinstall/postinstall(optional).
Every field that could vary by host is pinned — uid/gid 0 (root:wheel),
mtime 0, fixed member order, SHA-1/CRC-32 hand-rolled (hash) —
so the .pkg is byte-identical whether built on the Linux backend or natively
on macOS, hence content-addressable like the rest of the store. There is no
pkgbuild-on-macOS-vs-bomutils-on-Linux fork: one code path, both hosts.
let pkg = pkgedit::Package::builder("fr.elpe.elpe", "0.1.0", "/opt/elpe")
.payload_dir("payload") // payload/bin/elpe -> /opt/elpe/bin/elpe
.postinstall("#!/bin/sh\nmkdir -p /opt/elpe\n…")
.build()?;
std::fs::write("elpe.pkg", pkg)?;
or cargo run --example pack -- <payload-dir> elpe.pkg.
Install model
Unsigned is sufficient for the two target channels:
- Corporate MDM (Jamf/Kandji/Mosyle/Munki/Fleet): the management channel is
trusted, so an unsigned
.pkgdeploys as a policy — no Developer ID, no Gatekeeper prompt, SIP stays on. - AWS EC2 Mac:
sudo installer -pkg elpe.pkg -target /headless.
/opt is a firmlink on stock macOS, so the store lives at /opt/elpe with
no synthetic.conf, dedicated APFS volume, or reboot (unlike the Nix/Determinate
approach). The store is user-owned, no daemon (Homebrew model); the sole
privileged step is the postinstall mkdir /opt/elpe && chown.
Developer ID signing + notarisation (only needed for open, non-MDM distribution)
is done from Linux by the companion script apple-notarize.sh
(rcodesign + the App Store Connect API): it mints a Developer ID Application
certificate via the ASC API, Dev-ID-signs Mach-O with hardened runtime, and
notary-submit --wait --staples — no Mac, no Xcode. Config lives in a
apple.env (team id, issuer id, key id, .p8). The one
thing it can’t create from Linux is the Developer ID Installer cert (not an
ASC API type) — make that once via the web portal to sign the .pkg wrapper;
the Mach-O signing + notarisation that a distributable app needs is fully covered.
Scope / limits
- BOM paths form a real B-tree: leaves of ≤
MAX_LEAF(256) (info1, file) pairs chained via forward/backward, under one branch node with one entry per leaf (the bomutils/mkbomconvention —lsbom/installeriterate branch entries to reach every leaf). Large payloads (the full darwin toolchain + deps bundle, tens of thousands of files) pack fine and read back byte-complete under real macOSlsbom/pkgutil; a payload within one leaf points the tree straight at it. - Members stored uncompressed at the xar level (the cpio streams are already gzip’d); no per-file xar compression.
- No signing (see above).