diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 66ca1ad..492fdb5 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -29,7 +29,41 @@ concurrency: cancel-in-progress: true jobs: + build-wasm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: | + fix-python-soname + sparse-checkout-cone-mode: false + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-wasip1 + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + .cargo-cache + target/ + key: wasm-cargo-cache-${{ hashFiles('**/Cargo.lock') }} + - name: Build WASM + working-directory: fix-python-soname + run: | + cargo build --target wasm32-wasip1 --release + cp target/wasm32-wasip1/release/fix-python-soname.wasm ../fix-python-soname.wasm + - name: Upload WASM artifacts + uses: actions/upload-artifact@v4 + with: + name: wasm-bindings + path: fix-python-soname.wasm + build: + needs: build-wasm strategy: fail-fast: false matrix: @@ -57,6 +91,11 @@ jobs: runs-on: ${{ matrix.settings.host }} steps: - uses: actions/checkout@v4 + - name: Download WASM artifacts + uses: actions/download-artifact@v4 + with: + name: wasm-bindings + path: . - uses: webfactory/ssh-agent@v0.9.0 with: ssh-private-key: | @@ -149,7 +188,6 @@ jobs: git config --global url."ssh://git@github.com-http-handler/platformatic/http-handler.git".insteadOf "ssh://git@github.com/platformatic/http-handler.git" git config --global url."ssh://git@github.com-http-rewriter/platformatic/http-rewriter.git".insteadOf "ssh://git@github.com/platformatic/http-rewriter.git" - npm run build:wasm ${{ matrix.settings.build }} - name: Build run: ${{ matrix.settings.build }} @@ -431,7 +469,7 @@ jobs: exit 1 fi shell: bash - - name: Copy fix-python-soname files to Linux packages + - name: Copy fix-python-soname files to Linux and macOS packages run: | # Find the WASM and JS files from Linux artifacts WASM_FILE=$(find artifacts -name "fix-python-soname.wasm" | head -n 1) @@ -441,9 +479,9 @@ jobs: echo "Found WASM file: $WASM_FILE" echo "Found JS file: $JS_FILE" - # Copy to all Linux npm directories + # Copy to all Linux and macOS npm directories for dir in npm/*/; do - if [[ "$dir" == *"linux"* ]]; then + if [[ "$dir" == *"linux"* ]] || [[ "$dir" == *"darwin"* ]]; then echo "Copying files to $dir" cp "$WASM_FILE" "$dir" cp "$JS_FILE" "$dir" diff --git a/fix-python-soname.js b/fix-python-soname.js index 57288fc..d15775b 100755 --- a/fix-python-soname.js +++ b/fix-python-soname.js @@ -42,22 +42,24 @@ function isDevInstall() { return false } -// Only patch soname on Linux -if (platform !== 'linux') { +// Only patch on Linux and macOS +if (platform !== 'linux' && platform !== 'darwin') { console.log(`No need to fix soname on platform: ${platform}`) process.exit(0) } -// Get the node file path -const nodeFilePath = path.join(__dirname, `python-node.linux-${arch}-gnu.node`) +// Get the node file path based on platform +const nodeFilePath = platform === 'linux' + ? path.join(__dirname, `python-node.linux-${arch}-gnu.node`) + : path.join(__dirname, `python-node.darwin-${arch}.node`) if (!fs.existsSync(nodeFilePath)) { if (isDevInstall()) { // No .node file found during dev install - this is expected, skip silently - console.log(`${nodeFilePath} not found during development install, skipping soname fix`) + console.log(`${nodeFilePath} not found during development install, skipping binary patching`) process.exit(0) } else { // No .node file found when installed as dependency - this is an error - console.error(`Error: Could not find "${nodeFilePath}" to fix soname`) + console.error(`Error: Could not find "${nodeFilePath}" to patch binary`) process.exit(1) } } @@ -67,7 +69,7 @@ const wasmPath = path.join(__dirname, 'fix-python-soname.wasm') if (!fs.existsSync(wasmPath)) { if (isDevInstall()) { // WASM file not found during dev install - this is expected, skip with warning - console.log('WASM file not found during development install, skipping soname fix') + console.log('WASM file not found during development install, skipping binary patching') process.exit(0) } else { // WASM file not found when installed as dependency - this is an error @@ -76,7 +78,7 @@ if (!fs.existsSync(wasmPath)) { } } -console.log(`Running soname fix on ${nodeFilePath}`) +console.log(`Running binary patch on ${nodeFilePath}`) // Create a WASI instance const wasi = new WASI({ diff --git a/fix-python-soname/src/main.rs b/fix-python-soname/src/main.rs index 3a90fd3..7a9c1d5 100644 --- a/fix-python-soname/src/main.rs +++ b/fix-python-soname/src/main.rs @@ -1,4 +1,5 @@ use arwen::elf::ElfContainer; +use arwen::macho::MachoContainer; use std::{ collections::HashMap, env, @@ -6,6 +7,160 @@ use std::{ path::Path, }; +fn is_elf_binary(file_contents: &[u8]) -> bool { + file_contents.len() >= 4 && &file_contents[0..4] == b"\x7fELF" +} + +fn is_macho_binary(file_contents: &[u8]) -> bool { + if file_contents.len() < 4 { + return false; + } + + let magic = u32::from_ne_bytes([ + file_contents[0], + file_contents[1], + file_contents[2], + file_contents[3], + ]); + + // Mach-O magic numbers + magic == 0xfeedface || // 32-bit + magic == 0xfeedfacf || // 64-bit + magic == 0xcafebabe || // Fat binary + magic == 0xcefaedfe || // 32-bit swapped + magic == 0xcffaedfe // 64-bit swapped +} + +fn find_python_library_macos() -> Result { + eprintln!("fix-python-soname: Looking for Python framework on macOS..."); + + // Python versions from 3.20 down to 3.8 + let mut python_versions = Vec::new(); + for major in (8..=20).rev() { + // Framework paths (highest priority) + python_versions.push(format!("Python.framework/Versions/3.{}/Python", major)); + } + + eprintln!( + "fix-python-soname: Looking for versions: {:?}", + &python_versions[0..6] + ); + + // macOS Python search paths (ordered by priority) + let mut lib_paths = vec![ + // Homebrew paths (most common first) + "/opt/homebrew/opt/python@3.13/Frameworks", + "/opt/homebrew/opt/python@3.12/Frameworks", + "/opt/homebrew/opt/python@3.11/Frameworks", + "/opt/homebrew/opt/python@3.10/Frameworks", + "/opt/homebrew/opt/python@3.9/Frameworks", + "/opt/homebrew/opt/python@3.8/Frameworks", + // Intel Mac Homebrew + "/usr/local/opt/python@3.13/Frameworks", + "/usr/local/opt/python@3.12/Frameworks", + "/usr/local/opt/python@3.11/Frameworks", + "/usr/local/opt/python@3.10/Frameworks", + "/usr/local/opt/python@3.9/Frameworks", + "/usr/local/opt/python@3.8/Frameworks", + // System Python frameworks + "/Library/Frameworks", + "/System/Library/Frameworks", + ]; + + // Check for active virtual environments first + if let Ok(venv) = env::var("VIRTUAL_ENV") { + let venv_fw = format!("{}/Frameworks", venv); + lib_paths.insert(0, Box::leak(venv_fw.into_boxed_str())); + } + + // Add user-specific paths + if let Ok(home) = env::var("HOME") { + // pyenv installations + let pyenv_versions = format!("{}/.pyenv/versions", home); + if let Ok(entries) = fs::read_dir(&pyenv_versions) { + for entry in entries.flatten() { + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + let version_fw = format!("{}/Frameworks", entry.path().display()); + lib_paths.push(Box::leak(version_fw.into_boxed_str())); + } + } + } + } + + eprintln!( + "fix-python-soname: Searching in {} framework directories...", + lib_paths.len() + ); + + // First try exact version matches + for lib_name in &python_versions { + for lib_path in &lib_paths { + let full_path = format!("{}/{}", lib_path, lib_name); + if std::path::Path::new(&full_path).exists() { + eprintln!( + "fix-python-soname: Found Python framework: {} at {}", + lib_name, full_path + ); + return Ok(full_path); + } + } + } + + eprintln!("fix-python-soname: No exact match found, searching for any Python.framework..."); + + // If no exact match found, search directories for any Python frameworks + for lib_path in &lib_paths { + if let Ok(entries) = fs::read_dir(lib_path) { + let mut found_frameworks: Vec<(String, u32, u32)> = Vec::new(); + + for entry in entries.flatten() { + if let Some(name) = entry.file_name().to_str() { + if name == "Python.framework" { + // Check for version directories + let versions_dir = entry.path().join("Versions"); + if let Ok(version_entries) = fs::read_dir(&versions_dir) { + for version_entry in version_entries.flatten() { + if let Some(version_name) = version_entry.file_name().to_str() { + if let Some(version_start) = version_name.find("3.") { + let version_part = &version_name[version_start + 2..]; + if let Ok(minor) = version_part.parse::() { + let python_path = version_entry.path().join("Python"); + if python_path.exists() { + found_frameworks.push(( + python_path.to_string_lossy().to_string(), + 3, + minor, + )); + } + } + } + } + } + } + } + } + } + + // Sort by version (newest first) + found_frameworks.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1))); + + if let Some((framework_path, _, _)) = found_frameworks.first() { + eprintln!( + "fix-python-soname: Found Python framework: {} in {}", + framework_path, lib_path + ); + return Ok(framework_path.clone()); + } + } + } + + Err( + "No Python framework found on the system. Searched in:\n".to_string() + + &lib_paths[..10].join("\n ") + + "\n ... and more", + ) +} + fn find_python_library() -> Result { // Generate Python versions from 3.20 down to 3.8 let mut python_versions = Vec::new(); @@ -265,7 +420,7 @@ fn find_python_library() -> Result { } fn main() -> Result<(), Box> { - eprintln!("fix-python-soname: Starting soname patcher..."); + eprintln!("fix-python-soname: Starting binary patcher..."); let args: Vec = env::args().collect(); eprintln!("fix-python-soname: Arguments: {:?}", args); @@ -277,22 +432,38 @@ fn main() -> Result<(), Box> { let node_file_path = &args[1]; eprintln!("fix-python-soname: Processing file: {}", node_file_path); - // Find the local Python library - let new_python_lib = find_python_library()?; - - // Read the file - eprintln!("fix-python-soname: Reading ELF file..."); + // Read the file first to detect format + eprintln!("fix-python-soname: Reading binary file..."); let file_contents = fs::read(node_file_path).map_err(|error| format!("Failed to read file: {error}"))?; eprintln!( - "fix-python-soname: ELF file size: {} bytes", + "fix-python-soname: Binary file size: {} bytes", file_contents.len() ); + // Detect binary format and process accordingly + if is_elf_binary(&file_contents) { + eprintln!("fix-python-soname: Detected ELF binary (Linux)"); + process_elf_binary(&file_contents, node_file_path) + } else if is_macho_binary(&file_contents) { + eprintln!("fix-python-soname: Detected Mach-O binary (macOS)"); + process_macho_binary(&file_contents, node_file_path) + } else { + Err("Unsupported binary format. Only ELF (Linux) and Mach-O (macOS) are supported.".into()) + } +} + +fn process_elf_binary( + file_contents: &[u8], + node_file_path: &str, +) -> Result<(), Box> { + // Find the local Python library (Linux) + let new_python_lib = find_python_library()?; + // Parse the ELF file eprintln!("fix-python-soname: Parsing ELF file..."); let mut elf = - ElfContainer::parse(&file_contents).map_err(|error| format!("Failed to parse ELF: {error}"))?; + ElfContainer::parse(file_contents).map_err(|error| format!("Failed to parse ELF: {error}"))?; // Get the list of needed libraries eprintln!("fix-python-soname: Getting needed libraries..."); @@ -359,3 +530,82 @@ fn main() -> Result<(), Box> { Ok(()) } + +fn process_macho_binary( + file_contents: &[u8], + node_file_path: &str, +) -> Result<(), Box> { + // Find the local Python framework (macOS) + let new_python_framework = find_python_library_macos()?; + + // Parse the Mach-O file + eprintln!("fix-python-soname: Parsing Mach-O file..."); + let mut macho = MachoContainer::parse(file_contents) + .map_err(|error| format!("Failed to parse Mach-O: {error}"))?; + + // Get the list of linked libraries (equivalent to needed libs on ELF) + eprintln!("fix-python-soname: Getting linked libraries..."); + + // Access the libs field based on the macho type + let libs = match &macho.inner { + arwen::macho::MachoType::SingleArch(single) => &single.inner.libs, + arwen::macho::MachoType::Fat(fat) => { + if fat.archs.is_empty() { + return Err("No architectures found in fat binary".into()); + } + &fat.archs[0].inner.inner.libs // Use first architecture + } + }; + + eprintln!("fix-python-soname: Linked libraries: {:?}", libs); + + // Find the existing Python framework dependency + let python_framework = libs + .iter() + .find(|lib| lib.contains("Python.framework") || lib.contains("Python")) + .ok_or("No Python framework dependency found in the binary")?; + + eprintln!( + "fix-python-soname: Current Python framework: {}", + python_framework + ); + + // Check if already pointing to the correct framework + if python_framework == &new_python_framework { + eprintln!("fix-python-soname: Already using the correct Python framework"); + return Ok(()); + } + + eprintln!( + "fix-python-soname: Replacing with: {}", + new_python_framework + ); + + // Use change_install_name to replace the Python framework path + eprintln!("fix-python-soname: Changing install name..."); + macho + .change_install_name(python_framework, &new_python_framework) + .map_err(|error| format!("Failed to change install name: {error}"))?; + + // Create backup + let file_path = Path::new(node_file_path); + let backup_path = file_path.with_extension("node.bak"); + eprintln!( + "fix-python-soname: Creating backup at: {}", + backup_path.display() + ); + fs::copy(file_path, &backup_path).map_err(|error| format!("Failed to create backup: {error}"))?; + eprintln!("fix-python-soname: Backup created successfully"); + + // Write the modified file + eprintln!("fix-python-soname: Writing modified Mach-O file..."); + fs::write(node_file_path, &macho.data) + .map_err(|error| format!("Failed to write Mach-O: {error}"))?; + + eprintln!( + "fix-python-soname: Successfully updated: {}", + node_file_path + ); + + Ok(()) +} diff --git a/npm/darwin-arm64/package.json b/npm/darwin-arm64/package.json index d24c29c..e0073e3 100644 --- a/npm/darwin-arm64/package.json +++ b/npm/darwin-arm64/package.json @@ -9,8 +9,13 @@ ], "main": "python-node.darwin-arm64.node", "files": [ - "python-node.darwin-arm64.node" + "python-node.darwin-arm64.node", + "fix-python-soname.js", + "fix-python-soname.wasm" ], + "scripts": { + "postinstall": "node fix-python-soname.js" + }, "publishConfig": { "registry": "https://registry.npmjs.org/", "access": "restricted", diff --git a/npm/darwin-x64/package.json b/npm/darwin-x64/package.json index e41d936..8655fc5 100644 --- a/npm/darwin-x64/package.json +++ b/npm/darwin-x64/package.json @@ -9,8 +9,13 @@ ], "main": "python-node.darwin-x64.node", "files": [ - "python-node.darwin-x64.node" + "python-node.darwin-x64.node", + "fix-python-soname.js", + "fix-python-soname.wasm" ], + "scripts": { + "postinstall": "node fix-python-soname.js" + }, "publishConfig": { "registry": "https://registry.npmjs.org/", "access": "restricted",