Skip to content
Merged
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
54 changes: 48 additions & 6 deletions src/launchpad/parsers/apple/macho_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import lief
import sentry_sdk

from launchpad.size.models.apple import DyldInfo
from launchpad.size.models.apple import LinkEditInfo

from ...utils.logging import get_logger
from .binary_utils import parse_null_terminated_strings
Expand Down Expand Up @@ -300,11 +300,53 @@ def find_symbol(addr: int) -> lief.Symbol | None:
return symbols

@sentry_sdk.trace
def extract_dyld_info(self) -> DyldInfo:
"""Extract DYLD information from LC_DYLD_INFO load commands."""
def extract_linkedit_info(self) -> LinkEditInfo:
"""Extract all __LINKEDIT segment component sizes from load commands."""
symbol_table_size = 0
string_table_size = 0
function_starts_size = 0
segment_size = 0
chained_fixups_size = 0
export_trie_size = 0
code_signature_size = 0
code_signature_offset = 0

# Determine if binary is 64-bit (nlist_64 is 16 bytes, nlist is 12 bytes)
is_64bit = self.binary.header.magic in [
lief.MachO.MACHO_TYPES.MAGIC_64,
lief.MachO.MACHO_TYPES.CIGAM_64,
]
entry_size = 16 if is_64bit else 12

for cmd in self.binary.commands:
if isinstance(cmd, lief.MachO.SymbolCommand):
symbol_table_size = cmd.numberof_symbols * entry_size
string_table_size = cmd.strings_size
elif isinstance(cmd, lief.MachO.FunctionStarts):
function_starts_size = cmd.data_size

