Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 61 additions & 4 deletions cipd_packages/ruby/third_party/ruby_ship/auto_relink_dylibs.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require "fileutils"
require "shellwords"



Expand All @@ -7,7 +8,54 @@

FileUtils.mkdir_p(@new_dylib_path)

def fix_dylib_for_file(file)
# Extracts the LC_RPATH entries from `otool -l` output.
def parse_rpaths(otool_l_output)
otool_l_output.scan(/cmd\s+LC_RPATH\s+cmdsize\s+\d+\s+path\s+(.+?)\s+\(offset\s+\d+\)/).flatten
end
Comment on lines +12 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current regular expression for parsing otool -l output assumes strict newline (\n) and spacing formatting. To make the parsing more robust against variations in whitespace, tabs, or line endings (e.g., carriage returns) across different macOS/Xcode versions, it is safer to use \s+ instead of literal newlines and spaces.

def parse_rpaths(otool_l_output)
	otool_l_output.scan(/cmd\s+LC_RPATH\s+cmdsize\s+\d+\s+path\s+(.+?)\s+\(offset\s+\d+\)/).flatten
end


# Returns the LC_RPATH entries of a Mach-O file.
def rpaths_for_file(file)
parse_rpaths(`otool -l #{Shellwords.escape(file)} 2> /dev/null`)
end
Comment on lines +17 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

Using string interpolation directly inside backticks (otool -l "#{file}") can lead to shell injection or command failures if the file path contains spaces, double quotes, or other shell metacharacters. It is highly recommended to escape the file path using Shellwords.escape to ensure the command executes safely and robustly.

def rpaths_for_file(file)
	require "shellwords" unless defined?(Shellwords)
	parse_rpaths(`otool -l #{Shellwords.escape(file)} 2> /dev/null`)
end


# Resolves a dependency reported by `otool -L` to a real file on disk.
#
# Absolute paths are returned unchanged. Homebrew bottles (e.g. brotli 1.2.0)
# now reference sibling libraries as `@rpath/libfoo.dylib` (or `@loader_path/`,
# `@executable_path/`), which is not a filesystem path and cannot be copied
# directly. For those, look for the library next to the referencing library's
# original location (source_dir), then in each of its LC_RPATH entries, then
# among the dylibs that were already copied. Returns nil if nothing matches.
#
# `@loader_path` is expanded relative to source_dir. `@executable_path` depends
# on whichever executable ends up loading the library, which is not known at
# packaging time, so those references are only matched by basename.
#
# See https://github.com/flutter/flutter/issues/164665 and
# https://ci.chromium.org/b/8669644619614462833.
def resolve_dylib_path(libfile, source_dir, rpaths)
return libfile unless libfile.start_with?("@")

basename = File.basename(libfile)
candidates = []
if libfile.start_with?("@loader_path/")
candidates << File.expand_path(libfile.sub(/\A@loader_path/, source_dir))
end
candidates << File.join(source_dir, basename)
rpaths.each do |rpath|
next if rpath.start_with?("@executable_path")
rpath = File.expand_path(rpath.sub(/\A@loader_path/, source_dir))
candidates << File.join(rpath, basename)
end
candidates << File.join(@new_dylib_path, basename)

candidates.find { |candidate| File.file?(candidate) }
end
Comment on lines +36 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Currently, @executable_path is resolved to source_dir (the directory of the referring library). However, @executable_path refers to the directory of the main executable (e.g., bin/darwin_ruby/bin/), whereas @loader_path refers to the directory of the referring library. If a library references a dependency via @executable_path, resolving it to source_dir will fail to find the dependency if the referring library is located in dylibs/ or lib/.

We can resolve @executable_path correctly by computing the executable directory relative to @new_dylib_path (which is File.expand_path(File.join(@new_dylib_path, "..", "bin"))).

Additionally, we can simplify the substitution logic and use the more idiomatic File.basename instead of File.split(libfile)[-1].

def resolve_dylib_path(libfile, source_dir, rpaths)
	return libfile unless libfile.start_with?("@")

	basename = File.basename(libfile)
	executable_dir = File.expand_path(File.join(@new_dylib_path, "..", "bin"))
	candidates = []
	if libfile.start_with?("@loader_path/", "@executable_path/")
		resolved_libfile = libfile.sub(/\A@loader_path/, source_dir).sub(/\A@executable_path/, executable_dir)
		candidates << File.expand_path(resolved_libfile)
	end
	candidates << File.join(source_dir, basename)
	rpaths.each do |rpath|
		resolved_rpath = rpath.sub(/\A@loader_path/, source_dir).sub(/\A@executable_path/, executable_dir)
		candidates << File.join(File.expand_path(resolved_rpath), basename)
	end
	candidates << File.join(@new_dylib_path, basename)

	candidates.find { |candidate| File.file?(candidate) }
end


# `source_dir` is the directory the file was originally copied from. It is used
# to resolve `@rpath`-style dependencies, since a copied dylib no longer sits
# next to its siblings.
def fix_dylib_for_file(file, source_dir = File.dirname(File.expand_path(file)))
results = `otool -L "#{file}"` #Will get information about which dylibs to link

