From ce5c96678dec93ca71c3ba4d58ae4e735fcbc625 Mon Sep 17 00:00:00 2001 From: Long Ho Date: Sun, 20 Sep 2026 21:32:18 -0400 Subject: [PATCH] Support macOS installer package payloads --- src/artifact_location.rs | 1 + src/fetch_method.rs | 14 ++- src/print_entry_for_url.rs | 3 + src/util/unarchive.rs | 194 ++++++++++++++++++++++++++++++++++ website/docs/dotslash-file.md | 10 ++ 5 files changed, 219 insertions(+), 3 deletions(-) diff --git a/src/artifact_location.rs b/src/artifact_location.rs index 7ae47cd..ec4c75c 100644 --- a/src/artifact_location.rs +++ b/src/artifact_location.rs @@ -133,6 +133,7 @@ fn create_key_for_format(format: ArtifactFormat, path: &ArtifactPath) -> Cow<'st ArtifactFormat::TarXz => Cow::Borrowed("tar.xz"), ArtifactFormat::TarZstd => Cow::Borrowed("tar.zst"), ArtifactFormat::Zip => Cow::Borrowed("zip"), + ArtifactFormat::Pkg => Cow::Borrowed("pkg"), } } diff --git a/src/fetch_method.rs b/src/fetch_method.rs index 14fa0e0..52c98df 100644 --- a/src/fetch_method.rs +++ b/src/fetch_method.rs @@ -50,6 +50,9 @@ pub enum ArtifactFormat { #[serde(rename = "zip")] Zip, + + #[serde(rename = "pkg")] + Pkg, } impl ArtifactFormat { @@ -67,6 +70,7 @@ impl ArtifactFormat { Self::TarXz => Some(ArchiveType::TarXz), Self::TarZstd => Some(ArchiveType::TarZstd), Self::Zip => Some(ArchiveType::Zip), + Self::Pkg => Some(ArchiveType::Pkg), } } @@ -74,9 +78,13 @@ impl ArtifactFormat { pub fn is_container(self) -> bool { match self { Self::Plain | Self::Bzip2 | Self::Gz | Self::Xz | Self::Zstd => false, - Self::Tar | Self::TarBzip2 | Self::TarGz | Self::TarXz | Self::TarZstd | Self::Zip => { - true - } + Self::Tar + | Self::TarBzip2 + | Self::TarGz + | Self::TarXz + | Self::TarZstd + | Self::Zip + | Self::Pkg => true, } } } diff --git a/src/print_entry_for_url.rs b/src/print_entry_for_url.rs index 06dad17..bd4d1a0 100644 --- a/src/print_entry_for_url.rs +++ b/src/print_entry_for_url.rs @@ -96,6 +96,8 @@ fn guess_artifact_format_from_url(url: &[u8]) -> ArtifactFormat { ArtifactFormat::Xz } else if url.ends_with(b".zst") { ArtifactFormat::Zstd + } else if url.ends_with(b".pkg") { + ArtifactFormat::Pkg } else { ArtifactFormat::Plain } @@ -174,6 +176,7 @@ mod tests { test("http://example.com/foo.gz", ArtifactFormat::Gz); test("http://example.com/foo.zip", ArtifactFormat::Zip); test("http://example.com/foo.zst", ArtifactFormat::Zstd); + test("http://example.com/foo.pkg", ArtifactFormat::Pkg); // These "backwards" extensions are interpreted as Tar. test("http://example.com/foo.zst.tar", ArtifactFormat::Tar); diff --git a/src/util/unarchive.rs b/src/util/unarchive.rs index d30f346..8ebc492 100644 --- a/src/util/unarchive.rs +++ b/src/util/unarchive.rs @@ -43,6 +43,8 @@ pub enum ArchiveType { TarZstd, #[cfg(not(dotslash_internal))] Zip, + #[cfg(not(dotslash_internal))] + Pkg, } /// Attempts to extract the tar/zip archive into the specified directory @@ -82,6 +84,9 @@ where archive.extract(destination)?; Ok(()) } + + #[cfg(not(dotslash_internal))] + ArchiveType::Pkg => unpack_pkg(reader, destination), } } @@ -95,6 +100,77 @@ where Ok(()) } +#[cfg(all(not(dotslash_internal), target_os = "macos"))] +fn unpack_pkg(mut reader: R, destination: &Path) -> io::Result<()> +where + R: BufRead + Seek, +{ + let destination = fs_ctx::canonicalize(destination)?; + let work = tempfile::tempdir()?; + let package = work.path().join("artifact.pkg"); + io::copy(&mut reader, &mut fs_ctx::file_create(&package)?)?; + let expanded = work.path().join("expanded"); + let output = std::process::Command::new("/usr/sbin/pkgutil") + .arg("--expand-full") + .arg(&package) + .arg(&expanded) + .output()?; + if !output.status.success() { + return Err(io::Error::other(format!( + "pkgutil failed: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + + // Expand payloads without installing the package or executing its scripts. + let mut pending = vec![expanded]; + let mut payloads = Vec::new(); + while let Some(directory) = pending.pop() { + for entry in std::fs::read_dir(directory)? { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + if entry.file_name() == "Payload" { + payloads.push(entry.path()); + } else { + pending.push(entry.path()); + } + } + } + if payloads.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "PKG contains no file payload", + )); + } + payloads.sort(); + for payload in payloads { + let output = std::process::Command::new("/usr/bin/ditto") + .arg(payload) + .arg(&destination) + .output()?; + if !output.status.success() { + return Err(io::Error::other(format!( + "ditto failed: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + } + Ok(()) +} + +#[cfg(all(not(dotslash_internal), not(target_os = "macos")))] +fn unpack_pkg(_reader: R, _destination: &Path) -> io::Result<()> +where + R: BufRead + Seek, +{ + Err(io::Error::new( + io::ErrorKind::Unsupported, + "PKG extraction is only supported on macOS", + )) +} + fn unpack_tar(reader: R, destination_dir: &Path) -> io::Result<()> where R: Read, @@ -128,3 +204,121 @@ where archive.set_preserve_mtime(true); archive.unpack(destination_dir) } + +#[cfg(all(test, not(dotslash_internal), target_os = "macos"))] +mod pkg_tests { + use super::{ArchiveType, unarchive}; + use std::fs; + use std::io; + use std::io::BufReader; + use std::os::unix::fs::{PermissionsExt, symlink}; + use std::path::Path; + use std::process::Command; + + fn run(command: &mut Command) { + let output = command.output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn build_package(work: &Path, name: &str) -> std::path::PathBuf { + let root = work.join(format!("{name}-root")); + fs::create_dir_all(root.join("bin")).unwrap(); + let executable = root.join("bin").join(name); + fs::write(&executable, b"#!/bin/sh\necho package-ok\n").unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + symlink(name, root.join("bin").join(format!("{name}-link"))).unwrap(); + let scripts = work.join(format!("{name}-scripts")); + fs::create_dir(&scripts).unwrap(); + let script = scripts.join("preinstall"); + fs::write(&script, b"#!/bin/sh\nexit 99\n").unwrap(); + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); + let package = work.join(format!("{name}.pkg")); + run(Command::new("/usr/bin/pkgbuild") + .arg("--root") + .arg(root) + .arg("--identifier") + .arg(format!("org.example.{name}")) + .args(["--version", "1", "--install-location", "/opt/example"]) + .arg("--scripts") + .arg(scripts) + .arg(&package)); + package + } + + #[test] + fn extracts_component_and_distribution_payloads() { + let work = tempfile::Builder::new() + .prefix("dotslash pkg's ") + .tempdir() + .unwrap(); + let first = build_package(work.path(), "first"); + let second = build_package(work.path(), "second"); + let distribution = work.path().join("distribution.pkg"); + run(Command::new("/usr/bin/productbuild") + .arg("--package") + .arg(&first) + .arg("--package") + .arg(second) + .arg(&distribution)); + for (package, names) in [ + (&first, vec!["first"]), + (&distribution, vec!["first", "second"]), + ] { + let destination = tempfile::Builder::new() + .prefix("payload's ") + .tempdir() + .unwrap(); + unarchive( + BufReader::new(fs::File::open(package).unwrap()), + destination.path(), + ArchiveType::Pkg, + ) + .unwrap(); + for name in names { + let executable = destination.path().join("bin").join(name); + assert_eq!( + fs::read(&executable).unwrap(), + b"#!/bin/sh\necho package-ok\n" + ); + assert_ne!( + fs::metadata(&executable).unwrap().permissions().mode() & 0o111, + 0 + ); + assert_eq!( + fs::read_link(destination.path().join("bin").join(format!("{name}-link"))) + .unwrap(), + Path::new(name) + ); + } + assert!(!destination.path().join("opt").exists()); + assert!(!destination.path().join("Scripts").exists()); + } + } + + #[test] + fn rejects_invalid_package() { + let destination = tempfile::tempdir().unwrap(); + let result = unarchive( + io::Cursor::new(b"not a package"), + destination.path(), + ArchiveType::Pkg, + ); + assert!(result.unwrap_err().to_string().contains("pkgutil failed")); + } +} + +#[cfg(all(test, not(dotslash_internal), not(target_os = "macos")))] +#[test] +fn pkg_extraction_requires_macos() { + let error = unarchive( + io::Cursor::new(b"not a package"), + Path::new("unused"), + ArchiveType::Pkg, + ) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::Unsupported); +} diff --git a/website/docs/dotslash-file.md b/website/docs/dotslash-file.md index a9e949e..add7a71 100644 --- a/website/docs/dotslash-file.md +++ b/website/docs/dotslash-file.md @@ -393,6 +393,7 @@ properties: | `tar.zst` | yes | zstd | | `tar` | yes | _none_ | | `zip` | yes | zip | +| `pkg` | yes | package-defined | | `bz2` | no | bzip2 | | `gz` | no | gzip | | `xz` | no | xz | @@ -523,3 +524,12 @@ DotSlash file may not be in a position to change that. In this case, setting `readonly: false` will disable the logic that marks all of the entries in the temporary folder read-only before it is moved to its final location in the cache. + +### macOS installer packages + +`"format": "pkg"` supports flat macOS installer packages. Extraction requires macOS +and uses `pkgutil --expand-full` and `ditto`; installer scripts are never executed. +`path` is relative to the extracted payload root, ignoring the package's installation +location. Component payloads in a distribution package are merged in lexical order; +later payloads overwrite overlapping files. Packages without a file payload are rejected. +Other platforms can parse manifests containing `pkg` entries but cannot extract them.