From 0f766c08f78ff8d177f6a779778f8c9872b66c4b Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 16 May 2026 00:37:46 +0200 Subject: [PATCH] fix(linter): skip foreign-arch ELF files in library linter The library linter calls load_dependencies() on every ELF file found in the prime directory. For foreign-arch builds (e.g. arm64 snap on an amd64 host) this invokes QEMU via binfmt to run the foreign binary, which fails with 'not a dynamic executable' errors and can SEGFAULT, producing multi-gigabyte core dumps. Add elf_machine field to _ArchConfig and get_host_elf_machine() to elf_utils.py. In library_linter.run(), skip any ELF file whose arch_tuple[2] (e_machine) does not match the host's expected value. Fixes: https://github.com/canonical/snapcraft/issues/4373 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- snapcraft/elf/elf_utils.py | 31 +++++-- snapcraft/linters/library_linter.py | 14 +++ .../linters/test_library_linter.py | 88 +++++++++++++++++++ tests/unit/conftest.py | 12 +++ tests/unit/linters/test_library_linter.py | 48 ++++++++++ 5 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 tests/integration/linters/test_library_linter.py diff --git a/snapcraft/elf/elf_utils.py b/snapcraft/elf/elf_utils.py index 38121b27f0..24dc6a7a1c 100644 --- a/snapcraft/elf/elf_utils.py +++ b/snapcraft/elf/elf_utils.py @@ -95,16 +95,23 @@ def get_elf_files_from_list(root: Path, file_list: Iterable[str]) -> list[ElfFil class _ArchConfig: arch_triplet: str dynamic_linker: str + elf_machine: str _ARCH_CONFIG = { - "aarch64": _ArchConfig("aarch64-linux-gnu", "lib/ld-linux-aarch64.so.1"), - "armv7l": _ArchConfig("arm-linux-gnueabihf", "lib/ld-linux-armhf.so.3"), - "ppc64le": _ArchConfig("powerpc64le-linux-gnu", "lib64/ld64.so.2"), - "riscv64": _ArchConfig("riscv64-linux-gnu", "lib/ld-linux-riscv64-lp64d.so.1"), - "s390x": _ArchConfig("s390x-linux-gnu", "lib/ld64.so.1"), - "x86_64": _ArchConfig("x86_64-linux-gnu", "lib64/ld-linux-x86-64.so.2"), - "i686": _ArchConfig("i386-linux-gnu", "lib/ld-linux.so.2"), + "aarch64": _ArchConfig( + "aarch64-linux-gnu", "lib/ld-linux-aarch64.so.1", "EM_AARCH64" + ), + "armv7l": _ArchConfig("arm-linux-gnueabihf", "lib/ld-linux-armhf.so.3", "EM_ARM"), + "ppc64le": _ArchConfig("powerpc64le-linux-gnu", "lib64/ld64.so.2", "EM_PPC64"), + "riscv64": _ArchConfig( + "riscv64-linux-gnu", "lib/ld-linux-riscv64-lp64d.so.1", "EM_RISCV" + ), + "s390x": _ArchConfig("s390x-linux-gnu", "lib/ld64.so.1", "EM_S390"), + "x86_64": _ArchConfig( + "x86_64-linux-gnu", "lib64/ld-linux-x86-64.so.2", "EM_X86_64" + ), + "i686": _ArchConfig("i386-linux-gnu", "lib/ld-linux.so.2", "EM_386"), } @@ -152,3 +159,13 @@ def get_arch_triplet(arch: str | None = None) -> str: def get_all_arch_triplets() -> list[str]: """Get a list of all architecture triplets.""" return [architecture.arch_triplet for architecture in _ARCH_CONFIG.values()] + + +def get_host_elf_machine() -> str | None: + """Get the ELF machine type string for the host architecture. + + :returns: The ELF e_machine string (e.g. ``'EM_X86_64'``), or None if the + host architecture is not in the known configuration. + """ + arch_config = _ARCH_CONFIG.get(platform.machine()) + return arch_config.elf_machine if arch_config else None diff --git a/snapcraft/linters/library_linter.py b/snapcraft/linters/library_linter.py index 8b64985195..7e93969893 100644 --- a/snapcraft/linters/library_linter.py +++ b/snapcraft/linters/library_linter.py @@ -59,6 +59,7 @@ def run(self) -> list[LinterIssue]: used_libraries: set[Path] = set() self._generate_ld_config_cache() + host_elf_machine = elf_utils.get_host_elf_machine() for elf_file in elf_files: # Skip linting files listed in the ignore list for the main "library" @@ -66,6 +67,19 @@ def run(self) -> list[LinterIssue]: if self._is_file_ignored(elf_file): continue + # Skip ELF files for foreign architectures — invoking them via binfmt/QEMU + # to resolve dependencies causes spurious errors and potential SEGFAULTs. + if ( + host_elf_machine is not None + and elf_file.arch_tuple is not None + and elf_file.arch_tuple[2] != host_elf_machine + ): + emit.debug( + f"Skipping library linting for foreign-arch ELF " + f"{str(elf_file.path)!r} ({elf_file.arch_tuple[2]})" + ) + continue + arch_triplet = elf_utils.get_arch_triplet() content_dirs = self._snap_metadata.get_provider_content_directories() diff --git a/tests/integration/linters/test_library_linter.py b/tests/integration/linters/test_library_linter.py new file mode 100644 index 0000000000..03962a4693 --- /dev/null +++ b/tests/integration/linters/test_library_linter.py @@ -0,0 +1,88 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2024 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Integration tests for the library linter using real ELF binaries.""" + +import shutil +import subprocess +import urllib.request +from pathlib import Path + +import pytest + +from snapcraft import linters, models +from snapcraft.elf import elf_utils +from snapcraft.meta import snap_yaml + +_POWERPC_BASH_URL = "https://old-releases.ubuntu.com/ubuntu/pool/main/b/bash/bash_2.05b-15ubuntu5_powerpc.deb" + + +def setup_function(): + elf_utils.get_elf_files.cache_clear() + + +@pytest.fixture +def powerpc_bash(tmp_path): + """Download and extract the bash binary from a powerpc .deb package. + + Returns the path to the extracted bash executable, which is a 32-bit + big-endian PowerPC ELF (EM_PPC) — foreign on every supported host arch. + """ + deb_path = tmp_path / "bash-powerpc.deb" + extract_dir = tmp_path / "bash-powerpc" + + try: + urllib.request.urlretrieve(_POWERPC_BASH_URL, deb_path) # noqa: S310 + except OSError as exc: + pytest.skip(f"Could not download powerpc bash package: {exc}") + + extract_dir.mkdir() + result = subprocess.run( + ["dpkg-deb", "-x", str(deb_path), str(extract_dir)], + capture_output=True, + check=False, + ) + if result.returncode != 0: + pytest.skip(f"Could not extract powerpc bash package: {result.stderr.decode()}") + + return extract_dir / "bin" / "bash" + + +def test_library_linter_skips_foreign_arch_elf(powerpc_bash, new_dir): + """The library linter must not call load_dependencies() on a foreign-arch ELF. + + A real powerpc (EM_PPC) bash binary is placed in the prime directory. + On every supported host the linter should silently skip it and report + no library issues, rather than trying to invoke it via binfmt/QEMU. + """ + shutil.copy(powerpc_bash, "bash-powerpc") + + yaml_data = { + "name": "mytest", + "version": "1.0", + "base": "core22", + "summary": "Foreign-arch ELF linter integration test", + "description": "test", + "confinement": "strict", + "parts": {}, + } + project = models.Project.unmarshal(yaml_data) + snap_yaml.write(project, prime_dir=Path(new_dir), arch="amd64") + + issues = linters.run_linters(new_dir, lint=None) + + library_issues = [i for i in issues if i.name == "library"] + assert library_issues == [] diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 99484659ef..fe53cb6969 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -415,6 +415,18 @@ def launched_environment( ): yield mock_instance + def list_instances( + self, + *, + project_name: str | None = None, + instance_name_prefix: str | None = None, + include_base_instances: bool = False, + ): + return [] + + def prune(self, *, project_name: str, prune_templates: bool = False) -> None: + pass + return FakeProvider() diff --git a/tests/unit/linters/test_library_linter.py b/tests/unit/linters/test_library_linter.py index 61207b671f..648f9baedf 100644 --- a/tests/unit/linters/test_library_linter.py +++ b/tests/unit/linters/test_library_linter.py @@ -81,12 +81,14 @@ def test_library_linter_unused_library(mocker, new_dir): mock_elf_file = Mock(spec=_elf_file.ElfFile) mock_elf_file.soname = "" mock_elf_file.path = Path("elf.bin") + mock_elf_file.arch_tuple = None mock_elf_file.load_dependencies.return_value = [] # mock a library mock_library = Mock(spec=_elf_file.ElfFile) mock_library.soname = "libfoo.so" mock_library.path = Path("lib/libfoo.so") + mock_library.arch_tuple = None mock_library.load_dependencies.return_value = [] mocker.patch( @@ -168,12 +170,14 @@ def test_library_linter_filter_unused_library(mocker, new_dir, filter_name): mock_elf_file = Mock(spec=_elf_file.ElfFile) mock_elf_file.soname = "" mock_elf_file.path = Path("elf.bin") + mock_elf_file.arch_tuple = None mock_elf_file.load_dependencies.return_value = [] # mock a library mock_library = Mock(spec=_elf_file.ElfFile) mock_library.soname = "libfoo.so" mock_library.path = Path("lib/libfoo.so") + mock_library.arch_tuple = None mock_library.load_dependencies.return_value = [] mocker.patch( @@ -210,6 +214,7 @@ def test_library_linter_mixed_filters(mocker, new_dir): mock_library = Mock(spec=_elf_file.ElfFile) mock_library.soname = "libfoo.so" mock_library.path = Path("lib/libfoo.so") + mock_library.arch_tuple = None mock_library.load_dependencies.return_value = [] mocker.patch( @@ -375,3 +380,46 @@ def test_find_deb_package_no_available(mocker, fake_process): result = linter._find_deb_package("libcurl.so.4") assert not result + + +def test_library_linter_skips_foreign_arch_elf(mocker, new_dir): + """Verify ELF files for foreign architectures are skipped.""" + mock_host_elf = Mock(spec=_elf_file.ElfFile) + mock_host_elf.soname = "" + mock_host_elf.path = Path("elf.bin") + mock_host_elf.arch_tuple = ("ELFCLASS64", "ELFDATA2LSB", "EM_X86_64") + mock_host_elf.load_dependencies.return_value = set() + + mock_foreign_elf = Mock(spec=_elf_file.ElfFile) + mock_foreign_elf.soname = "" + mock_foreign_elf.path = Path("elf-arm64.bin") + mock_foreign_elf.arch_tuple = ("ELFCLASS64", "ELFDATA2LSB", "EM_AARCH64") + mock_foreign_elf.load_dependencies.return_value = set() + + mocker.patch( + "snapcraft.linters.library_linter.elf_utils.get_elf_files", + return_value=[mock_host_elf, mock_foreign_elf], + ) + mocker.patch( + "snapcraft.linters.library_linter.elf_utils.get_host_elf_machine", + return_value="EM_X86_64", + ) + mocker.patch("snapcraft.linters.linters.LINTERS", {"library": LibraryLinter}) + + yaml_data = { + "name": "mytest", + "version": "1.29.3", + "base": "core22", + "summary": "Single-line elevator pitch for your amazing snap", + "description": "test-description", + "confinement": "strict", + "parts": {}, + } + + project = models.Project.unmarshal(yaml_data) + snap_yaml.write(project, prime_dir=Path(new_dir), arch="amd64") + + linters.run_linters(new_dir, lint=None) + + mock_host_elf.load_dependencies.assert_called_once() + mock_foreign_elf.load_dependencies.assert_not_called()