if results.is_a?(String) && results != "" && !results.include?("is not an object file") && !results.include?("Assertion failed:")
Expand All @@ -25,6 +73,7 @@ def fix_dylib_for_file(file)
itterate = lines[1..-1]
itterate = [] unless itterate

rpaths = nil

itterate.each do |libfile_line|
libfile = libfile_line.split(" (compatibility version")[0].strip
Expand All @@ -34,14 +83,22 @@ def fix_dylib_for_file(file)
next if libfile.include?("/usr/lib/") # These are global and assumed to be present on all versions of osx
next if libfile.include?("/System/Library/") # Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation

new_libfile = File.join(@new_dylib_path, File.split(libfile)[-1])
rpaths ||= rpaths_for_file(file) if libfile.start_with?("@")
source_libfile = resolve_dylib_path(libfile, source_dir, rpaths || [])
if source_libfile.nil?
puts "ERROR: could not resolve #{libfile} referenced by #{file} (source dir: #{source_dir}, rpaths: #{rpaths.inspect})"
exit 1
end

new_libfile = File.join(@new_dylib_path, File.split(source_libfile)[-1])

unless File.file?(new_libfile)
FileUtils.copy(libfile, new_libfile)
FileUtils.copy(source_libfile, new_libfile)
puts "--COPIED-- #{new_libfile}"
fix_dylib_for_file(new_libfile)
fix_dylib_for_file(new_libfile, File.dirname(source_libfile))
end

# `libfile` must be the exact string from the load command (e.g. `@rpath/libfoo.dylib`).
relink_command_results = `install_name_tool -change #{libfile} #{new_libfile} #{file} 2> /dev/null` # Will relink external library
puts "Linked: #{new_libfile} to #{file}"

Expand Down
11 changes: 8 additions & 3 deletions cipd_packages/ruby/third_party/ruby_ship/ruby_ship_build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ echo "############################"
cd $DIR/../cleanup/extracted_ruby/$RUBYDIR
if [[ "$OS" == "darwin" ]]; then
OPTS=""
OPTS+="$(brew --prefix openssl)"
OPTS+="$(brew --prefix openssl@3)"
OPTS+=":$(brew --prefix readline)"
OPTS+=":$(brew --prefix libyaml)"
OPTS+=":$(brew --prefix gdbm)"
Expand All @@ -71,7 +71,7 @@ find $(brew --prefix gdbm)/lib/ -name '*.dylib' -exec cp {} -f "$DIR/../build/bi
find $(brew --prefix libffi)/lib/ -name '*.dylib' -exec cp {} -f "$DIR/../build/bin/darwin_ruby/dylibs/" \;
find $(brew --prefix libyaml)/lib/ -name '*.dylib' -exec cp {} -f "$DIR/../build/bin/darwin_ruby/dylibs/" \;
find $(brew --prefix openldap)/lib/ -name '*.dylib' -exec cp {} -f "$DIR/../build/bin/darwin_ruby/dylibs/" \;
find $(brew --prefix openssl)/lib/ -name '*.dylib' -exec cp {} -f "$DIR/../build/bin/darwin_ruby/dylibs/" \;
find $(brew --prefix openssl@3)/lib/ -name '*.dylib' -exec cp {} -f "$DIR/../build/bin/darwin_ruby/dylibs/" \;
find $(brew --prefix readline)/lib/ -name '*.dylib' -exec cp {} -f "$DIR/../build/bin/darwin_ruby/dylibs/" \;

# Setting up reference directories.
Expand Down Expand Up @@ -239,12 +239,17 @@ codesign --force -s - $DIR/../build/bin/darwin_ruby/bin/ruby
remove_dylib_signatures "$DIR/../build/bin/darwin_ruby/dylibs"
# Install bundler
$DIR/../build/bin/gem cleanup bundler
$DIR/../build/bin/gem install -f bundler
$DIR/../build/bin/gem install -f bundler -v '~> 2.5.0' # bundler >= 2.7 requires Ruby >= 3.2.
remove_dylib_signatures "$DIR/../build/bin/darwin_ruby/dylibs"

# Install cococoapods
$DIR/../build/bin/gem install concurrent-ruby -v 1.3.4 # 1.3.5+ dropped the implicit require "logger" that activesupport 7.0 relies on.
$DIR/../build/bin/gem install activesupport -v 7.0.8 # Pin this dep version.
$DIR/../build/bin/gem install cocoapods -v $COCOAPODS_VERSION
# RubyGems 3.3 (bundled with Ruby 3.1) still upgrades already-satisfied dependencies to the newest
# release, so activesupport/cocoapods pull in a second, newer concurrent-ruby that would be the one
# activated at runtime. Remove anything other than the pinned 1.3.4 (no-op if nothing else is installed).
$DIR/../build/bin/gem uninstall concurrent-ruby -a -x -I -v '> 1.3.4'
remove_dylib_signatures "$DIR/../build/bin/darwin_ruby/dylibs"

# Cleanup temp folder.
Expand Down
Loading