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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
.ruby-version
.idea/
.vscode/
mise.toml

# macOS metadata file
.DS_Store
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,4 @@ DEPENDENCIES
simplecov-cobertura

BUNDLED WITH
2.3.5
4.0.6
6 changes: 3 additions & 3 deletions lib/macho/fat_file.rb
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ def populate_fields
# All load commands responsible for loading dylibs in the file's Mach-O's.
# @return [Array<LoadCommands::DylibCommand>] an array of DylibCommands
def dylib_load_commands
machos.map(&:dylib_load_commands).flatten
machos.flat_map(&:dylib_load_commands)
end

# Changes the file's dylib ID to `new_id`. If the file is not a dylib,
Expand Down Expand Up @@ -199,7 +199,7 @@ def linked_dylibs
# Individual architectures in a fat binary can link to different subsets
# of libraries, but at this point we want to have the full picture, i.e.
# the union of all libraries used by all architectures.
machos.map(&:linked_dylibs).flatten.uniq
machos.flat_map(&:linked_dylibs).uniq
end

# Changes all dependent shared library install names from `old_name` to
Expand Down Expand Up @@ -229,7 +229,7 @@ def change_install_name(old_name, new_name, options = {})
# @see MachOFile#rpaths
def rpaths
# Can individual architectures have different runtime paths?
machos.map(&:rpaths).flatten.uniq
machos.flat_map(&:rpaths).uniq
end

# Change the runtime path `old_path` to `new_path` in the file's Mach-Os.
Expand Down
72 changes: 47 additions & 25 deletions lib/macho/macho_file.rb
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ def cpusubtype
# @return [Array<LoadCommands::LoadCommand>] an array of load commands
# corresponding to `name`
def command(name)
load_commands.select { |lc| lc.type == name.to_sym }
@load_commands_by_type.fetch(name.to_sym, [])
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

command now returns the underlying cached array from @load_commands_by_type. Previously this method returned a fresh array via select, so callers could mutate the result without affecting internal state. To avoid an unintended public API behavior change (and accidental corruption of the type index), return a copy (e.g., dup) of the cached array.

Suggested change
@load_commands_by_type.fetch(name.to_sym, [])
(@load_commands_by_type[name.to_sym] || []).dup

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

