From a4e962445cf9d435a20589e6ebacb9c72ad10cba Mon Sep 17 00:00:00 2001 From: messense Date: Tue, 31 Mar 2026 21:44:49 +0800 Subject: [PATCH 1/7] feat: add arwen dependencies and auditwheel feature Add arwen = 0.0.5 (Mach-O patching) and arwen-codesign = 0.0.1-alpha.1 (pure Rust ad-hoc codesigning) as optional dependencies behind a new 'auditwheel' Cargo feature. Add 'auditwheel' to the 'full' feature list. These enable cross-platform wheel repair: - macOS: Mach-O install name rewriting and ad-hoc codesigning - Works from any host OS (pure Rust, no macOS tools needed) --- Cargo.lock | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 8 ++++++- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aa04372cf..c654d39fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,7 +85,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" dependencies = [ - "object", + "object 0.37.3", ] [[package]] @@ -97,6 +97,31 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arwen" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d44cbd9bd79165abe331ebabb9dd4d59a5dc93791be33ff15ebd71baaadc85ba" +dependencies = [ + "clap", + "goblin", + "object 0.38.1", + "scroll", + "thiserror 2.0.18", +] + +[[package]] +name = "arwen-codesign" +version = "0.0.1-alpha.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35d7a19757bfe3658d5a95bf25a0492f29ebb21933549bdbfa4075c895510124" +dependencies = [ + "goblin", + "scroll", + "sha2", + "tempfile", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -1016,6 +1041,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1174,7 +1205,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1182,6 +1213,9 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -1635,6 +1669,8 @@ name = "maturin" version = "1.12.6" dependencies = [ "anyhow", + "arwen", + "arwen-codesign", "base64", "bytesize", "cargo-config2", @@ -1879,6 +1915,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "object" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271638cd5fa9cca89c4c304675ca658efc4e64a66c716b7cfe1afb4b9611dbbc" +dependencies = [ + "crc32fast", + "flate2", + "hashbrown 0.16.1", + "indexmap", + "memchr", + "ruzstd", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -2632,6 +2682,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ruzstd" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ff0cc5e135c8870a775d3320910cd9b564ec036b4dc0b8741629020be63f01" +dependencies = [ + "twox-hash", +] + [[package]] name = "same-file" version = "1.0.6" diff --git a/Cargo.toml b/Cargo.toml index 0c12af032..386b0b9a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -148,6 +148,10 @@ which = { version = "8.0.0", optional = true } memmap2 = "0.9.9" reflink-copy = "0.1.29" +# auditwheel repair (macOS delocate) +arwen = { version = "0.0.5", optional = true } +arwen-codesign = { version = "0.0.1-alpha.1", optional = true } + [dev-dependencies] expect-test = "1.4.1" fs4 = { version = "0.13.1", features = ["fs-err3"] } @@ -165,7 +169,9 @@ which = "8.0.0" [features] default = ["full", "rustls"] -full = ["cli-completion", "cross-compile", "scaffolding", "upload", "sbom"] +full = ["cli-completion", "cross-compile", "scaffolding", "upload", "sbom", "auditwheel"] + +auditwheel = ["dep:arwen", "dep:arwen-codesign"] cli-completion = ["dep:clap_complete_command"] From ec6bbc29ab7fc8a830b82a9892b937f3267c64e9 Mon Sep 17 00:00:00 2001 From: messense Date: Tue, 31 Mar 2026 22:08:12 +0800 Subject: [PATCH 2/7] feat: create MacOSRepairer implementing WheelRepairer for macOS Add src/auditwheel/macos.rs with MacOSRepairer that uses arwen for Mach-O install name/rpath manipulation and arwen-codesign for pure-Rust ad-hoc code signing. No macOS-only tool dependencies. Key behavior: - Filters system libraries (/usr/lib/*, /System/*) and libpython - Rewrites LC_LOAD_DYLIB to @loader_path-relative names - Sets LC_ID_DYLIB to /DLC// (matching delocate) - Removes absolute rpaths, keeps @loader_path/@executable_path - Ad-hoc codesigns all modified binaries (cross-platform) - Uses .dylibs directory (matching delocate convention) --- src/auditwheel/macos.rs | 238 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 src/auditwheel/macos.rs diff --git a/src/auditwheel/macos.rs b/src/auditwheel/macos.rs new file mode 100644 index 000000000..f67fba1d3 --- /dev/null +++ b/src/auditwheel/macos.rs @@ -0,0 +1,238 @@ +//! macOS/Mach-O wheel audit and repair (delocate equivalent). +//! +//! This module implements [`WheelRepairer`] for macOS Mach-O binaries, +//! providing the Rust equivalent of [delocate](https://github.com/matthew-brett/delocate). +//! +//! Uses `arwen` for Mach-O install name / rpath manipulation and +//! `arwen-codesign` for pure-Rust ad-hoc code signing (no macOS tools needed). + +use super::Policy; +use super::audit::relpath; +use super::repair::{AuditedArtifact, GraftedLib, WheelRepairer, leaf_filename}; +use crate::compile::BuildArtifact; +use anyhow::{Context, Result}; +use arwen::macho::MachoContainer; +use arwen_codesign::{AdhocSignOptions, adhoc_sign_file}; +use lddtree::Library; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// macOS/Mach-O wheel repairer (delocate equivalent). +/// +/// Bundles external `.dylib` files and rewrites Mach-O install names +/// and rpaths so that `@loader_path`-relative references resolve to +/// the bundled copies in the `.dylibs/` directory. +pub struct MacOSRepairer; + +impl WheelRepairer for MacOSRepairer { + fn audit( + &self, + artifact: &BuildArtifact, + ld_paths: Vec, + ) -> Result<(Policy, Vec)> { + let ext_libs = find_external_libs(&artifact.path, ld_paths)?; + Ok((Policy::default(), ext_libs)) + } + + fn patch( + &self, + artifacts: &[AuditedArtifact], + grafted: &[GraftedLib], + libs_dir: &Path, + artifact_dir: &Path, + ) -> Result<()> { + // Build a lookup from all known install names → new leaf name. + let mut name_map: BTreeMap<&str, &str> = BTreeMap::new(); + for lib in grafted { + name_map.insert(lib.original_name.as_str(), lib.new_name.as_str()); + for alias in &lib.aliases { + name_map.insert(alias.as_str(), lib.new_name.as_str()); + } + } + + // 1. Patch each grafted library: set install id, rewrite cross-references, + // remove absolute rpaths, then ad-hoc codesign. + for lib in grafted { + let new_install_id = format!("/DLC/{}/{}", libs_dir.display(), lib.new_name); + + // Collect rpaths to remove (all non-relative rpaths). + let rpaths_to_remove: Vec<&str> = lib + .rpath + .iter() + .filter(|r| !r.starts_with("@loader_path") && !r.starts_with("@executable_path")) + .map(String::as_str) + .collect(); + + // Collect install name changes for cross-references between grafted libs. + let install_name_changes: Vec<(&str, String)> = lib + .needed + .iter() + .filter_map(|n| { + name_map + .get(n.as_str()) + .map(|new| (n.as_str(), format!("@loader_path/{new}"))) + }) + .collect(); + + patch_macho( + &lib.dest_path, + &install_name_changes, + Some(&new_install_id), + &rpaths_to_remove, + )?; + + ad_hoc_sign(&lib.dest_path)?; + } + + // 2. Patch each artifact: rewrite references to grafted libs using + // @loader_path-relative names. + let rel = relpath(libs_dir, artifact_dir); + for audited in artifacts { + let install_name_changes: Vec<(&str, String)> = name_map + .iter() + .map(|(old, new)| { + let relative = Path::new("@loader_path").join(&rel).join(new); + (*old, relative.to_string_lossy().into_owned()) + }) + .collect(); + + if !install_name_changes.is_empty() { + patch_macho(&audited.artifact.path, &install_name_changes, None, &[])?; + ad_hoc_sign(&audited.artifact.path)?; + } + } + + Ok(()) + } + + fn libs_dir(&self, dist_name: &str) -> PathBuf { + PathBuf::from(format!("{dist_name}.dylibs")) + } +} + +/// Check if a library path is a macOS system library that should not be bundled. +/// +/// System libraries live under `/usr/lib/` and `/System/`. Notably, +/// `/usr/local/lib/` (Homebrew) is NOT considered system — those libs +/// get bundled, matching Python delocate behaviour. +fn is_system_library(path: &Path) -> bool { + let s = path.to_string_lossy(); + s.starts_with("/usr/lib/") || s.starts_with("/System/") +} + +/// Check if a library name refers to libpython, which should never be bundled. +fn is_libpython(name: &str) -> bool { + let leaf = leaf_filename(name); + leaf.starts_with("libpython3") +} + +/// Find external shared library dependencies for a macOS artifact. +fn find_external_libs(artifact: impl AsRef, ld_paths: Vec) -> Result> { + let analyzer = if ld_paths.is_empty() { + lddtree::DependencyAnalyzer::default() + } else { + lddtree::DependencyAnalyzer::default().library_paths(ld_paths) + }; + let deps = analyzer + .analyze(artifact.as_ref()) + .context("Failed to analyze Mach-O dependencies")?; + + let mut ext_libs = Vec::new(); + for (_, lib) in deps.libraries { + // Skip libraries that couldn't be resolved + if lib.realpath.is_none() { + continue; + } + // Skip system libraries + if is_system_library(&lib.path) { + continue; + } + // Skip libpython + if is_libpython(&lib.name) { + continue; + } + ext_libs.push(lib); + } + Ok(ext_libs) +} + +/// Batch Mach-O patching: parse once, apply all changes, write once. +fn patch_macho( + file: &Path, + install_name_changes: &[(&str, String)], + new_install_id: Option<&str>, + rpaths_to_remove: &[&str], +) -> Result<()> { + let data = fs_err::read(file)?; + let mut container = + MachoContainer::parse(&data).context("Failed to parse Mach-O for patching")?; + + if let Some(id) = new_install_id { + // Ignore DylibIdMissing — the file may not be a dylib (e.g., a .so extension module). + match container.change_install_id(id) { + Ok(()) => {} + Err(arwen::macho::MachoError::DylibIdMissing) => {} + Err(e) => return Err(e).context("Failed to change install id"), + } + } + + for (old, new) in install_name_changes { + // Ignore DylibNameMissing — the binary may not reference this name + // (e.g., an alias that only appears in a different binary). + match container.change_install_name(old, new) { + Ok(()) => {} + Err(arwen::macho::MachoError::DylibNameMissing(_)) => {} + Err(e) => { + return Err(e) + .with_context(|| format!("Failed to change install name {old} -> {new}")); + } + } + } + + for rpath in rpaths_to_remove { + match container.remove_rpath(rpath) { + Ok(()) => {} + Err(arwen::macho::MachoError::RpathMissing(_)) => {} + Err(e) => return Err(e).with_context(|| format!("Failed to remove rpath {rpath}")), + } + } + + fs_err::write(file, &container.data)?; + Ok(()) +} + +/// Ad-hoc codesign a Mach-O binary using pure-Rust arwen-codesign. +fn ad_hoc_sign(file: &Path) -> Result<()> { + let identifier = file + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown"); + adhoc_sign_file(file, &AdhocSignOptions::new(identifier)) + .with_context(|| format!("Failed to ad-hoc codesign {}", file.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_system_library() { + assert!(is_system_library(Path::new("/usr/lib/libSystem.B.dylib"))); + assert!(is_system_library(Path::new( + "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation" + ))); + assert!(!is_system_library(Path::new("/usr/local/lib/libfoo.dylib"))); + assert!(!is_system_library(Path::new( + "/opt/homebrew/lib/libbar.dylib" + ))); + } + + #[test] + fn test_is_libpython() { + assert!(is_libpython("libpython3.12.dylib")); + assert!(is_libpython("/usr/local/lib/libpython3.11.dylib")); + assert!(is_libpython("@rpath/libpython3.10.dylib")); + assert!(!is_libpython("libfoo.dylib")); + assert!(!is_libpython("libpython2.7.dylib")); + } +} From fb8e26d4c6d5e1cefda8d49578081ed92310b301 Mon Sep 17 00:00:00 2001 From: messense Date: Tue, 31 Mar 2026 22:08:26 +0800 Subject: [PATCH 3/7] feat: wire MacOSRepairer into build pipeline Update auditwheel/mod.rs to export MacOSRepairer (feature-gated behind 'auditwheel'). Wire it into make_repairer() in build_context/repair.rs so macOS builds use MacOSRepairer for wheel repair instead of returning None. --- src/auditwheel/mod.rs | 4 ++++ src/build_context/repair.rs | 12 ++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/auditwheel/mod.rs b/src/auditwheel/mod.rs index 43776c474..4e310336b 100644 --- a/src/auditwheel/mod.rs +++ b/src/auditwheel/mod.rs @@ -1,5 +1,7 @@ mod audit; mod linux; +#[cfg(feature = "auditwheel")] +mod macos; mod musllinux; pub mod patchelf; mod platform_tag; @@ -12,6 +14,8 @@ mod whichprovides; pub use audit::*; pub use linux::ElfRepairer; +#[cfg(feature = "auditwheel")] +pub use macos::MacOSRepairer; pub use platform_tag::PlatformTag; pub use policy::Policy; pub use repair::{AuditedArtifact, WheelRepairer, log_grafted_libs, prepare_grafted_libs}; diff --git a/src/build_context/repair.rs b/src/build_context/repair.rs index 0ba445ec6..929facf8b 100644 --- a/src/build_context/repair.rs +++ b/src/build_context/repair.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "auditwheel")] +use crate::auditwheel::MacOSRepairer; #[cfg(feature = "sbom")] use crate::auditwheel::get_sysroot_path; use crate::auditwheel::{ @@ -48,8 +50,14 @@ impl BuildContext { allow_linking_libpython, })) } else if self.project.target.is_macos() { - // TODO: MacOSRepairer (Phase 2) - None + #[cfg(feature = "auditwheel")] + { + Some(Box::new(MacOSRepairer)) + } + #[cfg(not(feature = "auditwheel"))] + { + None + } } else { None } From dd6d887b2daed6910a264291c489c3c748220bae Mon Sep 17 00:00:00 2001 From: messense Date: Thu, 2 Apr 2026 20:02:17 +0800 Subject: [PATCH 4/7] fix(macos): skip bundling Python.framework in wheel repair The is_libpython check now recognizes Python.framework paths in addition to traditional libpython3.*.dylib files. This fixes pyo3-bin bindings that link against /Library/Frameworks/Python.framework/Versions/X/Python. Also adds should_bundle_library() to properly error on missing non-system dependencies instead of silently skipping them. --- src/auditwheel/macos.rs | 103 ++++++++++++---- src/auditwheel/macos_sign.rs | 230 +++++++++++++++++++++++++++++++++++ src/auditwheel/mod.rs | 2 + 3 files changed, 309 insertions(+), 26 deletions(-) create mode 100644 src/auditwheel/macos_sign.rs diff --git a/src/auditwheel/macos.rs b/src/auditwheel/macos.rs index f67fba1d3..b8dd89b77 100644 --- a/src/auditwheel/macos.rs +++ b/src/auditwheel/macos.rs @@ -4,15 +4,15 @@ //! providing the Rust equivalent of [delocate](https://github.com/matthew-brett/delocate). //! //! Uses `arwen` for Mach-O install name / rpath manipulation and -//! `arwen-codesign` for pure-Rust ad-hoc code signing (no macOS tools needed). +//! pure-Rust signing helpers from `macos_sign` for both thin and fat binaries. use super::Policy; use super::audit::relpath; +use super::macos_sign::ad_hoc_sign; use super::repair::{AuditedArtifact, GraftedLib, WheelRepairer, leaf_filename}; use crate::compile::BuildArtifact; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use arwen::macho::MachoContainer; -use arwen_codesign::{AdhocSignOptions, adhoc_sign_file}; use lddtree::Library; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -120,12 +120,40 @@ fn is_system_library(path: &Path) -> bool { s.starts_with("/usr/lib/") || s.starts_with("/System/") } -/// Check if a library name refers to libpython, which should never be bundled. +/// Check if a library name refers to libpython or Python.framework, which should never be bundled. +/// +/// This catches both: +/// - Traditional libpython: `/usr/local/lib/libpython3.12.dylib`, `@rpath/libpython3.10.dylib` +/// - Python framework: `/Library/Frameworks/Python.framework/Versions/3.14/Python` fn is_libpython(name: &str) -> bool { + // Check for Python.framework (macOS framework-style Python) + if name.contains("Python.framework") { + return true; + } + // Check for traditional libpython dylib let leaf = leaf_filename(name); leaf.starts_with("libpython3") } +/// Decide whether a dependency should be bundled or ignored. +/// +/// Unlike Linux, unresolved non-system Mach-O dependencies must fail the repair +/// because the resulting wheel would still be broken on another machine. +fn should_bundle_library(lib: &Library) -> Result { + if is_system_library(&lib.path) || is_libpython(&lib.name) { + return Ok(false); + } + + if lib.realpath.is_none() { + bail!( + "Cannot repair wheel, because required library {} could not be located.", + lib.path.display() + ); + } + + Ok(true) +} + /// Find external shared library dependencies for a macOS artifact. fn find_external_libs(artifact: impl AsRef, ld_paths: Vec) -> Result> { let analyzer = if ld_paths.is_empty() { @@ -139,19 +167,9 @@ fn find_external_libs(artifact: impl AsRef, ld_paths: Vec) -> Res let mut ext_libs = Vec::new(); for (_, lib) in deps.libraries { - // Skip libraries that couldn't be resolved - if lib.realpath.is_none() { - continue; + if should_bundle_library(&lib)? { + ext_libs.push(lib); } - // Skip system libraries - if is_system_library(&lib.path) { - continue; - } - // Skip libpython - if is_libpython(&lib.name) { - continue; - } - ext_libs.push(lib); } Ok(ext_libs) } @@ -201,20 +219,20 @@ fn patch_macho( Ok(()) } -/// Ad-hoc codesign a Mach-O binary using pure-Rust arwen-codesign. -fn ad_hoc_sign(file: &Path) -> Result<()> { - let identifier = file - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown"); - adhoc_sign_file(file, &AdhocSignOptions::new(identifier)) - .with_context(|| format!("Failed to ad-hoc codesign {}", file.display())) -} - #[cfg(test)] mod tests { use super::*; + fn library(name: &str, path: &str, realpath: Option<&str>) -> Library { + Library { + name: name.to_string(), + path: PathBuf::from(path), + realpath: realpath.map(PathBuf::from), + needed: Vec::new(), + rpath: Vec::new(), + } + } + #[test] fn test_is_system_library() { assert!(is_system_library(Path::new("/usr/lib/libSystem.B.dylib"))); @@ -229,10 +247,43 @@ mod tests { #[test] fn test_is_libpython() { + // Traditional libpython dylibs assert!(is_libpython("libpython3.12.dylib")); assert!(is_libpython("/usr/local/lib/libpython3.11.dylib")); assert!(is_libpython("@rpath/libpython3.10.dylib")); + // Python.framework (macOS framework-style Python) + assert!(is_libpython( + "/Library/Frameworks/Python.framework/Versions/3.14/Python" + )); + assert!(is_libpython( + "/opt/homebrew/Frameworks/Python.framework/Versions/3.12/Python" + )); + // Non-Python libraries assert!(!is_libpython("libfoo.dylib")); assert!(!is_libpython("libpython2.7.dylib")); } + + #[test] + fn test_missing_non_system_dependency_errors() { + let err = + should_bundle_library(&library("@rpath/libfoo.dylib", "@rpath/libfoo.dylib", None)) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "Cannot repair wheel, because required library @rpath/libfoo.dylib could not be located." + ); + } + + #[test] + fn test_missing_system_dependency_is_ignored() { + assert!( + !should_bundle_library(&library( + "/usr/lib/libSystem.B.dylib", + "/usr/lib/libSystem.B.dylib", + None, + )) + .unwrap() + ); + } } diff --git a/src/auditwheel/macos_sign.rs b/src/auditwheel/macos_sign.rs new file mode 100644 index 000000000..d2b7296d3 --- /dev/null +++ b/src/auditwheel/macos_sign.rs @@ -0,0 +1,230 @@ +//! Pure-Rust ad-hoc code signing for Mach-O binaries. +//! +//! This module provides ad-hoc code signing for both thin and fat (universal) Mach-O +//! binaries using `arwen-codesign`. The signatures produced are functionally equivalent +//! to those created by Apple's `codesign -s -` command and pass all verification checks. +//! +//! ## Differences from `codesign` CLI +//! +//! The signatures produced by this module differ slightly from Apple's `codesign` tool: +//! +//! - **Page size**: `arwen-codesign` uses 4KB pages, while `codesign` uses 16KB pages. +//! This results in more hash entries but is valid on all macOS architectures. +//! - **Identifier**: We use the filename as identifier; `codesign` appends a content hash. +//! +//! These differences do not affect functionality - both signatures pass `codesign --verify` +//! and the signed binaries execute correctly on both Intel and Apple Silicon Macs. + +use anyhow::{Context, Result}; +use arwen_codesign::{AdhocSignOptions, adhoc_sign}; +use fat_macho::{Error as FatMachoError, FatReader, FatWriter}; +use std::path::Path; +use tempfile::NamedTempFile; + +/// Check if the given bytes represent a fat (universal) Mach-O binary. +/// +/// Fat binaries use magic `0xcafebabe` (big-endian) or `0xbebafeca` (little-endian). +#[cfg(test)] +fn is_fat_macho(data: &[u8]) -> bool { + matches!( + data.get(..4), + Some([0xca, 0xfe, 0xba, 0xbe] | [0xbe, 0xba, 0xfe, 0xca]) + ) +} + +/// Ad-hoc codesign Mach-O bytes, handling both thin and fat (universal) binaries. +/// +/// For fat binaries, each architecture slice is signed individually and then +/// the slices are reassembled into a new fat binary. This approach requires that +/// each thin slice has an existing `LC_CODE_SIGNATURE` load command (which is the +/// case for binaries produced by modern Apple toolchains with `-Wl,-adhoc_codesign`). +pub(crate) fn ad_hoc_sign_macho_bytes(data: Vec, identifier: &str) -> Result> { + match FatReader::new(&data) { + Ok(reader) => { + let mut writer = FatWriter::new(); + for arch in reader.iter_arches() { + let arch = arch.with_context(|| { + format!("Failed to iterate fat Mach-O slices for {identifier}") + })?; + let signed = sign_thin_macho_slice(arch.slice(&data).to_vec(), identifier)?; + writer.add(signed).with_context(|| { + format!("Failed to rebuild fat Mach-O slices for {identifier}") + })?; + } + + let mut rebuilt = Vec::new(); + writer + .write_to(&mut rebuilt) + .with_context(|| format!("Failed to write fat Mach-O for {identifier}"))?; + Ok(rebuilt) + } + Err(FatMachoError::NotFatBinary) => sign_thin_macho_slice(data, identifier), + Err(err) => { + Err(err).with_context(|| format!("Failed to parse fat Mach-O for {identifier}")) + } + } +} + +fn sign_thin_macho_slice(data: Vec, identifier: &str) -> Result> { + adhoc_sign(data, &AdhocSignOptions::new(identifier)) + .with_context(|| format!("Failed to ad-hoc codesign Mach-O slice {identifier}")) +} + +pub(crate) fn ad_hoc_sign(path: &Path) -> Result<()> { + let data = fs_err::read(path)?; + let identifier = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("unknown"); + let signed = ad_hoc_sign_macho_bytes(data, identifier)?; + let metadata = fs_err::metadata(path)?; + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let mut temp = NamedTempFile::new_in(parent)?; + use std::io::Write; + temp.write_all(&signed)?; + temp.as_file().sync_all()?; + fs_err::set_permissions(temp.path(), metadata.permissions())?; + temp.persist(path) + .map_err(|err| err.error) + .with_context(|| format!("Failed to persist signed Mach-O {}", path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + /// Minimal C source that compiles to a tiny Mach-O executable. + const MINIMAL_C_SOURCE: &str = "int main(){return 0;}"; + + /// Compile a minimal Mach-O binary for the given architecture. + /// Returns the path to the compiled binary. + #[cfg(target_os = "macos")] + fn compile_thin_macho(dir: &Path, arch: &str) -> std::path::PathBuf { + let src = dir.join("main.c"); + let out = dir.join(format!("main_{arch}")); + fs_err::write(&src, MINIMAL_C_SOURCE).unwrap(); + + let status = Command::new("clang") + .args([ + "-arch", + arch, + // Ensure LC_CODE_SIGNATURE is present even when cross-compiling + "-Wl,-adhoc_codesign", + "-o", + ]) + .arg(&out) + .arg(&src) + .status() + .expect("Failed to run clang"); + assert!(status.success(), "clang failed for {arch}"); + out + } + + #[test] + fn detects_thin_macho_magic() { + // MH_MAGIC_64 little-endian (most common on x86_64/arm64) + assert!(!is_fat_macho(&[0xcf, 0xfa, 0xed, 0xfe])); + // MH_MAGIC big-endian + assert!(!is_fat_macho(&[0xfe, 0xed, 0xfa, 0xce])); + } + + #[test] + fn detects_fat_macho_magic() { + // FAT_MAGIC big-endian + assert!(is_fat_macho(&[0xca, 0xfe, 0xba, 0xbe])); + // FAT_MAGIC little-endian (rare but valid) + assert!(is_fat_macho(&[0xbe, 0xba, 0xfe, 0xca])); + } + + #[test] + #[cfg(target_os = "macos")] + fn signs_thin_binary_and_verifies() { + let temp_dir = tempfile::tempdir().unwrap(); + let thin = compile_thin_macho(temp_dir.path(), "arm64"); + + ad_hoc_sign(&thin).unwrap(); + + let output = Command::new("codesign") + .args(["--verify", "--verbose"]) + .arg(&thin) + .output() + .unwrap(); + assert!( + output.status.success(), + "codesign --verify failed for thin binary: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + #[cfg(target_os = "macos")] + fn signs_thin_x86_64_binary_and_verifies() { + let temp_dir = tempfile::tempdir().unwrap(); + let thin = compile_thin_macho(temp_dir.path(), "x86_64"); + + ad_hoc_sign(&thin).unwrap(); + + let output = Command::new("codesign") + .args(["--verify", "--verbose"]) + .arg(&thin) + .output() + .unwrap(); + assert!( + output.status.success(), + "codesign --verify failed for thin x86_64 binary: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + #[cfg(target_os = "macos")] + fn signs_fat_binary_from_thin_slices() { + // Test the fat binary signing flow by building thin binaries, + // manually creating a fat binary with FatWriter, and signing it. + // This simulates what happens when bundling dylibs from different + // architectures into a universal binary. + let temp_dir = tempfile::tempdir().unwrap(); + let arm64 = compile_thin_macho(temp_dir.path(), "arm64"); + let x86_64 = compile_thin_macho(temp_dir.path(), "x86_64"); + + // Read the thin binaries (each has its own LC_CODE_SIGNATURE) + let arm64_data = fs_err::read(&arm64).unwrap(); + let x86_64_data = fs_err::read(&x86_64).unwrap(); + + // Build fat binary from self-contained thin slices + let mut writer = FatWriter::new(); + writer.add(arm64_data).unwrap(); + writer.add(x86_64_data).unwrap(); + + let mut fat = Vec::new(); + writer.write_to(&mut fat).unwrap(); + + // Verify it's a fat binary + assert!(is_fat_macho(&fat), "Expected fat binary"); + + // Sign each slice and rebuild + let signed = ad_hoc_sign_macho_bytes(fat, "test-universal").unwrap(); + + // Verify both slices are present + let reader = FatReader::new(&signed).unwrap(); + assert!(reader.extract("arm64").is_some(), "arm64 slice missing"); + assert!(reader.extract("x86_64").is_some(), "x86_64 slice missing"); + + // Write to file and verify with codesign + let fat_path = temp_dir.path().join("universal"); + fs_err::write(&fat_path, &signed).unwrap(); + + let output = Command::new("codesign") + .args(["--verify", "--verbose"]) + .arg(&fat_path) + .output() + .unwrap(); + assert!( + output.status.success(), + "codesign --verify failed for fat binary: {}", + String::from_utf8_lossy(&output.stderr) + ); + } +} diff --git a/src/auditwheel/mod.rs b/src/auditwheel/mod.rs index 4e310336b..0688c9d69 100644 --- a/src/auditwheel/mod.rs +++ b/src/auditwheel/mod.rs @@ -2,6 +2,8 @@ mod audit; mod linux; #[cfg(feature = "auditwheel")] mod macos; +#[cfg(feature = "auditwheel")] +mod macos_sign; mod musllinux; pub mod patchelf; mod platform_tag; From 20580a9bc3739ca0a01926017a3b632fc8d82a43 Mon Sep 17 00:00:00 2001 From: messense Date: Thu, 2 Apr 2026 20:48:54 +0800 Subject: [PATCH 5/7] fix(macos): re-parse Mach-O between patching operations arwen's MachoContainer caches parsed load command offsets, which become stale after modifications that change install name lengths. Re-parsing between each operation ensures correct offsets are used. This fixes corruption when changing install names to longer strings, which shifts subsequent load commands in the binary. --- src/auditwheel/macos.rs | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/auditwheel/macos.rs b/src/auditwheel/macos.rs index b8dd89b77..f68c93043 100644 --- a/src/auditwheel/macos.rs +++ b/src/auditwheel/macos.rs @@ -174,31 +174,37 @@ fn find_external_libs(artifact: impl AsRef, ld_paths: Vec) -> Res Ok(ext_libs) } -/// Batch Mach-O patching: parse once, apply all changes, write once. +/// Batch Mach-O patching: apply changes, re-parsing between operations to handle +/// offset shifts from install name changes. fn patch_macho( file: &Path, install_name_changes: &[(&str, String)], new_install_id: Option<&str>, rpaths_to_remove: &[&str], ) -> Result<()> { - let data = fs_err::read(file)?; - let mut container = - MachoContainer::parse(&data).context("Failed to parse Mach-O for patching")?; - + // Change install ID first (this can shift load command offsets) if let Some(id) = new_install_id { - // Ignore DylibIdMissing — the file may not be a dylib (e.g., a .so extension module). + let data = fs_err::read(file)?; + let mut container = + MachoContainer::parse(&data).context("Failed to parse Mach-O for install_id change")?; match container.change_install_id(id) { - Ok(()) => {} + Ok(()) => { + fs_err::write(file, &container.data)?; + } Err(arwen::macho::MachoError::DylibIdMissing) => {} Err(e) => return Err(e).context("Failed to change install id"), } } + // Change install names (each can shift offsets, so re-parse between each) for (old, new) in install_name_changes { - // Ignore DylibNameMissing — the binary may not reference this name - // (e.g., an alias that only appears in a different binary). + let data = fs_err::read(file)?; + let mut container = MachoContainer::parse(&data) + .context("Failed to parse Mach-O for install_name change")?; match container.change_install_name(old, new) { - Ok(()) => {} + Ok(()) => { + fs_err::write(file, &container.data)?; + } Err(arwen::macho::MachoError::DylibNameMissing(_)) => {} Err(e) => { return Err(e) @@ -207,15 +213,20 @@ fn patch_macho( } } + // Remove rpaths for rpath in rpaths_to_remove { + let data = fs_err::read(file)?; + let mut container = + MachoContainer::parse(&data).context("Failed to parse Mach-O for rpath removal")?; match container.remove_rpath(rpath) { - Ok(()) => {} + Ok(()) => { + fs_err::write(file, &container.data)?; + } Err(arwen::macho::MachoError::RpathMissing(_)) => {} Err(e) => return Err(e).with_context(|| format!("Failed to remove rpath {rpath}")), } } - fs_err::write(file, &container.data)?; Ok(()) } From 62d3ab4eddb4f5984a17be3a359f12026c86406b Mon Sep 17 00:00:00 2001 From: messense Date: Thu, 2 Apr 2026 20:54:32 +0800 Subject: [PATCH 6/7] fix(macos): skip bundling PythonT.framework for free-threaded Python The is_libpython() check only recognized Python.framework but free-threaded Python builds (3.13t, 3.14t) use PythonT.framework. This caused the framework to be bundled and the binary patched to reference a non-existent hashed library name. --- src/auditwheel/macos.rs | 13 +++++++++++-- src/auditwheel/macos_sign.rs | 7 ++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/auditwheel/macos.rs b/src/auditwheel/macos.rs index f68c93043..f6469a84b 100644 --- a/src/auditwheel/macos.rs +++ b/src/auditwheel/macos.rs @@ -125,9 +125,11 @@ fn is_system_library(path: &Path) -> bool { /// This catches both: /// - Traditional libpython: `/usr/local/lib/libpython3.12.dylib`, `@rpath/libpython3.10.dylib` /// - Python framework: `/Library/Frameworks/Python.framework/Versions/3.14/Python` +/// - Free-threaded Python framework: `/Library/Frameworks/PythonT.framework/Versions/3.14/PythonT` fn is_libpython(name: &str) -> bool { - // Check for Python.framework (macOS framework-style Python) - if name.contains("Python.framework") { + // Check for Python.framework or PythonT.framework (macOS framework-style Python) + // PythonT.framework is used by free-threaded Python builds (e.g., Python 3.13t, 3.14t) + if name.contains("Python.framework") || name.contains("PythonT.framework") { return true; } // Check for traditional libpython dylib @@ -269,6 +271,13 @@ mod tests { assert!(is_libpython( "/opt/homebrew/Frameworks/Python.framework/Versions/3.12/Python" )); + // PythonT.framework (free-threaded Python builds, e.g., 3.13t, 3.14t) + assert!(is_libpython( + "/Library/Frameworks/PythonT.framework/Versions/3.14/PythonT" + )); + assert!(is_libpython( + "/opt/homebrew/Frameworks/PythonT.framework/Versions/3.13/PythonT" + )); // Non-Python libraries assert!(!is_libpython("libfoo.dylib")); assert!(!is_libpython("libpython2.7.dylib")); diff --git a/src/auditwheel/macos_sign.rs b/src/auditwheel/macos_sign.rs index d2b7296d3..be86491b5 100644 --- a/src/auditwheel/macos_sign.rs +++ b/src/auditwheel/macos_sign.rs @@ -93,15 +93,16 @@ pub(crate) fn ad_hoc_sign(path: &Path) -> Result<()> { #[cfg(test)] mod tests { use super::*; + #[cfg(target_os = "macos")] use std::process::Command; - /// Minimal C source that compiles to a tiny Mach-O executable. - const MINIMAL_C_SOURCE: &str = "int main(){return 0;}"; - /// Compile a minimal Mach-O binary for the given architecture. /// Returns the path to the compiled binary. #[cfg(target_os = "macos")] fn compile_thin_macho(dir: &Path, arch: &str) -> std::path::PathBuf { + /// Minimal C source that compiles to a tiny Mach-O executable. + const MINIMAL_C_SOURCE: &str = "int main(){return 0;}"; + let src = dir.join("main.c"); let out = dir.join(format!("main_{arch}")); fs_err::write(&src, MINIMAL_C_SOURCE).unwrap(); From 17fa8468cd205f807a797439ef73afe944b3931e Mon Sep 17 00:00:00 2001 From: messense Date: Thu, 2 Apr 2026 21:38:26 +0800 Subject: [PATCH 7/7] fix(macos): use system codesign CLI on macOS The pure-Rust arwen-codesign library requires an LC_CODE_SIGNATURE load command which older dylibs (e.g., Homebrew's libintl) don't have. Use Apple's codesign CLI directly on macOS for reliability, keeping the pure-Rust implementation for cross-compilation from other platforms. --- src/auditwheel/macos_sign.rs | 115 +++++++++++++++++++---------------- 1 file changed, 61 insertions(+), 54 deletions(-) diff --git a/src/auditwheel/macos_sign.rs b/src/auditwheel/macos_sign.rs index be86491b5..87e5ffd59 100644 --- a/src/auditwheel/macos_sign.rs +++ b/src/auditwheel/macos_sign.rs @@ -1,44 +1,31 @@ //! Pure-Rust ad-hoc code signing for Mach-O binaries. //! //! This module provides ad-hoc code signing for both thin and fat (universal) Mach-O -//! binaries using `arwen-codesign`. The signatures produced are functionally equivalent -//! to those created by Apple's `codesign -s -` command and pass all verification checks. +//! binaries. On macOS, it uses Apple's `codesign` CLI tool directly. For cross-compilation +//! from other platforms, it uses `arwen-codesign` as a pure-Rust fallback. //! -//! ## Differences from `codesign` CLI -//! -//! The signatures produced by this module differ slightly from Apple's `codesign` tool: -//! -//! - **Page size**: `arwen-codesign` uses 4KB pages, while `codesign` uses 16KB pages. -//! This results in more hash entries but is valid on all macOS architectures. -//! - **Identifier**: We use the filename as identifier; `codesign` appends a content hash. -//! -//! These differences do not affect functionality - both signatures pass `codesign --verify` -//! and the signed binaries execute correctly on both Intel and Apple Silicon Macs. +//! The signatures produced are functionally equivalent to those created by Apple's +//! `codesign -s -` command and pass all verification checks. use anyhow::{Context, Result}; -use arwen_codesign::{AdhocSignOptions, adhoc_sign}; +#[cfg(not(target_os = "macos"))] +use arwen_codesign::adhoc_sssspSAnsOpSonsOptions; +#[cfg(not(target_os = "macos"))] use fat_macho::{Error as FatMachoError, FatReader, FatWriter}; use std::path::Path; +#[cfg(target_os = "macos")] +use std::process::Command; +#[cfg(not(target_os = "macos"))] use tempfile::NamedTempFile; -/// Check if the given bytes represent a fat (universal) Mach-O binary. -/// -/// Fat binaries use magic `0xcafebabe` (big-endian) or `0xbebafeca` (little-endian). -#[cfg(test)] -fn is_fat_macho(data: &[u8]) -> bool { - matches!( - data.get(..4), - Some([0xca, 0xfe, 0xba, 0xbe] | [0xbe, 0xba, 0xfe, 0xca]) - ) -} - /// Ad-hoc codesign Mach-O bytes, handling both thin and fat (universal) binaries. /// /// For fat binaries, each architecture slice is signed individually and then /// the slices are reassembled into a new fat binary. This approach requires that /// each thin slice has an existing `LC_CODE_SIGNATURE` load command (which is the /// case for binaries produced by modern Apple toolchains with `-Wl,-adhoc_codesign`). -pub(crate) fn ad_hoc_sign_macho_bytes(data: Vec, identifier: &str) -> Result> { +#[cfg(not(target_os = "macos"))] +fn ad_hoc_sign_macho_bytes(data: Vec, identifier: &str) -> Result> { match FatReader::new(&data) { Ok(reader) => { let mut writer = FatWriter::new(); @@ -65,17 +52,46 @@ pub(crate) fn ad_hoc_sign_macho_bytes(data: Vec, identifier: &str) -> Result } } +#[cfg(not(target_os = "macos"))] fn sign_thin_macho_slice(data: Vec, identifier: &str) -> Result> { adhoc_sign(data, &AdhocSignOptions::new(identifier)) .with_context(|| format!("Failed to ad-hoc codesign Mach-O slice {identifier}")) } +/// Ad-hoc codesign a Mach-O file at the given path. +/// +/// On macOS, uses Apple's `codesign` CLI tool directly. For cross-compilation +/// from other platforms, uses the pure-Rust `arwen-codesign` library. +#[cfg(target_os = "macos")] +pub(crate) fn ad_hoc_sign(path: &Path) -> Result<()> { + let output = Command::new("codesign") + .args(["-s", "-", "-f"]) + .arg(path) + .output() + .context("Failed to run codesign command")?; + + if !output.status.success() { + anyhow::bail!( + "codesign failed for {}: {}", + path.display(), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(()) +} + +/// Ad-hoc codesign a Mach-O file at the given path. +/// +/// Uses the pure-Rust `arwen-codesign` library for cross-compilation scenarios +/// where Apple's `codesign` tool is not available. +#[cfg(not(target_os = "macos"))] pub(crate) fn ad_hoc_sign(path: &Path) -> Result<()> { let data = fs_err::read(path)?; let identifier = path .file_name() .and_then(|name| name.to_str()) .unwrap_or("unknown"); + let signed = ad_hoc_sign_macho_bytes(data, identifier)?; let metadata = fs_err::metadata(path)?; let parent = path.parent().unwrap_or_else(|| Path::new(".")); @@ -90,15 +106,24 @@ pub(crate) fn ad_hoc_sign(path: &Path) -> Result<()> { Ok(()) } -#[cfg(test)] +#[cfg_attr(target_os = "macos", cfg(test))] mod tests { use super::*; - #[cfg(target_os = "macos")] + use fat_macho::{FatReader, FatWriter}; use std::process::Command; + /// Check if the given bytes represent a fat (universal) Mach-O binary. + /// + /// Fat binaries use magic `0xcafebabe` (big-endian) or `0xbebafeca` (little-endian). + fn is_fat_macho(data: &[u8]) -> bool { + matches!( + data.get(..4), + Some([0xca, 0xfe, 0xba, 0xbe] | [0xbe, 0xba, 0xfe, 0xca]) + ) + } + /// Compile a minimal Mach-O binary for the given architecture. /// Returns the path to the compiled binary. - #[cfg(target_os = "macos")] fn compile_thin_macho(dir: &Path, arch: &str) -> std::path::PathBuf { /// Minimal C source that compiles to a tiny Mach-O executable. const MINIMAL_C_SOURCE: &str = "int main(){return 0;}"; @@ -124,23 +149,6 @@ mod tests { } #[test] - fn detects_thin_macho_magic() { - // MH_MAGIC_64 little-endian (most common on x86_64/arm64) - assert!(!is_fat_macho(&[0xcf, 0xfa, 0xed, 0xfe])); - // MH_MAGIC big-endian - assert!(!is_fat_macho(&[0xfe, 0xed, 0xfa, 0xce])); - } - - #[test] - fn detects_fat_macho_magic() { - // FAT_MAGIC big-endian - assert!(is_fat_macho(&[0xca, 0xfe, 0xba, 0xbe])); - // FAT_MAGIC little-endian (rare but valid) - assert!(is_fat_macho(&[0xbe, 0xba, 0xfe, 0xca])); - } - - #[test] - #[cfg(target_os = "macos")] fn signs_thin_binary_and_verifies() { let temp_dir = tempfile::tempdir().unwrap(); let thin = compile_thin_macho(temp_dir.path(), "arm64"); @@ -160,7 +168,6 @@ mod tests { } #[test] - #[cfg(target_os = "macos")] fn signs_thin_x86_64_binary_and_verifies() { let temp_dir = tempfile::tempdir().unwrap(); let thin = compile_thin_macho(temp_dir.path(), "x86_64"); @@ -180,7 +187,6 @@ mod tests { } #[test] - #[cfg(target_os = "macos")] fn signs_fat_binary_from_thin_slices() { // Test the fat binary signing flow by building thin binaries, // manually creating a fat binary with FatWriter, and signing it. @@ -205,18 +211,19 @@ mod tests { // Verify it's a fat binary assert!(is_fat_macho(&fat), "Expected fat binary"); - // Sign each slice and rebuild - let signed = ad_hoc_sign_macho_bytes(fat, "test-universal").unwrap(); + // Write to file and sign with codesign CLI + let fat_path = temp_dir.path().join("universal"); + fs_err::write(&fat_path, &fat).unwrap(); - // Verify both slices are present + ad_hoc_sign(&fat_path).unwrap(); + + // Verify both slices are present after signing + let signed = fs_err::read(&fat_path).unwrap(); let reader = FatReader::new(&signed).unwrap(); assert!(reader.extract("arm64").is_some(), "arm64 slice missing"); assert!(reader.extract("x86_64").is_some(), "x86_64 slice missing"); - // Write to file and verify with codesign - let fat_path = temp_dir.path().join("universal"); - fs_err::write(&fat_path, &signed).unwrap(); - + // Verify with codesign let output = Command::new("codesign") .args(["--verify", "--verbose"]) .arg(&fat_path)