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
31 changes: 24 additions & 7 deletions snapcraft/elf/elf_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}


Expand Down Expand Up @@ -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
14 changes: 14 additions & 0 deletions snapcraft/linters/library_linter.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,27 @@ 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"
# filter.
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]})"
Comment on lines +70 to +79
)
continue
Comment on lines +72 to +81

arch_triplet = elf_utils.get_arch_triplet()
content_dirs = self._snap_metadata.get_provider_content_directories()

Expand Down
88 changes: 88 additions & 0 deletions tests/integration/linters/test_library_linter.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

"""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"
Comment on lines +30 to +61


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 == []
12 changes: 12 additions & 0 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +418 to +428

return FakeProvider()


Expand Down
48 changes: 48 additions & 0 deletions tests/unit/linters/test_library_linter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()