I think this and the others are probably fine in practice, since callers have no reason to ever mutate these returned arrays in place (and we never guaranteed that they'd be fresh values for callers).

Copy link
Member

@Bo98 Bo98 Feb 11, 2026

Choose a reason for hiding this comment

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

It's a reasonable point to be fair. We could alternatively .freeze on these to force the caller to make an explicit .dup if they need to mutate but that would be an API change to do that now though.

Looking at Homebrew, I already see cases where we are mutating so we probably can't ignore this:

https://github.com/Homebrew/brew/blob/8ab39821c51ff591456be48a0ff5f8f55f44511b/Library/Homebrew/os/mac/mach.rb#L119

https://github.com/Homebrew/brew/blob/8ab39821c51ff591456be48a0ff5f8f55f44511b/Library/Homebrew/os/mac/mach.rb#L108

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh well. Okay, I'll change it to copies for now, and we can always make them frozen with the next major release.

end

alias [] command
Expand Down Expand Up @@ -245,21 +245,22 @@ def delete_command(lc, options = {})
# The exception to this rule is when methods like {#add_command} and
# {#delete_command} have been called with `repopulate = false`.
def populate_fields
clear_memoization_cache
@header = populate_mach_header
@load_commands = populate_load_commands
end

# All load commands responsible for loading dylibs.
# @return [Array<LoadCommands::DylibCommand>] an array of DylibCommands
def dylib_load_commands
load_commands.select { |lc| LoadCommands::DYLIB_LOAD_COMMANDS.include?(lc.type) }
@dylib_load_commands ||= load_commands.select { |lc| LoadCommands::DYLIB_LOAD_COMMANDS.include?(lc.type) }
end
Comment on lines 255 to 257
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

These memoized array-returning methods now return the same array instance on subsequent calls. Previously each call produced a new array, so external mutation of the returned value couldn't affect later results. To preserve the prior API behavior while keeping the computation memoized, consider memoizing the contents but returning a defensive copy (e.g., (@dylib_load_commands ||= ...).dup).

Copilot uses AI. Check for mistakes.

# All segment load commands in the Mach-O.
# @return [Array<LoadCommands::SegmentCommand>] if the Mach-O is 32-bit
# @return [Array<LoadCommands::SegmentCommand64>] if the Mach-O is 64-bit
def segments
if magic32?
@segments ||= if magic32?
command(:LC_SEGMENT)
else
command(:LC_SEGMENT_64)
Expand All @@ -271,26 +272,7 @@ def segments
# @note This is **not** the same as {#alignment}!
# @note See `get_align` and `get_align_64` in `cctools/misc/lipo.c`
def segment_alignment
# special cases: 12 for x86/64/PPC/PP64, 14 for ARM/ARM64
return 12 if %i[i386 x86_64 ppc ppc64].include?(cputype)
return 14 if %i[arm arm64].include?(cputype)

cur_align = Sections::MAX_SECT_ALIGN

segments.each do |segment|
if filetype == :object
# start with the smallest alignment, and work our way up
align = magic32? ? 2 : 3
segment.sections.each do |section|
align = section.align unless section.align <= align
end
else
align = segment.guess_align
end
cur_align = align if align < cur_align
end

cur_align
@segment_alignment ||= calculate_segment_alignment
end

# The Mach-O's dylib ID, or `nil` if not a dylib.
Expand Down Expand Up @@ -338,7 +320,7 @@ def linked_dylibs
# library, but at this point we're really only interested in a list of
# unique libraries this Mach-O file links to, thus: `uniq`. (This is also
# for consistency with `FatFile` that merges this list across all archs.)
dylib_load_commands.map(&:name).map(&:to_s).uniq
@linked_dylibs ||= dylib_load_commands.map { |lc| lc.name.to_s }.uniq
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

linked_dylibs is now memoized and will return the same array instance on subsequent calls. Because this is a public API, callers mutating the returned array will now affect later results (previously each call constructed a new array). Consider returning a defensive copy of the memoized array to preserve prior behavior.

Suggested change
@linked_dylibs ||= dylib_load_commands.map { |lc| lc.name.to_s }.uniq
@linked_dylibs ||= dylib_load_commands.map { |lc| lc.name.to_s }.uniq
@linked_dylibs.dup

Copilot uses AI. Check for mistakes.
end

# Changes the shared library `old_name` to `new_name`
Expand Down Expand Up @@ -368,7 +350,7 @@ def change_install_name(old_name, new_name, _options = {})
# All runtime paths searched by the dynamic linker for the Mach-O.
# @return [Array<String>] an array of all runtime paths
def rpaths
command(:LC_RPATH).map(&:path).map(&:to_s)
@rpaths ||= command(:LC_RPATH).map { |lc| lc.path.to_s }
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

rpaths is now memoized and returns a cached array instance. This can be an observable API change if any caller mutates the array, since subsequent calls will reflect that mutation (previously each call built a new array). Consider returning a defensive copy of the cached value.

Suggested change
@rpaths ||= command(:LC_RPATH).map { |lc| lc.path.to_s }
@rpaths ||= command(:LC_RPATH).map { |lc| lc.path.to_s }
@rpaths.dup

Copilot uses AI. Check for mistakes.
end

# Changes the runtime path `old_path` to `new_path`
Expand Down Expand Up @@ -475,6 +457,18 @@ def to_h

private

# Clears all memoized values. Called when the file is repopulated.
# @return [void]
# @api private
def clear_memoization_cache
@linked_dylibs = nil
@rpaths = nil
@dylib_load_commands = nil
@segments = nil
@load_commands_by_type = nil
@segment_alignment = nil
end

# The file's Mach-O header structure.
# @return [Headers::MachHeader] if the Mach-O is 32-bit
# @return [Headers::MachHeader64] if the Mach-O is 64-bit
Expand Down Expand Up @@ -589,6 +583,7 @@ def populate_load_commands
permissive = options.fetch(:permissive, false)
offset = header.class.bytesize
load_commands = []
@load_commands_by_type = Hash.new { |h, k| h[k] = [] }

header.ncmds.times do
fmt = Utils.specialize_format("L=", endianness)
Expand All @@ -609,12 +604,39 @@ def populate_load_commands
command = klass.new_from_bin(view)

load_commands << command
@load_commands_by_type[command.type] << command
offset += command.cmdsize
end

load_commands
end

# Calculate the segment alignment for the Mach-O. Guesses conservatively.
# @return [Integer] the alignment, as a power of 2
# @api private
def calculate_segment_alignment
# special cases: 12 for x86/64/PPC/PP64, 14 for ARM/ARM64
return 12 if %i[i386 x86_64 ppc ppc64].include?(cputype)
return 14 if %i[arm arm64].include?(cputype)

cur_align = Sections::MAX_SECT_ALIGN

segments.each do |segment|
if filetype == :object
# start with the smallest alignment, and work our way up
align = magic32? ? 2 : 3
segment.sections.each do |section|
align = section.align unless section.align <= align
end
else
align = segment.guess_align
end
cur_align = align if align < cur_align
end

cur_align
end

# The low file offset (offset to first section data).
# @return [Integer] the offset
# @api private
Expand Down