From d8d7939c94d62bc995fa040e7ac04b7554a77bbf Mon Sep 17 00:00:00 2001 From: Hector Date: Wed, 10 Dec 2025 10:37:15 +0000 Subject: [PATCH] Add insight for 16kb page ready --- src/launchpad/size/analyzers/android.py | 2 + .../insights/android/sixteen_kb_page_ready.py | 103 +++++ src/launchpad/size/models/android.py | 4 + src/launchpad/size/models/insights.py | 14 + .../test_sixteen_kb_page_ready_insight.py | 416 ++++++++++++++++++ 5 files changed, 539 insertions(+) create mode 100644 src/launchpad/size/insights/android/sixteen_kb_page_ready.py create mode 100644 tests/unit/size/test_sixteen_kb_page_ready_insight.py diff --git a/src/launchpad/size/analyzers/android.py b/src/launchpad/size/analyzers/android.py index a0bdd2fa..a4adc819 100644 --- a/src/launchpad/size/analyzers/android.py +++ b/src/launchpad/size/analyzers/android.py @@ -14,6 +14,7 @@ from launchpad.size.hermes.utils import make_hermes_reports from launchpad.size.insights.android.image_optimization import WebPOptimizationInsight from launchpad.size.insights.android.multiple_native_library_arch import MultipleNativeLibraryArchInsight +from launchpad.size.insights.android.sixteen_kb_page_ready import SixteenKBPageReadyInsight from launchpad.size.insights.common.duplicate_files import DuplicateFilesInsight from launchpad.size.insights.common.hermes_debug_info import HermesDebugInfoInsight from launchpad.size.insights.common.large_audios import LargeAudioFileInsight @@ -118,6 +119,7 @@ def analyze(self, artifact: AndroidArtifact) -> AndroidAnalysisResults: large_audio=LargeAudioFileInsight().generate(insights_input), hermes_debug_info=HermesDebugInfoInsight().generate(insights_input), multiple_native_library_archs=MultipleNativeLibraryArchInsight().generate(insights_input), + sixteen_kb_page_ready=SixteenKBPageReadyInsight().generate(insights_input), ) analysis_duration = time.time() - start_time diff --git a/src/launchpad/size/insights/android/sixteen_kb_page_ready.py b/src/launchpad/size/insights/android/sixteen_kb_page_ready.py new file mode 100644 index 00000000..d42d1f9a --- /dev/null +++ b/src/launchpad/size/insights/android/sixteen_kb_page_ready.py @@ -0,0 +1,103 @@ +"""16KB page ready insight for Android apps.""" + +from pathlib import Path + +import lief + +from launchpad.size.insights.insight import Insight, InsightsInput +from launchpad.size.models.insights import SixteenKBPageReadyInsightResult +from launchpad.utils.logging import get_logger + +logger = get_logger(__name__) + + +class SixteenKBPageReadyInsight(Insight[SixteenKBPageReadyInsightResult]): + """Analyze whether Android app is ready for 16KB page size devices. + + This insight checks ELF section alignment in native libraries (.so files) for + arm64-v8a and x86_64 architectures. For 16KB page compatibility, all ELF sections + with alignment requirements must be aligned to at least 16KB (2^16 bytes). + + Only arm64-v8a and x86_64 architectures need to be aligned for 16KB page compatibility. + """ + + # Architectures that need 16KB alignment + TARGET_ARCHITECTURES = {"arm64-v8a", "x86_64"} + + def generate(self, input: InsightsInput) -> SixteenKBPageReadyInsightResult | None: + """Check if the app is ready for 16KB page sizes.""" + unaligned_files: list[str] = [] + + # Find all .so files in the relevant architectures + for file_info in input.file_analysis.files: + if not file_info.path.endswith(".so"): + continue + + # Extract architecture from path (lib//*.so) + path_parts = Path(file_info.path).parts + if len(path_parts) < 3 or path_parts[0] != "lib": + continue + + arch = path_parts[1] + if arch not in self.TARGET_ARCHITECTURES: + logger.debug(f"Skipping {file_info.path} - architecture {arch} not in target architectures") + continue + + # Check ELF alignment using the actual file path + if self._check_elf_alignment(file_info.full_path, file_info.path): + unaligned_files.append(file_info.path) + logger.debug(f"Found unaligned file: {file_info.path}") + + is_ready = len(unaligned_files) == 0 + logger.info(f"16KB page ready analysis complete: ready={is_ready}, unaligned_files={len(unaligned_files)}") + + return SixteenKBPageReadyInsightResult( + is_16kb_ready=is_ready, + unaligned_files=unaligned_files, + total_unaligned_files=len(unaligned_files), + ) + + def _check_elf_alignment(self, file_path: Path, relative_path: str) -> bool: + """Check if an ELF file has proper 16KB alignment. + + Args: + file_path: Absolute path to the ELF file + relative_path: Relative path for logging + + Returns: + True if the file has alignment issues, False if properly aligned + """ + try: + # Parse the ELF file using LIEF + elf_binary = lief.parse(str(file_path)) + if not elf_binary: + logger.warning(f"Could not parse ELF file {relative_path}") + return False + + # Check each section's alignment + for section in elf_binary.sections: + # LIEF sections might not have alignment attribute or it might be None + alignment = getattr(section, "alignment", None) + if alignment is None: + continue + + # 0 or 1 means no alignment restriction + if alignment <= 1: + continue + + # Check if alignment is >= 16KB (2^16) + if alignment >= 16 * 1024: # 16KB = 16384 bytes + continue + + logger.warning( + f"Android .so unaligned: {relative_path} section '{getattr(section, 'name', 'unknown')}' " + f"alignment={alignment} (needs >= 16384)" + ) + return True + + return False + + except Exception as e: + logger.error(f"Error analyzing ELF file {relative_path}: {e}") + # Don't consider it unaligned if we can't parse it + return False diff --git a/src/launchpad/size/models/android.py b/src/launchpad/size/models/android.py index e09a6085..0a158ee5 100644 --- a/src/launchpad/size/models/android.py +++ b/src/launchpad/size/models/android.py @@ -8,6 +8,7 @@ LargeImageFileInsightResult, LargeVideoFileInsightResult, MultipleNativeLibraryArchInsightResult, + SixteenKBPageReadyInsightResult, WebPOptimizationInsightResult, ) @@ -24,6 +25,9 @@ class AndroidInsightResults(BaseModel): multiple_native_library_archs: MultipleNativeLibraryArchInsightResult | None = Field( None, description="Multiple native library architectures analysis" ) + sixteen_kb_page_ready: SixteenKBPageReadyInsightResult | None = Field( + None, description="16KB page size readiness analysis" + ) class AndroidAppInfo(BaseAppInfo): diff --git a/src/launchpad/size/models/insights.py b/src/launchpad/size/models/insights.py index b507d317..647feadf 100644 --- a/src/launchpad/size/models/insights.py +++ b/src/launchpad/size/models/insights.py @@ -243,3 +243,17 @@ class MultipleNativeLibraryArchInsightResult(FilesInsightResult): """ pass + + +class SixteenKBPageReadyInsightResult(BaseModel): + """Results from 16KB page ready analysis. + + Indicates whether the Android app is ready for 16KB page size devices. + This checks ELF section alignment in native libraries for arm64-v8a and x86_64 architectures. + """ + + model_config = ConfigDict(frozen=True) + + is_16kb_ready: bool = Field(..., description="Whether the app is ready for 16KB page sizes") + unaligned_files: List[str] = Field(default_factory=list, description="List of files with alignment issues") + total_unaligned_files: int = Field(default=0, ge=0, description="Total number of files with alignment issues") diff --git a/tests/unit/size/test_sixteen_kb_page_ready_insight.py b/tests/unit/size/test_sixteen_kb_page_ready_insight.py new file mode 100644 index 00000000..fc6e5af3 --- /dev/null +++ b/tests/unit/size/test_sixteen_kb_page_ready_insight.py @@ -0,0 +1,416 @@ +"""Tests for the 16KB page ready insight.""" + +import tempfile + +from pathlib import Path +from unittest.mock import Mock, patch + +from launchpad.size.insights.android.sixteen_kb_page_ready import SixteenKBPageReadyInsight +from launchpad.size.insights.insight import InsightsInput +from launchpad.size.models.common import BaseAppInfo, FileAnalysis, FileInfo +from launchpad.size.models.insights import SixteenKBPageReadyInsightResult +from launchpad.size.models.treemap import TreemapType + + +class TestSixteenKBPageReadyInsight: + def setup_method(self): + self.insight = SixteenKBPageReadyInsight() + + def _create_insights_input(self, files: list[FileInfo]) -> InsightsInput: + file_analysis = FileAnalysis(items=files) + return InsightsInput( + app_info=BaseAppInfo(name="TestApp", version="1.0", build="1", app_id="com.testapp"), + file_analysis=file_analysis, + treemap=None, + binary_analysis=[], + ) + + def _create_mock_elf_binary(self, sections_alignments: list[int]): + """Create a mock ELF binary with sections having the specified alignments.""" + mock_binary = Mock() + mock_sections = [] + + for i, alignment in enumerate(sections_alignments): + mock_section = Mock() + mock_section.alignment = alignment + mock_section.name = f"section_{i}" + mock_sections.append(mock_section) + + mock_binary.sections = mock_sections + return mock_binary + + def test_all_libraries_aligned_16kb_ready(self): + """Test that app is 16KB ready when all libraries have proper alignment.""" + with tempfile.TemporaryDirectory() as temp_dir: + # Create mock files + arm64_lib = Path(temp_dir) / "lib" / "arm64-v8a" / "libnative.so" + x86_64_lib = Path(temp_dir) / "lib" / "x86_64" / "libutils.so" + + files = [ + FileInfo( + full_path=arm64_lib, + path="lib/arm64-v8a/libnative.so", + size=500000, + file_type="so", + treemap_type=TreemapType.NATIVE_LIBRARIES, + hash="arm64_native_hash", + is_dir=False, + ), + FileInfo( + full_path=x86_64_lib, + path="lib/x86_64/libutils.so", + size=400000, + file_type="so", + treemap_type=TreemapType.NATIVE_LIBRARIES, + hash="x86_64_utils_hash", + is_dir=False, + ), + ] + + # Mock LIEF to return properly aligned sections + mock_binary_arm64 = self._create_mock_elf_binary([0, 16384, 32768]) # All >= 16KB + mock_binary_x86_64 = self._create_mock_elf_binary([1, 65536, 16384]) # All >= 16KB + + with patch("lief.parse") as mock_parse: + mock_parse.side_effect = [mock_binary_arm64, mock_binary_x86_64] + + insights_input = self._create_insights_input(files) + result = self.insight.generate(insights_input) + + assert isinstance(result, SixteenKBPageReadyInsightResult) + assert result.is_16kb_ready is True + assert result.unaligned_files == [] + assert result.total_unaligned_files == 0 + + def test_some_libraries_unaligned_not_16kb_ready(self): + """Test that app is not 16KB ready when some libraries have improper alignment.""" + with tempfile.TemporaryDirectory() as temp_dir: + # Create mock files + arm64_lib = Path(temp_dir) / "lib" / "arm64-v8a" / "libnative.so" + x86_64_lib = Path(temp_dir) / "lib" / "x86_64" / "libutils.so" + + files = [ + FileInfo( + full_path=arm64_lib, + path="lib/arm64-v8a/libnative.so", + size=500000, + file_type="so", + treemap_type=TreemapType.NATIVE_LIBRARIES, + hash="arm64_native_hash", + is_dir=False, + ), + FileInfo( + full_path=x86_64_lib, + path="lib/x86_64/libutils.so", + size=400000, + file_type="so", + treemap_type=TreemapType.NATIVE_LIBRARIES, + hash="x86_64_utils_hash", + is_dir=False, + ), + ] + + # Mock LIEF: arm64 properly aligned, x86_64 has alignment issues + mock_binary_arm64 = self._create_mock_elf_binary([0, 16384, 32768]) # All >= 16KB + mock_binary_x86_64 = self._create_mock_elf_binary([1, 8192, 16384]) # One section < 16KB + + with patch("lief.parse") as mock_parse: + mock_parse.side_effect = [mock_binary_arm64, mock_binary_x86_64] + + insights_input = self._create_insights_input(files) + result = self.insight.generate(insights_input) + + assert isinstance(result, SixteenKBPageReadyInsightResult) + assert result.is_16kb_ready is False + assert "lib/x86_64/libutils.so" in result.unaligned_files + assert "lib/arm64-v8a/libnative.so" not in result.unaligned_files + assert result.total_unaligned_files == 1 + + def test_only_target_architectures_checked(self): + """Test that only arm64-v8a and x86_64 architectures are checked.""" + with tempfile.TemporaryDirectory() as temp_dir: + files = [ + # arm64-v8a (should be checked) + FileInfo( + full_path=Path(temp_dir) / "lib" / "arm64-v8a" / "libnative.so", + path="lib/arm64-v8a/libnative.so", + size=500000, + file_type="so", + treemap_type=TreemapType.NATIVE_LIBRARIES, + hash="arm64_native_hash", + is_dir=False, + ), + # x86_64 (should be checked) + FileInfo( + full_path=Path(temp_dir) / "lib" / "x86_64" / "libutils.so", + path="lib/x86_64/libutils.so", + size=400000, + file_type="so", + treemap_type=TreemapType.NATIVE_LIBRARIES, + hash="x86_64_utils_hash", + is_dir=False, + ), + # x86 (should NOT be checked) + FileInfo( + full_path=Path(temp_dir) / "lib" / "x86" / "libother.so", + path="lib/x86/libother.so", + size=300000, + file_type="so", + treemap_type=TreemapType.NATIVE_LIBRARIES, + hash="x86_other_hash", + is_dir=False, + ), + # armeabi-v7a (should NOT be checked) + FileInfo( + full_path=Path(temp_dir) / "lib" / "armeabi-v7a" / "liblegacy.so", + path="lib/armeabi-v7a/liblegacy.so", + size=350000, + file_type="so", + treemap_type=TreemapType.NATIVE_LIBRARIES, + hash="armv7_legacy_hash", + is_dir=False, + ), + ] + + # Mock LIEF: arm64 and x86_64 both have alignment issues + mock_binary_with_issues = self._create_mock_elf_binary([8192]) # < 16KB + + with patch("lief.parse") as mock_parse: + # Should only be called twice (arm64-v8a and x86_64) + mock_parse.side_effect = [mock_binary_with_issues, mock_binary_with_issues] + + insights_input = self._create_insights_input(files) + result = self.insight.generate(insights_input) + + assert isinstance(result, SixteenKBPageReadyInsightResult) + assert result.is_16kb_ready is False + assert len(result.unaligned_files) == 2 + assert "lib/arm64-v8a/libnative.so" in result.unaligned_files + assert "lib/x86_64/libutils.so" in result.unaligned_files + # These should NOT be in unaligned_files + assert "lib/x86/libother.so" not in result.unaligned_files + assert "lib/armeabi-v7a/liblegacy.so" not in result.unaligned_files + assert result.total_unaligned_files == 2 + + # Verify LIEF parse was called exactly twice + assert mock_parse.call_count == 2 + + def test_no_native_libraries_returns_16kb_ready(self): + """Test that apps with no native libraries are considered 16KB ready.""" + files = [ + FileInfo( + full_path=Path("assets/image.png"), + path="assets/image.png", + size=50000, + file_type="png", + treemap_type=TreemapType.ASSETS, + hash="image_hash", + is_dir=False, + ), + FileInfo( + full_path=Path("classes.dex"), + path="classes.dex", + size=1000000, + file_type="dex", + treemap_type=TreemapType.DEX, + hash="dex_hash", + is_dir=False, + ), + ] + + insights_input = self._create_insights_input(files) + result = self.insight.generate(insights_input) + + assert isinstance(result, SixteenKBPageReadyInsightResult) + assert result.is_16kb_ready is True + assert result.unaligned_files == [] + assert result.total_unaligned_files == 0 + + def test_no_target_architecture_libraries_returns_16kb_ready(self): + """Test that apps with only non-target architecture libraries are considered 16KB ready.""" + files = [ + FileInfo( + full_path=Path("lib/x86/libnative.so"), + path="lib/x86/libnative.so", + size=500000, + file_type="so", + treemap_type=TreemapType.NATIVE_LIBRARIES, + hash="x86_native_hash", + is_dir=False, + ), + FileInfo( + full_path=Path("lib/armeabi-v7a/libutils.so"), + path="lib/armeabi-v7a/libutils.so", + size=400000, + file_type="so", + treemap_type=TreemapType.NATIVE_LIBRARIES, + hash="armv7_utils_hash", + is_dir=False, + ), + ] + + insights_input = self._create_insights_input(files) + result = self.insight.generate(insights_input) + + assert isinstance(result, SixteenKBPageReadyInsightResult) + assert result.is_16kb_ready is True + assert result.unaligned_files == [] + assert result.total_unaligned_files == 0 + + def test_elf_parsing_error_handled_gracefully(self): + """Test that ELF parsing errors don't cause the insight to fail.""" + with tempfile.TemporaryDirectory() as temp_dir: + files = [ + FileInfo( + full_path=Path(temp_dir) / "lib" / "arm64-v8a" / "libnative.so", + path="lib/arm64-v8a/libnative.so", + size=500000, + file_type="so", + treemap_type=TreemapType.NATIVE_LIBRARIES, + hash="arm64_native_hash", + is_dir=False, + ), + ] + + with patch("lief.parse") as mock_parse: + # Simulate LIEF parsing error + mock_parse.side_effect = Exception("Failed to parse ELF file") + + insights_input = self._create_insights_input(files) + result = self.insight.generate(insights_input) + + # Should still return a result, considering the file as properly aligned + # since we couldn't determine otherwise + assert isinstance(result, SixteenKBPageReadyInsightResult) + assert result.is_16kb_ready is True + assert result.unaligned_files == [] + assert result.total_unaligned_files == 0 + + def test_lief_returns_none_handled_gracefully(self): + """Test that LIEF returning None is handled gracefully.""" + with tempfile.TemporaryDirectory() as temp_dir: + files = [ + FileInfo( + full_path=Path(temp_dir) / "lib" / "arm64-v8a" / "libnative.so", + path="lib/arm64-v8a/libnative.so", + size=500000, + file_type="so", + treemap_type=TreemapType.NATIVE_LIBRARIES, + hash="arm64_native_hash", + is_dir=False, + ), + ] + + with patch("lief.parse") as mock_parse: + # Simulate LIEF returning None (unparseable file) + mock_parse.return_value = None + + insights_input = self._create_insights_input(files) + result = self.insight.generate(insights_input) + + # Should still return a result, considering the file as properly aligned + assert isinstance(result, SixteenKBPageReadyInsightResult) + assert result.is_16kb_ready is True + assert result.unaligned_files == [] + assert result.total_unaligned_files == 0 + + def test_sections_with_zero_and_one_alignment_ignored(self): + """Test that sections with alignment 0 or 1 are ignored.""" + with tempfile.TemporaryDirectory() as temp_dir: + files = [ + FileInfo( + full_path=Path(temp_dir) / "lib" / "arm64-v8a" / "libnative.so", + path="lib/arm64-v8a/libnative.so", + size=500000, + file_type="so", + treemap_type=TreemapType.NATIVE_LIBRARIES, + hash="arm64_native_hash", + is_dir=False, + ), + ] + + # Mock LIEF with sections having alignments 0, 1 (should be ignored) and 8192 (< 16KB) + mock_binary = self._create_mock_elf_binary([0, 1, 8192]) + + with patch("lief.parse") as mock_parse: + mock_parse.return_value = mock_binary + + insights_input = self._create_insights_input(files) + result = self.insight.generate(insights_input) + + # Should detect the alignment issue from the 8192 alignment section + assert isinstance(result, SixteenKBPageReadyInsightResult) + assert result.is_16kb_ready is False + assert "lib/arm64-v8a/libnative.so" in result.unaligned_files + assert result.total_unaligned_files == 1 + + def test_empty_file_list_returns_16kb_ready(self): + """Test that empty file list returns 16KB ready.""" + insights_input = self._create_insights_input([]) + result = self.insight.generate(insights_input) + + assert isinstance(result, SixteenKBPageReadyInsightResult) + assert result.is_16kb_ready is True + assert result.unaligned_files == [] + assert result.total_unaligned_files == 0 + + def test_malformed_lib_path_ignored(self): + """Test that files with malformed lib paths are ignored.""" + files = [ + # Valid path + FileInfo( + full_path=Path("lib/arm64-v8a/libnative.so"), + path="lib/arm64-v8a/libnative.so", + size=500000, + file_type="so", + treemap_type=TreemapType.NATIVE_LIBRARIES, + hash="arm64_native_hash", + is_dir=False, + ), + # Malformed paths (should be ignored) + FileInfo( + full_path=Path("libnative.so"), + path="libnative.so", + size=400000, + file_type="so", + treemap_type=TreemapType.NATIVE_LIBRARIES, + hash="malformed1_hash", + is_dir=False, + ), + FileInfo( + full_path=Path("lib/libnative.so"), + path="lib/libnative.so", + size=300000, + file_type="so", + treemap_type=TreemapType.NATIVE_LIBRARIES, + hash="malformed2_hash", + is_dir=False, + ), + FileInfo( + full_path=Path("assets/lib/arm64-v8a/libnative.so"), + path="assets/lib/arm64-v8a/libnative.so", + size=200000, + file_type="so", + treemap_type=TreemapType.NATIVE_LIBRARIES, + hash="malformed3_hash", + is_dir=False, + ), + ] + + # Mock LIEF with proper alignment + mock_binary = self._create_mock_elf_binary([16384]) # >= 16KB + + with patch("lief.parse") as mock_parse: + mock_parse.return_value = mock_binary + + insights_input = self._create_insights_input(files) + result = self.insight.generate(insights_input) + + # Should only process the valid lib path + assert isinstance(result, SixteenKBPageReadyInsightResult) + assert result.is_16kb_ready is True + assert result.unaligned_files == [] + assert result.total_unaligned_files == 0 + + # Verify LIEF parse was called exactly once (only for valid file) + assert mock_parse.call_count == 1