1use 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
92const EPOCH_2000: i64 = 946_684_800;
94
95const SHA1_LEN: u64 = 20;
97
98#[derive(Debug, Clone)]
101pub struct FileSpec {
102 pub src: PathBuf,
103 pub dest: String,
104}
105
106#[derive(Debug, Clone)]
109pub struct PkgSpec {
110 pub name: String,
112 pub display_name: String,
114 pub identifier: String,
116 pub version: String,
117 pub install_location: String,
119 pub enable_user_home: bool,
123 pub files: Vec<FileSpec>,
124}
125
126enum Node {
128 Dir,
129 File { bytes: Vec<u8>, mode: u32 },
130}
131
132pub fn build(spec: &PkgSpec, out: &Path) -> Result<()> {
136 validate(spec)?;
137
138 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 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 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 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
258fn 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
285fn payload_cpio(nodes: &BTreeMap<String, Node>) -> Vec<u8> {
293 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
327fn 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
353fn 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
362type 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 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
469pub struct XarEntry<'a> {
474 pub name: &'a str,
476 pub kind: XarEntryKind<'a>,
477}
478
479pub enum XarEntryKind<'a> {
480 File {
485 data: &'a [u8],
486 mode: u32,
487 store: bool,
488 },
489 Directory {
491 mode: u32,
492 children: Vec<XarEntry<'a>>,
493 },
494}
495
496const 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
523pub 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, 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, };
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
570fn 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
637pub 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
672fn 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}