dyld_chained_fixups = self.binary.dyld_chained_fixups
dyld_exports_trie = self.binary.dyld_exports_trie
return DyldInfo(
chained_fixups_size=(dyld_chained_fixups.data_size if dyld_chained_fixups else 0),
export_trie_size=dyld_exports_trie.data_size if dyld_exports_trie else 0,
chained_fixups_size = dyld_chained_fixups.data_size if dyld_chained_fixups else 0
export_trie_size = dyld_exports_trie.data_size if dyld_exports_trie else 0

if self.binary.has_code_signature and self.binary.code_signature is not None:
cs = self.binary.code_signature
code_signature_size = cs.data_size
code_signature_offset = cs.data_offset

for segment in self.binary.segments:
if segment.name == "__LINKEDIT":
segment_size = segment.file_size
break

return LinkEditInfo(
segment_size=segment_size,
symbol_table_size=symbol_table_size,
string_table_size=string_table_size,
function_starts_size=function_starts_size,
chained_fixups_size=chained_fixups_size,
export_trie_size=export_trie_size,
code_signature_size=code_signature_size,
code_signature_offset=code_signature_offset,
)
4 changes: 2 additions & 2 deletions src/launchpad/size/analyzers/apple.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ def _analyze_binary(
objc_method_names = parser.parse_objc_method_names()
segments = self._extract_segments_info(parser.binary)
load_commands = self._extract_load_commands_info(parser.binary)
dyld_info = parser.extract_dyld_info()
linkedit_info = parser.extract_linkedit_info()

symbol_info = None
dwarf_relocations = None
Expand Down Expand Up @@ -496,9 +496,9 @@ def _analyze_binary(
segments=segments,
load_commands=load_commands,
header_size=parser.get_header_size(),
dyld_info=dyld_info,
dwarf_relocations=dwarf_relocations,
strippable_symbols_size=strippable_symbols_size,
linkedit_info=linkedit_info,
)

@sentry_sdk.trace
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ def generate(self, input: InsightsInput) -> MainBinaryExportMetadataResult | Non
if not analysis.is_main_binary:
continue

dyld_info = analysis.dyld_info
if dyld_info is None:
linkedit_info = analysis.linkedit_info
if linkedit_info is None:
continue

export_trie_size = dyld_info.export_trie_size
export_trie_size = linkedit_info.export_trie_size
if export_trie_size >= self.MIN_EXPORTS_THRESHOLD:
export_files.append(
FileSavingsResult(
Expand Down
24 changes: 21 additions & 3 deletions src/launchpad/size/models/apple.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,30 @@ class LoadCommandInfo:


@dataclass
class DyldInfo:
"""DYLD-specific information extracted from related DYLD load commands."""
class LinkEditInfo:
"""Link edit segment components extracted from various load commands in __LINKEDIT.

Consolidates all __LINKEDIT segment data including symbol tables, DYLD info, and code signature.
"""

# Segment
segment_size: int = 0

# Symbol tables (from LC_SYMTAB)
symbol_table_size: int = 0
string_table_size: int = 0

# Debugging (from LC_FUNCTION_STARTS)
function_starts_size: int = 0

# DYLD (from LC_DYLD_CHAINED_FIXUPS, LC_DYLD_EXPORTS_TRIE)
chained_fixups_size: int = 0
export_trie_size: int = 0

# Code signing (from LC_CODE_SIGNATURE)
code_signature_size: int = 0
code_signature_offset: int = 0


@dataclass
class MachOBinaryAnalysis:
Expand All @@ -150,9 +168,9 @@ class MachOBinaryAnalysis:
swift_metadata: SwiftMetadata | None = None
symbol_info: SymbolInfo | None = None
header_size: int = 0
dyld_info: DyldInfo | None = None
dwarf_relocations: DwarfRelocationsData | None = None
strippable_symbols_size: int = 0
linkedit_info: LinkEditInfo | None = None


@dataclass
Expand Down
90 changes: 73 additions & 17 deletions src/launchpad/size/treemap/macho_element_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,11 +435,11 @@ def _add_segments(
)
)

dyld_children_size = 0
linkedit_children_size = 0
if segment_name == "__LINKEDIT":
dyld_children = self._build_dyld_load_command_children(binary_analysis)
segment_children.extend(dyld_children)
dyld_children_size = sum(c.size for c in dyld_children)
linkedit_children = self._build_linkedit_children(binary_analysis)
segment_children.extend(linkedit_children)
linkedit_children_size = sum(c.size for c in linkedit_children)

displayed_section_size = sum(c.size for c in segment_children)

Expand All @@ -448,7 +448,7 @@ def _add_segments(
seg_total_size = segment.size

total_section_declared = sum(s.size for s in segment.sections) if segment.sections else 0
segment_overhead = seg_total_size - total_section_declared - dyld_children_size
segment_overhead = seg_total_size - total_section_declared - linkedit_children_size
actual_segment_size = displayed_section_size + max(0, segment_overhead)

if actual_segment_size > 0:
Expand Down Expand Up @@ -503,37 +503,93 @@ def _build_metadata_components(self, binary_analysis: MachOBinaryAnalysis) -> Li

return metadata_children

def _build_dyld_load_command_children(self, binary_analysis: MachOBinaryAnalysis) -> List[TreemapElement]:
dyld_children: List[TreemapElement] = []
di = binary_analysis.dyld_info
if di is None:
return dyld_children
def _build_linkedit_children(self, binary_analysis: MachOBinaryAnalysis) -> List[TreemapElement]:
"""Build child elements for the __LINKEDIT segment.

if di.chained_fixups_size > 0:
dyld_children.append(
Includes symbol table, string table, function starts, DYLD info, and code signature.
"""
linkedit_children: List[TreemapElement] = []

le = binary_analysis.linkedit_info
if le is None:
return linkedit_children

# Add symbol table and string table
if le.string_table_size > 0:
linkedit_children.append(
TreemapElement(
name="String Table",
size=le.string_table_size,
type=TreemapType.EXECUTABLES,
path=None,
is_dir=False,
children=[],
)
)

if le.symbol_table_size > 0:
linkedit_children.append(
TreemapElement(
name="Symbol Table",
size=le.symbol_table_size,
type=TreemapType.EXECUTABLES,
path=None,
is_dir=False,
children=[],
)
)

if le.function_starts_size > 0:
linkedit_children.append(
TreemapElement(
name="Function Starts",
size=le.function_starts_size,
type=TreemapType.EXECUTABLES,
path=None,
is_dir=False,
children=[],
)
)

# Add DYLD info
if le.chained_fixups_size > 0:
linkedit_children.append(
TreemapElement(
name="Chained Fixups",
size=di.chained_fixups_size,
size=le.chained_fixups_size,
type=TreemapType.DYLD,
path=None,
is_dir=False,
children=[],
)
)

if di.export_trie_size > 0:
dyld_children.append(
if le.export_trie_size > 0:
linkedit_children.append(
TreemapElement(
name="Export Trie",
size=di.export_trie_size,
size=le.export_trie_size,
type=TreemapType.DYLD,
path=None,
is_dir=False,
children=[],
)
)

return dyld_children
# Add code signature
if le.code_signature_size > 0:
linkedit_children.append(
TreemapElement(
name="Code Signature",
size=le.code_signature_size,
type=TreemapType.CODE_SIGNATURE,
path=None,
is_dir=False,
children=[],
)
)

return linkedit_children

def _add_unmapped_region(self, binary_analysis: MachOBinaryAnalysis, binary_children: List[TreemapElement]) -> None:
total_accounted = sum(c.size for c in binary_children)
Expand Down
20 changes: 19 additions & 1 deletion tests/integration/size/test_treemap_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,9 +361,27 @@ def find_node_by_path(root: TreemapElement, path: str) -> TreemapElement | None:
# Verify __LINKEDIT segment
has_linkedit = "__LINKEDIT" in main_exe_sections
assert has_linkedit
linkedit_size = main_exe_sections["__LINKEDIT"].size
linkedit = main_exe_sections["__LINKEDIT"]
linkedit_size = linkedit.size
assert linkedit_size == 269360

# Verify __LINKEDIT has children (symbol table, string table, etc.)
linkedit_children = {child.name: child for child in linkedit.children}
assert len(linkedit_children) == 6, "__LINKEDIT should have exactly 6 child components"

# Verify expected __LINKEDIT components exist
assert "Symbol Table" in linkedit_children, "Symbol table should be present"
assert "String Table" in linkedit_children, "String table should be present"
assert "Function Starts" in linkedit_children, "Function starts should be present"

# Verify exact sizes for __LINKEDIT components
assert linkedit_children["Symbol Table"].size == 11072, "Symbol table size mismatch"
assert linkedit_children["String Table"].size == 16584, "String table size mismatch"
assert linkedit_children["Function Starts"].size == 13584, "Function starts size mismatch"
assert linkedit_children["Chained Fixups"].size == 87616, "Chained fixups size mismatch"
assert linkedit_children["Export Trie"].size == 85056, "Export trie size mismatch"
assert linkedit_children["Code Signature"].size == 43488, "Code signature size mismatch"

# Verify Unmapped section (track size changes)
has_unmapped = "Unmapped" in main_exe_sections
assert has_unmapped
Expand Down
20 changes: 10 additions & 10 deletions tests/unit/test_main_binary_export_metadata_insight.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from launchpad.size.insights.apple.main_binary_export_metadata import MainBinaryExportMetadataInsight
from launchpad.size.insights.insight import InsightsInput
from launchpad.size.models.apple import DyldInfo, MachOBinaryAnalysis
from launchpad.size.models.apple import LinkEditInfo, MachOBinaryAnalysis
from launchpad.size.models.common import BaseAppInfo, FileAnalysis
from launchpad.size.models.insights import MainBinaryExportMetadataResult

Expand All @@ -26,7 +26,7 @@ def test_generate_with_main_binary_and_dyld_exports_trie(self):
swift_metadata=None,
is_main_binary=True,
header_size=32,
dyld_info=DyldInfo(export_trie_size=5000),
linkedit_info=LinkEditInfo(export_trie_size=5000),
)

insights_input = InsightsInput(
Expand All @@ -42,7 +42,7 @@ def test_generate_with_main_binary_and_dyld_exports_trie(self):
assert result.total_savings == 5000

def test_generate_with_main_binary_without_dyld_exports_trie(self):
"""Test that no insight is generated when main binary has no dyld_info."""
"""Test that no insight is generated when main binary has no linkedit_info."""
main_binary_analysis = MachOBinaryAnalysis(
binary_absolute_path=Path("MyApp"),
binary_relative_path=Path("MyApp"),
Expand All @@ -56,7 +56,7 @@ def test_generate_with_main_binary_without_dyld_exports_trie(self):
swift_metadata=None,
is_main_binary=True,
header_size=32,
dyld_info=None, # No dyld_info
linkedit_info=None, # No linkedit_info
)

insights_input = InsightsInput(
Expand Down Expand Up @@ -85,7 +85,7 @@ def test_generate_with_no_main_binary(self):
swift_metadata=None,
is_main_binary=False, # Not a main binary
header_size=32,
dyld_info=DyldInfo(export_trie_size=5000), # Has export trie but not main binary
linkedit_info=LinkEditInfo(export_trie_size=5000), # Has export trie but not main binary
)

insights_input = InsightsInput(
Expand Down Expand Up @@ -114,7 +114,7 @@ def test_generate_with_main_binary_but_empty_export_trie(self):
swift_metadata=None,
is_main_binary=True,
header_size=32,
dyld_info=DyldInfo(export_trie_size=0), # Zero-size export trie
linkedit_info=LinkEditInfo(export_trie_size=0), # Zero-size export trie
)

insights_input = InsightsInput(
Expand Down Expand Up @@ -156,7 +156,7 @@ def test_generate_with_multiple_binaries_one_main(self):
swift_metadata=None,
is_main_binary=True,
header_size=32,
dyld_info=DyldInfo(export_trie_size=8000),
linkedit_info=LinkEditInfo(export_trie_size=8000),
)

# Create framework binary (non-main)
Expand All @@ -173,7 +173,7 @@ def test_generate_with_multiple_binaries_one_main(self):
swift_metadata=None,
is_main_binary=False,
header_size=32,
dyld_info=DyldInfo(export_trie_size=3000), # Framework also has export trie but won't be included
linkedit_info=LinkEditInfo(export_trie_size=3000), # Framework also has export trie but won't be included
)

insights_input = InsightsInput(
Expand Down Expand Up @@ -203,7 +203,7 @@ def test_generate_with_export_trie_below_threshold(self):
swift_metadata=None,
is_main_binary=True,
header_size=32,
dyld_info=DyldInfo(export_trie_size=512), # Below MIN_EXPORTS_THRESHOLD (1024)
linkedit_info=LinkEditInfo(export_trie_size=512), # Below MIN_EXPORTS_THRESHOLD (1024)
)

insights_input = InsightsInput(
Expand Down Expand Up @@ -232,7 +232,7 @@ def test_generate_with_export_trie_at_threshold(self):
swift_metadata=None,
is_main_binary=True,
header_size=32,
dyld_info=DyldInfo(export_trie_size=1024), # Exactly at MIN_EXPORTS_THRESHOLD
linkedit_info=LinkEditInfo(export_trie_size=1024), # Exactly at MIN_EXPORTS_THRESHOLD
)

insights_input = InsightsInput(
Expand Down
Loading