@@ -12,6 +12,7 @@ require 'net/http'
1212require 'open3'
1313require 'optparse'
1414require 'pathname'
15+ require 'set'
1516require 'time'
1617require 'tmpdir'
1718require 'uri'
@@ -532,7 +533,23 @@ class Build
532533 env += ENV [ 'NIX_CFLAGS_COMPILE' ] . split
533534 end
534535
535- @env_CFLAGS = env
536+ # Group "-isystem <path>" flags together as a single flag.
537+ #
538+ # This allows us to deduplicate CFLAGS from NIX_CFLAGS_COMPILE.
539+ new_env = [ ]
540+ isystem_flag = false
541+ env . each do |flag |
542+ if flag . strip == '-isystem'
543+ isystem_flag = true
544+ elsif isystem_flag
545+ new_env << "-isystem #{ flag } "
546+ isystem_flag = false
547+ else
548+ new_env << flag
549+ end
550+ end
551+
552+ @env_CFLAGS = new_env
536553 end
537554
538555 def env_LDFLAGS
@@ -623,11 +640,11 @@ class Build
623640
624641 if options [ :native_comp ]
625642 env [ 'CFLAGS' ] = [ env_CFLAGS , ENV . fetch ( 'CFLAGS' , nil ) ]
626- . flatten . compact . reject ( &:empty? ) . join ( ' ' )
643+ . flatten . compact . reject ( &:empty? ) . uniq . join ( ' ' )
627644 env [ 'LDFLAGS' ] = [ env_LDFLAGS , ENV . fetch ( 'LDFLAGS' , nil ) ]
628- . flatten . compact . reject ( &:empty? ) . join ( ' ' )
645+ . flatten . compact . reject ( &:empty? ) . uniq . join ( ' ' )
629646 env [ 'LIBRARY_PATH' ] = [ env_LIBRARY_PATH , ENV . fetch ( 'LIBRARY_PATH' , nil ) ]
630- . flatten . compact . reject ( &:empty? ) . join ( ':' )
647+ . flatten . compact . reject ( &:empty? ) . uniq . join ( ':' )
631648 end
632649
633650 @compile_env = env
@@ -1701,6 +1718,7 @@ end
17011718
17021719class GccInfo
17031720 include Output
1721+ include System
17041722
17051723 def initialize ( use_nix : false )
17061724 @use_nix = use_nix
@@ -1823,7 +1841,7 @@ class GccInfo
18231841 Dir [
18241842 File . join ( libgccjit_root_dir , 'lib/gcc/*/libgccjit*.dylib' ) ,
18251843 File . join ( libgccjit_root_dir , 'lib/gcc/*/libgccjit.so*' ) ,
1826- ]
1844+ ]
18271845 . map { |path | File . dirname ( path ) }
18281846 . select { |path | File . basename ( path ) . match ( /^\d +$/ ) }
18291847 . max_by { |path | File . basename ( path ) . to_i }
@@ -1840,10 +1858,15 @@ class GccInfo
18401858 'brew reinstall libgccjit'
18411859 end
18421860
1843- # No need to verify gcc vs libgccjit for Nix, as we can pull everything we
1844- # need from the libgccjit package. On homebrew we need to pull parts from
1845- # gcc and parts from libgccjit, hence we need to ensure versions match.
1846- return if use_nix?
1861+ if use_nix?
1862+ Dir [ File . join ( libgccjit_lib_dir , 'libgccjit*.dylib' ) ]
1863+ . each { |path | clean_macho_binary ( path ) }
1864+
1865+ # No need to verify gcc vs libgccjit for Nix, as we can pull everything we
1866+ # need from the libgccjit package. On homebrew we need to pull parts from
1867+ # gcc and parts from libgccjit, hence we need to ensure versions match.
1868+ return
1869+ end
18471870
18481871 return if major_version == libgccjit_major_version
18491872
@@ -1868,6 +1891,186 @@ class GccInfo
18681891 def relative_path ( base , path )
18691892 Pathname . new ( path ) . relative_path_from ( Pathname . new ( base ) ) . to_s
18701893 end
1894+
1895+ def clean_macho_binary ( path )
1896+ debug "Checking for duplicate RPATHs in #{ path } "
1897+ macho_cleaner = MachOCleaner . new ( path )
1898+ return unless macho_cleaner . has_duplicate_rpaths?
1899+
1900+ begin
1901+ info "Removing duplicate RPATHs from #{ path } "
1902+ macho_cleaner . clean!
1903+ debug 'Cleaned duplicate RPATHs successfully!'
1904+ rescue MachOCleaner ::PermissionError => e
1905+ warn "Could not remove duplicate RPATHs from #{ path } : #{ e . message } "
1906+ if ENV [ 'USER' ] == 'root'
1907+ fatal "Could not remove duplicate RPATHs from #{ path } : #{ e . message } "
1908+ else
1909+ warn '================================================================='
1910+ warn "Attempting to clean duplicate RPATHs from #{ path } as root"
1911+ warn '================================================================='
1912+ run_cmd ( 'sudo' , $PROGRAM_NAME, '--clean-macho-binary' , path )
1913+ end
1914+ end
1915+ end
1916+ end
1917+
1918+ # MachOCleaner is a class that cleans up a Mach-O file by removing all duplicate
1919+ # RPATH load commands. This ensures compatibility with macOS 15.4 and later,
1920+ # which refuses to load binaries and shared libraries with duplicate RPATHs.
1921+ class MachOCleaner
1922+ include Output
1923+ include System
1924+
1925+ class PermissionError < StandardError
1926+ def initialize ( file , message = nil )
1927+ @file = file
1928+ super ( message || "Insufficient permissions to modify #{ file } " )
1929+ end
1930+
1931+ attr_reader :file
1932+ end
1933+
1934+ attr_reader :file
1935+
1936+ def initialize ( file_path , backup : true )
1937+ @file = file_path
1938+ @backup = backup
1939+
1940+ validate_file!
1941+ end
1942+
1943+ def backup?
1944+ @backup
1945+ end
1946+
1947+ # Main cleaning method - removes duplicate RPATH commands
1948+ def clean!
1949+ duplicate_paths = find_duplicate_rpaths ( macho_object )
1950+ return if duplicate_paths . empty?
1951+
1952+ backup_file! if backup?
1953+
1954+ while_writable ( @file ) do
1955+ duplicate_paths . each do |rpath |
1956+ remove_rpath_with_install_name_tool! ( rpath )
1957+ end
1958+ end
1959+ end
1960+
1961+ # Check if file has duplicate RPATH commands
1962+ def has_duplicate_rpaths?
1963+ count_duplicate_rpaths ( macho_object ) . positive?
1964+ end
1965+
1966+ # Return total number of RPATH commands
1967+ def rpath_count
1968+ count_rpaths ( macho_object )
1969+ end
1970+
1971+ # Return number of duplicate RPATH commands
1972+ def duplicate_rpath_count
1973+ count_duplicate_rpaths ( macho_object )
1974+ end
1975+
1976+ private
1977+
1978+ # Validate that the file exists and is readable
1979+ def validate_file!
1980+ fatal "File does not exist: #{ @file } " unless File . exist? ( @file )
1981+ return if File . readable? ( @file )
1982+
1983+ fatal "File is not readable: #{ @file } "
1984+ end
1985+
1986+ # Load and memoize the Mach-O object
1987+ def macho_object
1988+ return @macho_object if @macho_object
1989+
1990+ begin
1991+ @macho_object = MachO . open ( @file )
1992+ rescue MachO ::MachOError => e
1993+ fatal "Not a valid Mach-O file: #{ @file } (#{ e . message } )"
1994+ end
1995+
1996+ unless @macho_object . respond_to? ( :rpaths )
1997+ fatal "Unsupported Mach-O file type: #{ @file } "
1998+ end
1999+
2000+ @macho_object
2001+ end
2002+
2003+ def backup_file!
2004+ backup_file = "#{ @file } .bak"
2005+ if File . exist? ( backup_file )
2006+ debug "Backup file already exists: #{ backup_file } "
2007+ return
2008+ end
2009+
2010+ FileUtils . cp ( @file , backup_file )
2011+ debug "Backed up #{ @file } to #{ backup_file } "
2012+ end
2013+
2014+ # Temporarily make file writable, execute block, then restore permissions
2015+ def while_writable ( file )
2016+ # Check if file is already writable to avoid unnecessary permission changes
2017+ if File . writable? ( file )
2018+ yield
2019+ return
2020+ end
2021+
2022+ original_mode = File . stat ( file ) . mode
2023+
2024+ begin
2025+ File . chmod ( 0o755 , file )
2026+ rescue Errno ::EPERM , Errno ::EACCES => e
2027+ raise PermissionError . new (
2028+ file , "Cannot change file permissions: #{ e . message } "
2029+ )
2030+ end
2031+
2032+ yield
2033+ ensure
2034+ if File . exist? ( file ) && original_mode
2035+ begin
2036+ File . chmod ( original_mode , file )
2037+ rescue Errno ::EPERM , Errno ::EACCES
2038+ # Log warning but don't fail - file was already modified successfully
2039+ warn "Warning: Could not restore original permissions for #{ file } "
2040+ end
2041+ end
2042+ end
2043+
2044+ # Find duplicate RPATH commands in a Mach-O file
2045+ def find_duplicate_rpaths ( macho_file )
2046+ seen = Set . new
2047+ duplicates = [ ]
2048+
2049+ macho_file . rpaths . each do |rpath |
2050+ if seen . include? ( rpath )
2051+ duplicates << rpath
2052+ else
2053+ seen . add ( rpath )
2054+ end
2055+ end
2056+
2057+ duplicates
2058+ end
2059+
2060+ # Remove an RPATH using install_name_tool
2061+ def remove_rpath_with_install_name_tool! ( rpath )
2062+ run_cmd ( 'install_name_tool' , '-delete_rpath' , rpath , @file )
2063+ end
2064+
2065+ # Count total RPATH commands in a Mach-O file
2066+ def count_rpaths ( macho_file )
2067+ macho_file . rpaths . size
2068+ end
2069+
2070+ # Count duplicate RPATH commands in a Mach-O file
2071+ def count_duplicate_rpaths ( macho_file )
2072+ find_duplicate_rpaths ( macho_file ) . size
2073+ end
18712074end
18722075
18732076class CLIOptions
@@ -1912,7 +2115,8 @@ class CLIOptions
19122115 archive : true ,
19132116 archive_keep : false ,
19142117 patches : [ ] ,
1915- log_level : 'info'
2118+ log_level : 'info' ,
2119+ clean_macho_binary : nil
19162120 }
19172121 end
19182122
@@ -2128,6 +2332,11 @@ class CLIOptions
21282332 '--plan FILE' ,
21292333 'Follow given plan file, instead of using given git ref/sha'
21302334 ) { |v | options [ :plan ] = v }
2335+
2336+ opts . on (
2337+ '--clean-macho-binary FILE' ,
2338+ 'Tool to clean duplicate RPATHs from given Mach-O binary.'
2339+ ) { |v | options [ :clean_macho_binary ] = v }
21312340 end
21322341 end
21332342end
@@ -2144,6 +2353,17 @@ if __FILE__ == $PROGRAM_NAME
21442353 build . print_info
21452354 elsif cli_options [ :preview ]
21462355 build . print_preview
2356+ elsif cli_options [ :clean_macho_binary ]
2357+ macho_cleaner = MachOCleaner . new ( cli_options [ :clean_macho_binary ] )
2358+
2359+ if macho_cleaner . has_duplicate_rpaths?
2360+ build . info 'Removing duplicate RPATHs from ' \
2361+ "#{ cli_options [ :clean_macho_binary ] } ..."
2362+ macho_cleaner . clean!
2363+ build . info 'Cleaned duplicate RPATHs successfully!'
2364+ else
2365+ build . info 'No duplicate RPATHs found.'
2366+ end
21472367 else
21482368 build . build
21492369 end
0 commit comments