1use 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
99const EPOCH_2000: i64 = 946_684_800;
101
102const SHA1_LEN: u64 = 20;
104
105#[derive(Debug, Clone)]
108pub struct FileSpec {
109 pub src: PathBuf,
110 pub dest: String,
111}
112
113#[derive(Debug, Clone)]
116pub struct PkgSpec {
117 pub name: String,
119 pub display_name: String,
121 pub identifier: String,
123 pub version: String,
124 pub install_location: String,
126 pub enable_user_home: bool,
130 pub files: Vec<FileSpec>,
131 pub scripts: Option<PathBuf>,
135}
136
137enum Node {
139 Dir,
140 File { bytes: Vec<u8>, mode: u32 },
141}
142
143pub fn build(spec: &PkgSpec, out: &Path) -> Result<()> {
147 validate(spec)?;
148
149 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 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 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 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 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
293fn 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 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
336fn ancestors(dest: &str) -> impl Iterator<Item = &str> {
338 dest.char_indices()
339 .filter(|(_, c)| *c == '/')
340 .map(|(i, _)| &dest[..i])
341}
342
343#[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#[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#[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 0xfeed_face | 0xcefa_edfe | 0xfeed_facf | 0xcffa_edfe | 0xcafe_babe | 0xbeba_feca
394 )
395}
396
397fn payload_cpio(nodes: &BTreeMap<String, Node>) -> Vec<u8> {
405 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
439fn 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
465fn 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
474type 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 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 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
599pub struct XarEntry<'a> {
604 pub name: &'a str,
606 pub kind: XarEntryKind<'a>,
607}
608
609pub enum XarEntryKind<'a> {
610 File {
615 data: &'a [u8],
616 mode: u32,
617 store: bool,
618 },
619 Directory {
621 mode: u32,
622 children: Vec<XarEntry<'a>>,
623 },
624}
625
626const 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
653pub 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, 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, };
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
700fn 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
767fn 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 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#[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#[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 #[test]
861 fn sniff_accepts_mach_o_and_scripts_only() {
862 assert!(looks_executable(&[0xcf, 0xfa, 0xed, 0xfe]));
864 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}