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`, and a gzipped odc-cpio `Payload`. Every layer
6//! was pinned against Apple tooling on macOS 26.5 (pkgbuild/productbuild
7//! output dissected byte-by-byte; results verified with `pkgutil
8//! --expand-full`, `lsbom`, and `installer`).
9//!
10//! Lower-level pieces are public for reuse and testing: [`write_xar`]
11//! (xar archives with nested directories) and [`write_bom`] (Bill of
12//! Materials over a staged tree).
13//!
14//! Spike outcomes the implementation rests on:
15//!
16//! - **xar**: `apple-xar` 0.20 has no turn-key writer, but its primitives
17//!   compose: `TableOfContents`/`File`/`FileData` are public with
18//!   `to_xml()`, `ChecksumType::digest_data` provides digests, and
19//!   `XarHeader` derives scroll `IOwrite`. The only wrinkle is that
20//!   `TableOfContents`'s inner field is private with no constructor, so we
21//!   bootstrap one by parsing a minimal template TOC via `from_reader` and
22//!   mutating it through its `DerefMut<Target = XarToC>` impl. No
23//!   hand-rolled writer needed.
24//! - **BOM**: `apple-bom` 0.3's shipped `BomBuilder` is unusable — its
25//!   `build_bom()` panics unconditionally, and beneath that lie a dozen
26//!   more writer bugs (wrong checksum algorithm, wrong B-tree order,
27//!   missing free list, ...; see `bom_builder.rs` for the catalog).
28//!   Bounded fallback taken: `bom_builder` ports the assembly on top of
29//!   apple-bom's public `format` types with those fixes; the output
30//!   matches both `mkbom` and pkgbuild's embedded `Bom` under
31//!   `lsbom -p fmugsc` (paths, modes, uid, gid, size, cksum). Per-file
32//!   modes must include the file-type bits (e.g. `0o100755`).
33//! - **cpio**: the Payload is odc/ASCII cpio (magic `070707`), hand-rolled
34//!   here because the format is a 76-byte octal header and pkgbuild's
35//!   conventions (sequential inodes, dir nlink = 2 + children, zero pad to
36//!   a 512-byte boundary) need exact control no crate offered.
37
38use std::{
39    collections::{BTreeMap, BTreeSet},
40    io::{Cursor, Write},
41    path::{Path, PathBuf},
42};
43
44use apple_xar::{
45    format::XarHeader,
46    table_of_contents::{
47        Checksum, ChecksumType, File as XarTocFile, FileChecksum, FileData, FileEncoding, FileType,
48        TableOfContents,
49    },
50};
51use flate2::{Compression, write::ZlibEncoder};
52use quick_xml::{
53    Writer as XmlWriter,
54    events::{BytesDecl, BytesText, Event},
55};
56use scroll::IOwrite;
57
58mod bom_builder;
59
60#[derive(Debug, thiserror::Error)]
61pub enum Error {
62    #[error("I/O error: {0}")]
63    Io(#[from] std::io::Error),
64
65    #[error("xar error: {0}")]
66    Xar(#[from] apple_xar::Error),
67
68    #[error("BOM error: {0}")]
69    Bom(#[from] apple_bom::Error),
70
71    #[error("invalid BOM path: {0}")]
72    BadBomPath(String),
73
74    #[error("pkg: no files to package")]
75    NoFiles,
76
77    #[error("pkg dest {0:?} must be a relative /-separated path without empty or '..' segments")]
78    InvalidDest(String),
79
80    #[error("pkg: two files share the dest {0:?}")]
81    DuplicateDest(String),
82
83    #[error("pkg dest {0:?} is both a file and a directory of another dest")]
84    DestConflict(String),
85
86    #[error("pkg install-location {0:?} must be an absolute path")]
87    InvalidInstallLocation(String),
88}
89
90pub type Result<T> = std::result::Result<T, Error>;
91
92/// Fixed timestamp used for deterministic output (2000-01-01T00:00:00Z).
93const EPOCH_2000: i64 = 946_684_800;
94
95/// SHA-1 digest length; the xar TOC checksum occupies heap `[0, 20)`.
96const SHA1_LEN: u64 = 20;
97
98/// A payload file: `src` on disk (already resolved to a real path), `dest`
99/// the `/`-separated path relative to the install location.
100#[derive(Debug, Clone)]
101pub struct FileSpec {
102    pub src: PathBuf,
103    pub dest: String,
104}
105
106/// Everything needed to author one product archive. Deliberately independent
107/// of the embala config types so the crate is usable on its own.
108#[derive(Debug, Clone)]
109pub struct PkgSpec {
110    /// Package name; names the artifact and the inner `<name>.pkg` component.
111    pub name: String,
112    /// Human name: the Distribution `<title>` Installer.app displays.
113    pub display_name: String,
114    /// Reverse-DNS package identifier (`pkgutil` receipt id).
115    pub identifier: String,
116    pub version: String,
117    /// Absolute path the payload tree installs under (e.g. `/usr/local`).
118    pub install_location: String,
119    /// Adds `enable_currentUserHome` to the Distribution's `<domains>` so
120    /// `installer -target CurrentUserHomeDirectory` is legal (the sudo-free
121    /// install path).
122    pub enable_user_home: bool,
123    pub files: Vec<FileSpec>,
124}
125
126/// One node of the payload tree, keyed by `/`-separated relative path.
127enum Node {
128    Dir,
129    File { bytes: Vec<u8>, mode: u32 },
130}
131
132/// Build the product-archive `.pkg` described by `spec` at `out` (parent
133/// dirs are created). Output is deterministic: fixed timestamps everywhere
134/// (xar TOC, cpio, gzip) and sorted payload entries.
135pub fn build(spec: &PkgSpec, out: &Path) -> Result<()> {
136    validate(spec)?;
137
138    // Payload tree model: relative path -> node, sorted (BTreeMap order
139    // matches pkgbuild's cpio/BOM ordering: parents before children).
140    let mut nodes: BTreeMap<String, Node> = BTreeMap::new();
141    for file in &spec.files {
142        for ancestor in ancestors(&file.dest) {
143            nodes.insert(ancestor.to_string(), Node::Dir);
144        }
145        let bytes = std::fs::read(&file.src)?;
146        let mode = if executable(&file.src)? { 0o755 } else { 0o644 };
147        nodes.insert(file.dest.clone(), Node::File { bytes, mode });
148    }
149
150    // pkg-info bookkeeping. numberOfFiles counts every BOM path including
151    // the root ".". installKBytes uses pkgbuild's rule (pinned empirically
152    // against eight pkgbuild runs): every file occupies ceil(size/512)
153    // 512-byte blocks, every directory except the root occupies one block,
154    // and the total is rounded up to whole KiB.
155    let mut blocks = 0u64;
156    for node in nodes.values() {
157        blocks += match node {
158            Node::Dir => 1,
159            Node::File { bytes, .. } => (bytes.len() as u64).div_ceil(512),
160        };
161    }
162    let number_of_files = 1 + nodes.len() as u32;
163    let install_kbytes = blocks.div_ceil(2);
164
165    // Stage the tree on disk for the BOM writer (root:wheel, 0:0).
166    let staging = tempfile::tempdir()?;
167    stage_tree(staging.path(), &nodes)?;
168    let bom = write_bom(staging.path(), 0, 0)?;
169
170    let payload = gzip(&payload_cpio(&nodes))?;
171    let package_info = package_info_xml(spec, number_of_files, install_kbytes)?;
172    let distribution = distribution_xml(spec, install_kbytes)?;
173
174    // Product-archive layout, mirroring productbuild: the component
175    // directory first (children in Apple's order: Bom, Payload,
176    // PackageInfo), Distribution last. The Payload is already gzipped, so
177    // it is stored raw (application/octet-stream) exactly as Apple does.
178    let component = format!("{}.pkg", spec.name);
179    let entries = [
180        XarEntry {
181            name: &component,
182            kind: XarEntryKind::Directory {
183                mode: 0o700,
184                children: vec![
185                    XarEntry {
186                        name: "Bom",
187                        kind: XarEntryKind::File {
188                            data: &bom,
189                            mode: 0o644,
190                            store: false,
191                        },
192                    },
193                    XarEntry {
194                        name: "Payload",
195                        kind: XarEntryKind::File {
196                            data: &payload,
197                            mode: 0o644,
198                            store: true,
199                        },
200                    },
201                    XarEntry {
202                        name: "PackageInfo",
203                        kind: XarEntryKind::File {
204                            data: &package_info,
205                            mode: 0o644,
206                            store: false,
207                        },
208                    },
209                ],
210            },
211        },
212        XarEntry {
213            name: "Distribution",
214            kind: XarEntryKind::File {
215                data: &distribution,
216                mode: 0o644,
217                store: false,
218            },
219        },
220    ];
221
222    if let Some(parent) = out.parent() {
223        if !parent.as_os_str().is_empty() {
224            std::fs::create_dir_all(parent)?;
225        }
226    }
227    let mut archive = Vec::new();
228    write_xar(&entries, &mut archive)?;
229    std::fs::write(out, archive)?;
230    Ok(())
231}
232
233fn validate(spec: &PkgSpec) -> Result<()> {
234    if spec.files.is_empty() {
235        return Err(Error::NoFiles);
236    }
237    if !spec.install_location.starts_with('/') {
238        return Err(Error::InvalidInstallLocation(spec.install_location.clone()));
239    }
240    let mut seen = BTreeSet::new();
241    for file in &spec.files {
242        if file.dest.split('/').any(|s| s.is_empty() || s == "..") {
243            return Err(Error::InvalidDest(file.dest.clone()));
244        }
245        if !seen.insert(file.dest.as_str()) {
246            return Err(Error::DuplicateDest(file.dest.clone()));
247        }
248    }
249    let dirs: BTreeSet<&str> = spec.files.iter().flat_map(|f| ancestors(&f.dest)).collect();
250    for file in &spec.files {
251        if dirs.contains(file.dest.as_str()) {
252            return Err(Error::DestConflict(file.dest.clone()));
253        }
254    }
255    Ok(())
256}
257
258/// Proper ancestors of a `/`-separated relative path: `"a/b/c"` -> `a`, `a/b`.
259fn ancestors(dest: &str) -> impl Iterator<Item = &str> {
260    dest.char_indices()
261        .filter(|(_, c)| *c == '/')
262        .map(|(i, _)| &dest[..i])
263}
264
265fn executable(path: &Path) -> Result<bool> {
266    use std::os::unix::fs::PermissionsExt;
267    Ok(std::fs::metadata(path)?.permissions().mode() & 0o111 != 0)
268}
269
270fn stage_tree(root: &Path, nodes: &BTreeMap<String, Node>) -> Result<()> {
271    use std::os::unix::fs::PermissionsExt;
272    for (path, node) in nodes {
273        let full = root.join(path);
274        match node {
275            Node::Dir => std::fs::create_dir_all(&full)?,
276            Node::File { bytes, mode } => {
277                std::fs::write(&full, bytes)?;
278                std::fs::set_permissions(&full, std::fs::Permissions::from_mode(*mode))?;
279            }
280        }
281    }
282    Ok(())
283}
284
285// ---------------------------------------------------------------------------
286// Payload: gzipped odc/ASCII cpio, matching pkgbuild's conventions.
287
288/// Serialize the payload tree as odc cpio the way pkgbuild does: entries
289/// `.`/`./path` in sorted order, ownership 0:0, sequential inode numbers,
290/// directory nlink = 2 + direct children, fixed mtime, `TRAILER!!!`
291/// terminator (mtime 0), zero padding to a 512-byte boundary.
292fn payload_cpio(nodes: &BTreeMap<String, Node>) -> Vec<u8> {
293    // Direct-child counts drive the directory nlink values ("" = root).
294    let mut child_count: BTreeMap<&str, u32> = BTreeMap::new();
295    for path in nodes.keys() {
296        let parent = match path.rfind('/') {
297            Some(i) => &path[..i],
298            None => "",
299        };
300        *child_count.entry(parent).or_insert(0) += 1;
301    }
302    let children = |path: &str| child_count.get(path).copied().unwrap_or(0);
303
304    let mtime = EPOCH_2000 as u64;
305    let mut out = Vec::new();
306    let mut inode = 0;
307    odc_record(&mut out, ".", 0o40755, 2 + children(""), inode, mtime, &[]);
308    for (path, node) in nodes {
309        inode += 1;
310        let name = format!("./{path}");
311        match node {
312            Node::Dir => {
313                let nlink = 2 + children(path.as_str());
314                odc_record(&mut out, &name, 0o40755, nlink, inode, mtime, &[]);
315            }
316            Node::File { bytes, mode } => {
317                odc_record(&mut out, &name, 0o100000 | mode, 1, inode, mtime, bytes);
318            }
319        }
320    }
321    odc_record(&mut out, "TRAILER!!!", 0, 1, inode + 1, 0, &[]);
322    let padded = out.len().div_ceil(512) * 512;
323    out.resize(padded, 0);
324    out
325}
326
327/// One odc record: 76-byte octal ASCII header, NUL-terminated name, data.
328fn odc_record(
329    out: &mut Vec<u8>,
330    name: &str,
331    mode: u32,
332    nlink: u32,
333    inode: u32,
334    mtime: u64,
335    data: &[u8],
336) {
337    let header = format!(
338        "070707{dev:06o}{inode:06o}{mode:06o}{uid:06o}{gid:06o}{nlink:06o}{rdev:06o}\
339         {mtime:011o}{namesize:06o}{filesize:011o}",
340        dev = 0,
341        uid = 0,
342        gid = 0,
343        rdev = 0,
344        namesize = name.len() + 1,
345        filesize = data.len(),
346    );
347    out.extend_from_slice(header.as_bytes());
348    out.extend_from_slice(name.as_bytes());
349    out.push(0);
350    out.extend_from_slice(data);
351}
352
353/// gzip with a pinned zero mtime and no name, for byte-stable output.
354fn gzip(data: &[u8]) -> std::io::Result<Vec<u8>> {
355    let mut encoder = flate2::GzBuilder::new()
356        .mtime(0)
357        .write(Vec::new(), Compression::default());
358    encoder.write_all(data)?;
359    encoder.finish()
360}
361
362// ---------------------------------------------------------------------------
363// PackageInfo and Distribution (quick-xml writer; shapes mirror the exact
364// output of pkgbuild/productbuild on macOS 26.5).
365
366type XmlResult = std::result::Result<Vec<u8>, std::io::Error>;
367
368fn xml_writer() -> std::result::Result<XmlWriter<Cursor<Vec<u8>>>, std::io::Error> {
369    let mut writer = XmlWriter::new_with_indent(Cursor::new(Vec::new()), b' ', 4);
370    writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("utf-8"), None)))?;
371    Ok(writer)
372}
373
374fn package_info_xml(spec: &PkgSpec, number_of_files: u32, install_kbytes: u64) -> XmlResult {
375    let mut writer = xml_writer()?;
376    writer
377        .create_element("pkg-info")
378        .with_attribute(("overwrite-permissions", "true"))
379        .with_attribute(("relocatable", "false"))
380        .with_attribute(("identifier", spec.identifier.as_str()))
381        .with_attribute(("postinstall-action", "none"))
382        .with_attribute(("version", spec.version.as_str()))
383        .with_attribute(("format-version", "2"))
384        .with_attribute(("install-location", spec.install_location.as_str()))
385        .with_attribute(("auth", "root"))
386        .write_inner_content(|w| {
387            w.create_element("payload")
388                .with_attribute(("numberOfFiles", number_of_files.to_string().as_str()))
389                .with_attribute(("installKBytes", install_kbytes.to_string().as_str()))
390                .write_empty()?;
391            for tag in [
392                "bundle-version",
393                "upgrade-bundle",
394                "update-bundle",
395                "atomic-update-bundle",
396                "strict-identifier",
397                "relocate",
398            ] {
399                w.create_element(tag).write_empty()?;
400            }
401            Ok::<(), std::io::Error>(())
402        })?;
403    Ok(writer.into_inner().into_inner())
404}
405
406fn distribution_xml(spec: &PkgSpec, install_kbytes: u64) -> XmlResult {
407    let id = spec.identifier.as_str();
408    let mut writer = xml_writer()?;
409    writer
410        .create_element("installer-gui-script")
411        .with_attribute(("minSpecVersion", "2"))
412        .write_inner_content(|w| {
413            w.create_element("title")
414                .write_text_content(BytesText::new(&spec.display_name))?;
415            let domains = w
416                .create_element("domains")
417                .with_attribute(("enable_localSystem", "true"));
418            if spec.enable_user_home {
419                domains
420                    .with_attribute(("enable_currentUserHome", "true"))
421                    .write_empty()?;
422            } else {
423                domains.write_empty()?;
424            }
425            // Without hostArchitectures Installer assumes the payload is
426            // x86_64-only and demands Rosetta on Apple Silicon; productbuild
427            // emits exactly this options line.
428            w.create_element("options")
429                .with_attribute(("customize", "never"))
430                .with_attribute(("require-scripts", "false"))
431                .with_attribute(("hostArchitectures", "x86_64,arm64"))
432                .write_empty()?;
433            w.create_element("choices-outline")
434                .write_inner_content(|w| {
435                    w.create_element("line")
436                        .with_attribute(("choice", "default"))
437                        .write_inner_content(|w| {
438                            w.create_element("line")
439                                .with_attribute(("choice", id))
440                                .write_empty()?;
441                            Ok::<(), std::io::Error>(())
442                        })?;
443                    Ok::<(), std::io::Error>(())
444                })?;
445            w.create_element("choice")
446                .with_attribute(("id", "default"))
447                .write_empty()?;
448            w.create_element("choice")
449                .with_attribute(("id", id))
450                .with_attribute(("visible", "false"))
451                .write_inner_content(|w| {
452                    w.create_element("pkg-ref")
453                        .with_attribute(("id", id))
454                        .write_empty()?;
455                    Ok::<(), std::io::Error>(())
456                })?;
457            w.create_element("pkg-ref")
458                .with_attribute(("id", id))
459                .with_attribute(("version", spec.version.as_str()))
460                .with_attribute(("onConclusion", "none"))
461                .with_attribute(("installKBytes", install_kbytes.to_string().as_str()))
462                .with_attribute(("updateKBytes", "0"))
463                .write_text_content(BytesText::new(&format!("#{}.pkg", spec.name)))?;
464            Ok::<(), std::io::Error>(())
465        })?;
466    Ok(writer.into_inner().into_inner())
467}
468
469// ---------------------------------------------------------------------------
470// xar writer
471
472/// An entry to store in a xar archive.
473pub struct XarEntry<'a> {
474    /// Name within the parent (no `/`), e.g. `PackageInfo`.
475    pub name: &'a str,
476    pub kind: XarEntryKind<'a>,
477}
478
479pub enum XarEntryKind<'a> {
480    /// Regular file. `mode` holds the permission bits (e.g. `0o755`; only
481    /// the low 12 bits are used). With `store` set the data goes into the
482    /// heap verbatim (`application/octet-stream`) instead of
483    /// zlib-compressed — Apple stores the already-gzipped Payload that way.
484    File {
485        data: &'a [u8],
486        mode: u32,
487        store: bool,
488    },
489    /// Directory holding child entries.
490    Directory {
491        mode: u32,
492        children: Vec<XarEntry<'a>>,
493    },
494}
495
496/// Minimal TOC used to bootstrap a mutable [`TableOfContents`]: the struct's
497/// inner field is private, but `from_reader` + `DerefMut` lets us replace
498/// every part of it.
499const TOC_TEMPLATE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
500<xar><toc>
501  <creation-time>2000-01-01T00:00:00</creation-time>
502  <checksum style="sha1"><offset>0</offset><size>20</size></checksum>
503  <file id="1"><type>file</type><name>placeholder</name></file>
504</toc></xar>"#;
505
506fn hex(data: &[u8]) -> String {
507    data.iter().map(|b| format!("{b:02x}")).collect()
508}
509
510fn zlib_compress(data: &[u8]) -> std::io::Result<Vec<u8>> {
511    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
512    encoder.write_all(data)?;
513    encoder.finish()
514}
515
516fn sha1_checksum(data: &[u8]) -> Result<FileChecksum> {
517    Ok(FileChecksum {
518        style: ChecksumType::Sha1,
519        checksum: hex(&ChecksumType::Sha1.digest_data(data)?),
520    })
521}
522
523/// Write a xar archive holding `entries` (files and directories).
524///
525/// Layout matches Apple `xar(1)` defaults: SHA-1 TOC checksum at heap
526/// offset 0, zlib-compressed file data (`application/x-gzip`) following it
527/// in TOC document order. IDs are assigned in pre-order, the way
528/// productbuild numbers a product archive.
529pub fn write_xar<W: Write>(entries: &[XarEntry<'_>], mut writer: W) -> Result<()> {
530    let mut toc = TableOfContents::from_reader(TOC_TEMPLATE.as_bytes())?;
531
532    let mut heap = Vec::new();
533    let mut next_id = 1;
534    let mut files = Vec::with_capacity(entries.len());
535    for entry in entries {
536        files.push(toc_file(entry, &mut next_id, &mut heap)?);
537    }
538
539    toc.creation_time = "2000-01-01T00:00:00".to_string();
540    toc.checksum = Checksum {
541        style: ChecksumType::Sha1,
542        offset: 0,
543        size: SHA1_LEN,
544    };
545    toc.files = files;
546    toc.signature = None;
547    toc.x_signature = None;
548
549    let toc_xml = toc.to_xml()?;
550    let toc_compressed = zlib_compress(&toc_xml)?;
551    let toc_digest = ChecksumType::Sha1.digest_data(&toc_compressed)?;
552
553    let header = XarHeader {
554        magic: 0x7861_7221, // `xar!`
555        size: 28,
556        version: 1,
557        toc_length_compressed: toc_compressed.len() as u64,
558        toc_length_uncompressed: toc_xml.len() as u64,
559        checksum_algorithm_id: 1, // SHA-1
560    };
561
562    writer.iowrite_with(header, scroll::BE)?;
563    writer.write_all(&toc_compressed)?;
564    writer.write_all(&toc_digest)?;
565    writer.write_all(&heap)?;
566
567    Ok(())
568}
569
570/// Convert one entry (and, for directories, its subtree) into a TOC file
571/// record, appending file data to the heap in document order.
572fn toc_file(entry: &XarEntry<'_>, next_id: &mut u64, heap: &mut Vec<u8>) -> Result<XarTocFile> {
573    let id = *next_id;
574    *next_id += 1;
575
576    let blank = XarTocFile {
577        id,
578        ctime: None,
579        mtime: None,
580        atime: None,
581        names: vec![entry.name.to_string()],
582        file_type: FileType::File,
583        mode: None,
584        deviceno: None,
585        inode: None,
586        uid: Some(0),
587        gid: Some(0),
588        user: None,
589        group: None,
590        size: None,
591        data: None,
592        ea: None,
593        finder_create_time: None,
594        files: vec![],
595    };
596
597    match &entry.kind {
598        XarEntryKind::File { data, mode, store } => {
599            let (archived, encoding) = if *store {
600                (data.to_vec(), "application/octet-stream")
601            } else {
602                (zlib_compress(data)?, "application/x-gzip")
603            };
604            let file = XarTocFile {
605                mode: Some(format!("{:04o}", mode & 0o7777)),
606                size: Some(data.len() as u64),
607                data: Some(FileData {
608                    offset: SHA1_LEN + heap.len() as u64,
609                    size: data.len() as u64,
610                    length: archived.len() as u64,
611                    extracted_checksum: sha1_checksum(data)?,
612                    archived_checksum: sha1_checksum(&archived)?,
613                    encoding: FileEncoding {
614                        style: encoding.to_string(),
615                    },
616                }),
617                ..blank
618            };
619            heap.extend_from_slice(&archived);
620            Ok(file)
621        }
622        XarEntryKind::Directory { mode, children } => {
623            let mut files = Vec::with_capacity(children.len());
624            for child in children {
625                files.push(toc_file(child, next_id, heap)?);
626            }
627            Ok(XarTocFile {
628                file_type: FileType::Directory,
629                mode: Some(format!("{:04o}", mode & 0o7777)),
630                files,
631                ..blank
632            })
633        }
634    }
635}
636
637// ---------------------------------------------------------------------------
638// BOM writer
639
640/// Build a BOM over the file tree rooted at `root`, recording `uid:gid` as
641/// the owner of every entry (pkgbuild's convention is `0:0`, root:wheel).
642///
643/// Files record their on-disk permission bits (plus `S_IFREG`), size, and
644/// CRC32; directories are derived from file paths and recorded as `40755`.
645/// Output is deterministic: a fixed mtime is used and entries are sorted.
646pub fn write_bom(root: &Path, uid: u32, gid: u32) -> Result<Vec<u8>> {
647    use std::os::unix::fs::PermissionsExt;
648
649    let mtime = chrono::DateTime::from_timestamp(EPOCH_2000, 0).expect("fixed timestamp is valid");
650
651    let mut builder = bom_builder::BomBuilder::default();
652    builder.default_user_id(uid);
653    builder.default_group_id(gid);
654    builder.default_mtime(mtime);
655
656    let mut files = Vec::new();
657    collect_files(root, PathBuf::new(), &mut files)?;
658    files.sort();
659
660    for relative in files {
661        let full = root.join(&relative);
662        let mode = full.metadata()?.permissions().mode();
663
664        let entry = builder.add_file_from_path(relative.to_string_lossy(), &full)?;
665        entry.set_file_mode((0o100000 | (mode & 0o7777)) as u16);
666        entry.set_modified_time(mtime);
667    }
668
669    builder.build_bom()
670}
671
672/// Collect regular files under `dir` as paths relative to the walk root.
673fn collect_files(dir: &Path, prefix: PathBuf, files: &mut Vec<PathBuf>) -> Result<()> {
674    for entry in std::fs::read_dir(dir)? {
675        let entry = entry?;
676        let relative = prefix.join(entry.file_name());
677        let file_type = entry.file_type()?;
678
679        if file_type.is_dir() {
680            collect_files(&entry.path(), relative, files)?;
681        } else if file_type.is_file() {
682            files.push(relative);
683        }
684    }
685
686    Ok(())
687}