From 9fb70a2e5ee052ae53eeec10769e6f7b2275a57a Mon Sep 17 00:00:00 2001 From: OctavioValdiviaMendoza Date: Tue, 21 Jul 2026 12:15:42 -0700 Subject: [PATCH 01/10] Initial Implementation of Linux kernel module package parser --- src/packagedcode/lkm.py | 160 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 src/packagedcode/lkm.py diff --git a/src/packagedcode/lkm.py b/src/packagedcode/lkm.py new file mode 100644 index 0000000000..76a731aa92 --- /dev/null +++ b/src/packagedcode/lkm.py @@ -0,0 +1,160 @@ +import re +from typing import Iterator, Dict, Any, List, Optional +from packagedcode.models import DatafileHandler, PackageData, DependentPackage, Party +import os + +from elftools.common.exceptions import ELFError +from elftools.elf.elffile import ELFFile + + +# Inside ScanCode, we would import the official classes: +# from packagedcode.models import Package, Party, Dependency +# from packagedcode import DatafileHandler + +class LinuxKernelModuleHandler(DatafileHandler): + """ + DatafileHandler for compiled Linux Kernel Modules (.ko binaries). + Extracts the .modinfo section and maps it to ScanCode's standard PackageData. + """ + + datasource_id = 'linux_kernel_module' + datasource_type = 'sys' + supported_oses = ('linux',) + default_package_type = 'linux-kernel-module' + path_patterns = ('*.ko',) + description = 'Linux Kernel Module' + documentation_url = "..." + + @classmethod + def parse(cls, location: str, package_only=False) -> Iterator[PackageData]: + #Main entry point called by ScanCode when scanning directories. + raw_metadata = cls.extract_modinfo(location) + if not raw_metadata: + return + + #Ensures no empty or invalid package data is yielded + + yield cls.build_package( + metadata=raw_metadata, + location=location, + package_only=package_only, + ) + + + @staticmethod + def extract_modinfo(location: str) -> Dict[str, List[str]]: + """ + Reads the .modinfo byte section from the ELF file in-memory. + Uses pyelftools (which is already a ScanCode dependency). + """ + + metadata: Dict[str, List[str]] = {} + + try: + with open(location, 'rb') as module_file: + elffile = ELFFile(module_file) + + # Locate the specific .modinfo section in the ELF structure + modinfo_sec = elffile.get_section_by_name('.modinfo') + if modinfo_sec is None: + return {} + + # Extract the raw binary block + raw_bytes = modinfo_sec.data() + + except (ELFError, OSError): + return {} + # Split null-terminated bytes on \x00 + for raw_entry in raw_bytes.split(b'\x00'): + if not raw_entry: + continue + + entry = raw_entry.decode('utf-8', errors='replace') + + if '=' not in entry: + continue + + key, value = entry.split('=', 1) + + if not key: + continue + + metadata.setdefault(key, []).append(value) + + return metadata + + @staticmethod + def get_first(metadata: Dict[str, List[str]], key: str) -> Optional[str]: + values = metadata.get(key) or [] + return values[0] if values else None + + @staticmethod + def get_dependencies(metadata: Dict[str, List[str]]) -> List[str]: + dependencies = [] + + for depends_entry in metadata.get('depends', []): + dependencies.extend( + dependency.strip() + for dependency in depends_entry.split(',') + if dependency.strip() + ) + + return dependencies + + @classmethod + def build_package(cls, metadata: Dict[str, Any], location: str, package_only: bool = False) -> PackageData: + """ + Maps raw .modinfo dictionary keys into ScanCode's standard PackageData models. + """ + # 1. Map Authors to standard 'Party' objects + + parties = [] + for author in metadata.get('author', []): + author = author.strip() + + if author: + parties.append( + Party( + type='person', + role='author', + name=author, + ) + ) + + + + filename = os.path.basename(location) + name = filename[:-3] if filename.endswith('.ko') else filename + + + normalized_keys = { + 'author', + 'description', + 'license', + 'version', + } + + extra_data = { + "srcversion": cls.get_first(metadata, "srcversion"), + "aliases": metadata.get("alias", []), + "intree": cls.get_first(metadata, "intree"), + "vermagic": cls.get_first(metadata, "vermagic"), + "Depends" : cls.get_dependencies(metadata) + } + + package_data = dict( + datasource_id=cls.datasource_id, + type=cls.default_package_type, + name=name, + version=cls.get_first(metadata, 'version'), + description=cls.get_first(metadata, 'description'), + extracted_license_statement=cls.get_first(metadata,'license'), + parties=parties, + extra_data=extra_data, + ) + + return PackageData.from_data( + package_data=package_data, + package_only=package_only, + ) + \ No newline at end of file From 92c64664ae1feec530bcca5edf4ddc69f624ee13 Mon Sep 17 00:00:00 2001 From: OctavioValdiviaMendoza Date: Wed, 22 Jul 2026 11:34:22 -0700 Subject: [PATCH 02/10] Add Linux kernel module package handler issue 625 Parsesthrough metadata using .modinfo section found in ELF files to yield the module macros --- src/packagedcode/lkm.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/packagedcode/lkm.py b/src/packagedcode/lkm.py index 76a731aa92..2f05fbf928 100644 --- a/src/packagedcode/lkm.py +++ b/src/packagedcode/lkm.py @@ -23,7 +23,7 @@ class LinuxKernelModuleHandler(DatafileHandler): default_package_type = 'linux-kernel-module' path_patterns = ('*.ko',) description = 'Linux Kernel Module' - documentation_url = "..." + documentation_url = "https://docs.kernel.org/kbuild/modules.html" @classmethod def parse(cls, location: str, package_only=False) -> Iterator[PackageData]: @@ -135,13 +135,16 @@ def build_package(cls, metadata: Dict[str, Any], location: str, package_only: bo } extra_data = { - "srcversion": cls.get_first(metadata, "srcversion"), - "aliases": metadata.get("alias", []), - "intree": cls.get_first(metadata, "intree"), - "vermagic": cls.get_first(metadata, "vermagic"), - "Depends" : cls.get_dependencies(metadata) + key: values + for key, values in metadata.items() + if key not in normalized_keys } + dependency_names = cls.get_dependency_names(metadata) + + if dependency_names: + extra_data['depends'] = dependency_names + package_data = dict( datasource_id=cls.datasource_id, type=cls.default_package_type, From 347cba9fb66f41fa7bf20d41aff2745349852dd9 Mon Sep 17 00:00:00 2001 From: diana-galeana <162429082+diana-galeana@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:37:06 +0000 Subject: [PATCH 03/10] Tests added for lkm.py --- tests/packagedcode/test_lkm.py | 116 +++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/packagedcode/test_lkm.py diff --git a/tests/packagedcode/test_lkm.py b/tests/packagedcode/test_lkm.py new file mode 100644 index 0000000000..b6ab818183 --- /dev/null +++ b/tests/packagedcode/test_lkm.py @@ -0,0 +1,116 @@ +import shutil +from pathlib import Path + +from packagedcode import lkm + + +class TestLinuxKernelModule: + + test_data_dir = Path(__file__).resolve().parents[2] / "tests" / "licensedcode" / "data" / "query" + + def test_is_linux_kernel_module(self): + test_file = self.test_data_dir / "eeepc_acpi.ko" + + assert lkm.LinuxKernelModuleHandler.is_datafile( + str(test_file) + ) + + def test_extract_modinfo(self): + test_file = self.test_data_dir / "eeepc_acpi.ko" + + metadata = lkm.LinuxKernelModuleHandler.extract_modinfo( + str(test_file) + ) + + assert "license" in metadata + assert "description" in metadata + assert "author" in metadata + + def test_get_first(self): + metadata = { + "license": [ + "GPL", + "MIT", + ] + } + + result = lkm.LinuxKernelModuleHandler.get_first( + metadata, + "license", + ) + + assert result == "GPL" + + def test_get_dependencies(self): + metadata = { + "depends": [ + "usbcore,cfg80211,mac80211" + ] + } + + result = lkm.LinuxKernelModuleHandler.get_dependencies( + metadata + ) + + assert result == [ + "usbcore", + "cfg80211", + "mac80211", + ] + + def test_get_dependencies_handles_multiple_entries(self): + metadata = { + "depends": [ + "usbcore,cfg80211", + "mac80211,netdev", + ] + } + + result = lkm.LinuxKernelModuleHandler.get_dependencies( + metadata + ) + + assert result == [ + "usbcore", + "cfg80211", + "mac80211", + "netdev", + ] + + def test_get_first_missing_optional_value_returns_none(self): + metadata = { + "license": [ + "GPL", + ] + } + + result = lkm.LinuxKernelModuleHandler.get_first( + metadata, + "version", + ) + + assert result is None + + def test_parse_elf_without_modinfo_section_returns_empty(self, tmp_path): + test_file = tmp_path / "no-modinfo.ko" + shutil.copyfile("/bin/ls", test_file) + + package = list( + lkm.LinuxKernelModuleHandler.parse( + str(test_file) + ) + ) + + assert package == [] + + def test_parse_invalid_lkm(self, tmp_path): + test_file = tmp_path / "fake.ko" + test_file.write_bytes(b"not an elf file") + + package = list( + lkm.LinuxKernelModuleHandler.parse( + str(test_file) + ) + ) + + assert package == [] \ No newline at end of file From d81ebbd000a753f213a877d433bdc45ac51cf825 Mon Sep 17 00:00:00 2001 From: OctavioValdiviaMendoza Date: Fri, 24 Jul 2026 03:58:28 -0700 Subject: [PATCH 04/10] Add Linux kernel module package parser, Added DependentPackage objects that are now passed to PackageData --- src/packagedcode/lkm.py | 114 ++++++++++++++++++++++++++-------------- 1 file changed, 74 insertions(+), 40 deletions(-) diff --git a/src/packagedcode/lkm.py b/src/packagedcode/lkm.py index 2f05fbf928..1f0966c2a5 100644 --- a/src/packagedcode/lkm.py +++ b/src/packagedcode/lkm.py @@ -1,70 +1,78 @@ -import re -from typing import Iterator, Dict, Any, List, Optional -from packagedcode.models import DatafileHandler, PackageData, DependentPackage, Party +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# ScanCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + import os +from typing import Iterator +from typing import Dict +from typing import List +from typing import Optional + from elftools.common.exceptions import ELFError from elftools.elf.elffile import ELFFile +from packageurl import PackageURL - -# Inside ScanCode, we would import the official classes: -# from packagedcode.models import Package, Party, Dependency -# from packagedcode import DatafileHandler +from packagedcode.models import DatafileHandler +from packagedcode.models import DependentPackage +from packagedcode.models import PackageData +from packagedcode.models import Party class LinuxKernelModuleHandler(DatafileHandler): """ - DatafileHandler for compiled Linux Kernel Modules (.ko binaries). - Extracts the .modinfo section and maps it to ScanCode's standard PackageData. + Extract package metadata from compiled Linux Kernel Module ELF files. """ - datasource_id = 'linux_kernel_module' datasource_type = 'sys' supported_oses = ('linux',) default_package_type = 'linux-kernel-module' path_patterns = ('*.ko',) description = 'Linux Kernel Module' - documentation_url = "https://docs.kernel.org/kbuild/modules.html" + documentation_url = 'https://docs.kernel.org/kbuild/modules.html' @classmethod def parse(cls, location: str, package_only=False) -> Iterator[PackageData]: - #Main entry point called by ScanCode when scanning directories. - raw_metadata = cls.extract_modinfo(location) - if not raw_metadata: + metadata = cls.extract_modinfo(location) + + if not metadata: return - #Ensures no empty or invalid package data is yielded - - yield cls.build_package( - metadata=raw_metadata, - location=location, - package_only=package_only, + yield cls.build_package_data( + metadata=metadata, + location=location, + package_only=package_only, ) @staticmethod def extract_modinfo(location: str) -> Dict[str, List[str]]: """ - Reads the .modinfo byte section from the ELF file in-memory. - Uses pyelftools (which is already a ScanCode dependency). + Reads the .modinfo byte section from the ELF file in-memory using pyelftools. + Returns a dictionary of metadata where keys are the .modinfo keys and values are lists of strings. """ - metadata: Dict[str, List[str]] = {} try: with open(location, 'rb') as module_file: - elffile = ELFFile(module_file) + elf_file = ELFFile(module_file) - # Locate the specific .modinfo section in the ELF structure - modinfo_sec = elffile.get_section_by_name('.modinfo') - if modinfo_sec is None: + # Restrict parsing to .modinfo instead of searching arbitrary binary data. + modinfo_section = elf_file.get_section_by_name('.modinfo') + if modinfo_section is None: return {} # Extract the raw binary block - raw_bytes = modinfo_sec.data() + raw_bytes = modinfo_section.data() except (ELFError, OSError): return {} - # Split null-terminated bytes on \x00 + + # Entries in .modinfo are NULL-terminated key=value strings. for raw_entry in raw_bytes.split(b'\x00'): if not raw_entry: continue @@ -89,9 +97,12 @@ def get_first(metadata: Dict[str, List[str]], key: str) -> Optional[str]: return values[0] if values else None @staticmethod - def get_dependencies(metadata: Dict[str, List[str]]) -> List[str]: + def get_dependency_names( + metadata: Dict[str, List[str]] + ) -> List[str]: dependencies = [] + # The depends field lists modules required when this module is loaded. for depends_entry in metadata.get('depends', []): dependencies.extend( dependency.strip() @@ -100,14 +111,40 @@ def get_dependencies(metadata: Dict[str, List[str]]) -> List[str]: ) return dependencies + + @classmethod + def get_dependent_packages( + cls, + metadata: Dict[str, List[str]], + ) -> List[DependentPackage]: + dependency_names = cls.get_dependency_names(metadata) + + return [ + DependentPackage( + purl=PackageURL( + type=cls.default_package_type, + name=dependency_name, + ).to_string(), + extracted_requirement=None, + scope='runtime', + is_runtime=True, + is_optional=False, + ) + for dependency_name in dependency_names + ] @classmethod - def build_package(cls, metadata: Dict[str, Any], location: str, package_only: bool = False) -> PackageData: + def build_package_data( + cls, + metadata: Dict[str, List[str]], + location: str, + package_only: bool = False + ) -> PackageData: """ Maps raw .modinfo dictionary keys into ScanCode's standard PackageData models. """ - # 1. Map Authors to standard 'Party' objects + # Represent every declared author as a package party. parties = [] for author in metadata.get('author', []): author = author.strip() @@ -120,30 +157,26 @@ def build_package(cls, metadata: Dict[str, Any], location: str, package_only: bo name=author, ) ) - - filename = os.path.basename(location) name = filename[:-3] if filename.endswith('.ko') else filename - normalized_keys = { 'author', 'description', 'license', 'version', + 'depends', } + #Preserve additional metadata that is not mapped to PackageData fields. extra_data = { key: values for key, values in metadata.items() if key not in normalized_keys } - dependency_names = cls.get_dependency_names(metadata) - - if dependency_names: - extra_data['depends'] = dependency_names + dependencies = cls.get_dependent_packages(metadata) package_data = dict( datasource_id=cls.datasource_id, @@ -151,8 +184,9 @@ def build_package(cls, metadata: Dict[str, Any], location: str, package_only: bo name=name, version=cls.get_first(metadata, 'version'), description=cls.get_first(metadata, 'description'), - extracted_license_statement=cls.get_first(metadata,'license'), + extracted_license_statement=cls.get_first(metadata, 'license'), parties=parties, + dependencies=dependencies, extra_data=extra_data, ) From ed5a38afd86aaddc48ebb9fe694263bdf7552a6a Mon Sep 17 00:00:00 2001 From: OctavioValdiviaMendoza Date: Fri, 24 Jul 2026 04:46:37 -0700 Subject: [PATCH 05/10] Added test cases to ensure all data gets returned.Added test to verify DependecyPackage and PAckageData are structured correctly --- tests/packagedcode/test_lkm.py | 135 +++++++++++++++++++++++++++++++-- 1 file changed, 129 insertions(+), 6 deletions(-) diff --git a/tests/packagedcode/test_lkm.py b/tests/packagedcode/test_lkm.py index b6ab818183..0d223a92d7 100644 --- a/tests/packagedcode/test_lkm.py +++ b/tests/packagedcode/test_lkm.py @@ -1,7 +1,14 @@ import shutil +import os from pathlib import Path +import pytest + from packagedcode import lkm +from packagedcode.models import DatafileHandler +from packagedcode.models import PackageData +from packagedcode.models import Party +from packagedcode.models import DependentPackage class TestLinuxKernelModule: @@ -41,14 +48,14 @@ def test_get_first(self): assert result == "GPL" - def test_get_dependencies(self): + def test_get_dependency_names(self): metadata = { "depends": [ "usbcore,cfg80211,mac80211" ] } - result = lkm.LinuxKernelModuleHandler.get_dependencies( + result = lkm.LinuxKernelModuleHandler.get_dependency_names( metadata ) @@ -58,7 +65,7 @@ def test_get_dependencies(self): "mac80211", ] - def test_get_dependencies_handles_multiple_entries(self): + def test_get_dependency_names_handles_multiple_entries(self): metadata = { "depends": [ "usbcore,cfg80211", @@ -66,7 +73,7 @@ def test_get_dependencies_handles_multiple_entries(self): ] } - result = lkm.LinuxKernelModuleHandler.get_dependencies( + result = lkm.LinuxKernelModuleHandler.get_dependency_names( metadata ) @@ -92,6 +99,8 @@ def test_get_first_missing_optional_value_returns_none(self): assert result is None def test_parse_elf_without_modinfo_section_returns_empty(self, tmp_path): + if os.name == 'nt': + pytest.skip("Test currently depends on the Unix /bin/ls ELF binary'") test_file = tmp_path / "no-modinfo.ko" shutil.copyfile("/bin/ls", test_file) @@ -102,7 +111,7 @@ def test_parse_elf_without_modinfo_section_returns_empty(self, tmp_path): ) assert package == [] - + def test_parse_invalid_lkm(self, tmp_path): test_file = tmp_path / "fake.ko" test_file.write_bytes(b"not an elf file") @@ -113,4 +122,118 @@ def test_parse_invalid_lkm(self, tmp_path): ) ) - assert package == [] \ No newline at end of file + assert package == [] + + def test_parse_linux_kernel_module(self): + test_file = self.test_data_dir / 'eeepc_acpi.ko' + + packages = list( + lkm.LinuxKernelModuleHandler.parse( + str(test_file) + ) + ) + + assert len(packages) == 1 + + package = packages[0] + + assert package.datasource_id == 'linux_kernel_module' + assert package.type == 'linux-kernel-module' + assert package.name == 'eeepc_acpi' + assert package.extracted_license_statement + assert package.description + assert package.parties + + def test_get_dependent_packages(self): + metadata = { + 'depends': [ + 'usbcore,cfg80211', + ] + } + + dependencies = ( + lkm.LinuxKernelModuleHandler.get_dependent_packages(metadata) + ) + + assert len(dependencies) == 2 + + assert dependencies[0].purl == ( + 'pkg:linux-kernel-module/usbcore' + ) + assert dependencies[0].scope == 'runtime' + assert dependencies[0].is_runtime is True + assert dependencies[0].is_optional is False + assert dependencies[0].extracted_requirement is None + + assert dependencies[1].purl == ( + 'pkg:linux-kernel-module/cfg80211' + ) + + def test_build_package_data_includes_dependencies(self): + metadata = { + 'license': ['GPL'], + 'depends': ['usbcore,cfg80211'], + } + + package_data = ( + lkm.LinuxKernelModuleHandler.build_package_data( + metadata=metadata, + location='/tmp/example.ko', + ) + ) + + assert package_data.name == 'example' + assert len(package_data.dependencies) == 2 + assert package_data.dependencies[0].purl == ( + 'pkg:linux-kernel-module/usbcore' + ) + assert package_data.dependencies[1].purl == ( + 'pkg:linux-kernel-module/cfg80211' + ) + + def test_build_package_data_preserves_unknown_metadata(self): + metadata = { + 'license': ['GPL'], + 'vermagic': ['6.8.0 SMP preempt mod_unload'], + 'srcversion': ['ABC123'], + 'firmware': ['example.bin'], + } + + package_data = ( + lkm.LinuxKernelModuleHandler.build_package_data( + metadata=metadata, + location='/tmp/example.ko', + ) + ) + + assert package_data.extra_data["vermagic"] == [ + "6.8.0 SMP preempt mod_unload" + ] + + assert package_data.extra_data["srcversion"] == [ + "ABC123" + ] + + assert package_data.extra_data["firmware"] == [ + "example.bin" + ] + + def test_build_package_data_handles_multiple_authors(self): + metadata = { + 'author': [ + 'Alice', + 'Bob', + ], + } + + package_data = ( + lkm.LinuxKernelModuleHandler.build_package_data( + metadata=metadata, + location='/tmp/example.ko', + ) + ) + + assert [party.name for party in package_data.parties] == [ + 'Alice', + 'Bob', + ] From 08531c3e8d8d6889aeea3dde04be438bf85c5e9f Mon Sep 17 00:00:00 2001 From: OctavioValdiviaMendoza Date: Sun, 26 Jul 2026 16:49:17 -0700 Subject: [PATCH 06/10] Signed-off-by: OctavioValdiviaMendoza Add Linux Kernel Module package parser Add support for parsing Linux Kernel Module (.ko) files. Add pyelftools as a runtime dependency for reading ELF .modinfo sections, register the new handler, and add unit tests. --- AUTHORS.rst | 1 + CHANGELOG.rst | 3 +++ pyproject-scancode-toolkit.toml | 1 + requirements.txt | 1 + src/packagedcode/__init__.py | 5 ++++- src/packagedcode/lkm.py | 31 +++++++++++++++++++++---------- tests/packagedcode/test_lkm.py | 9 +++++++++ 7 files changed, 40 insertions(+), 11 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 9a8224dc7d..38de31aefc 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -61,6 +61,7 @@ The following organizations or individuals have contributed to ScanCode: - Nisha Kumar @nishakm - Nishchith Shetty @inishchith - Nitish Sharma @nitish81299 +- Octavio Valdivia Mendoza @OctavioValdiviaMendoza - Paul Gier @pgier - Philippe Ombredanne @pombredanne - Pi Delport @PiDelport diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d9a5a6b402..67ccd1e5f4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,9 @@ Next release ``licensedcode-data``. https://github.com/aboutcode-org/scancode-toolkit/pull/5056 +- Add a package data handler for compiled Linux Kernel Module (``.ko``) + files that extracts metadata from the ELF ``.modinfo`` section. + v33.0.0rc1 - 2026-05-14 ------------------------ diff --git a/pyproject-scancode-toolkit.toml b/pyproject-scancode-toolkit.toml index 407d65b9c4..37f90b388e 100644 --- a/pyproject-scancode-toolkit.toml +++ b/pyproject-scancode-toolkit.toml @@ -88,6 +88,7 @@ dependencies = [ "plugincode >= 32.0.0", "publicsuffix2", "pyahocorasick >= 2.3.0", + "pyelftools >= 0.32", "pygmars >= 1.0.0", "pygments >= 1.0.0", "pymaven_patch >= 0.2.8", diff --git a/requirements.txt b/requirements.txt index e534652d52..05175e3b58 100644 --- a/requirements.txt +++ b/requirements.txt @@ -63,6 +63,7 @@ publicsuffix2==2.20191221 py==1.11.0 pyahocorasick==2.3.1 pycparser==2.23 +pyelftools==0.32 pygmars==1.0.0 Pygments==2.13.0 pymaven-patch==0.3.2 diff --git a/src/packagedcode/__init__.py b/src/packagedcode/__init__.py index fc1e490eef..0c8e75c6cd 100644 --- a/src/packagedcode/__init__.py +++ b/src/packagedcode/__init__.py @@ -26,6 +26,7 @@ from packagedcode import godeps from packagedcode import golang from packagedcode import haxe +from packagedcode import lkm from packagedcode import maven from packagedcode import misc from packagedcode import npm @@ -233,8 +234,10 @@ debian.DebianInstalledMd5sumFilelistHandler, debian.DebianInstalledStatusDatabaseHandler, + lkm.LinuxKernelModuleHandler, + rpm.RpmLicenseFilesHandler, - rpm.RpmMarinerContainerManifestHandler + rpm.RpmMarinerContainerManifestHandler, ] if on_linux: diff --git a/src/packagedcode/lkm.py b/src/packagedcode/lkm.py index 1f0966c2a5..6204e745ec 100644 --- a/src/packagedcode/lkm.py +++ b/src/packagedcode/lkm.py @@ -9,8 +9,8 @@ import os -from typing import Iterator from typing import Dict +from typing import Iterator from typing import List from typing import Optional @@ -23,6 +23,7 @@ from packagedcode.models import PackageData from packagedcode.models import Party + class LinuxKernelModuleHandler(DatafileHandler): """ Extract package metadata from compiled Linux Kernel Module ELF files. @@ -52,9 +53,12 @@ def parse(cls, location: str, package_only=False) -> Iterator[PackageData]: @staticmethod def extract_modinfo(location: str) -> Dict[str, List[str]]: """ - Reads the .modinfo byte section from the ELF file in-memory using pyelftools. - Returns a dictionary of metadata where keys are the .modinfo keys and values are lists of strings. + Return .modinfo metadata as a mapping of keys to lists of values. + + Multiple values are preserved because fields such as 'author', 'alias', + and 'firmware' may appear more than once. """ + metadata: Dict[str, List[str]] = {} try: @@ -66,13 +70,13 @@ def extract_modinfo(location: str) -> Dict[str, List[str]]: if modinfo_section is None: return {} - # Extract the raw binary block + # Read only the raw bytes stored in the ELF .modinfo section. raw_bytes = modinfo_section.data() except (ELFError, OSError): return {} - # Entries in .modinfo are NULL-terminated key=value strings. + # Entries in .modinfo are NUL-terminated key=value strings. for raw_entry in raw_bytes.split(b'\x00'): if not raw_entry: continue @@ -135,13 +139,13 @@ def get_dependent_packages( @classmethod def build_package_data( - cls, - metadata: Dict[str, List[str]], - location: str, - package_only: bool = False + cls, + metadata: Dict[str, List[str]], + location: str, + package_only: bool = False, ) -> PackageData: """ - Maps raw .modinfo dictionary keys into ScanCode's standard PackageData models. + Return PackageData built from raw .modinfo metadata. """ # Represent every declared author as a package party. @@ -176,6 +180,13 @@ def build_package_data( if key not in normalized_keys } + # PackageData stores only the first value for these normalized fields. + # Preserve all values in extra_data when a field occurs more than once. + for key in ('description', 'license', 'version'): + values = metadata.get(key, []) + if len(values) > 1: + extra_data[key] = values + dependencies = cls.get_dependent_packages(metadata) package_data = dict( diff --git a/tests/packagedcode/test_lkm.py b/tests/packagedcode/test_lkm.py index 0d223a92d7..70dd3ffd69 100644 --- a/tests/packagedcode/test_lkm.py +++ b/tests/packagedcode/test_lkm.py @@ -1,6 +1,7 @@ import shutil import os from pathlib import Path +from xml.sax import handler import pytest @@ -9,6 +10,7 @@ from packagedcode.models import PackageData from packagedcode.models import Party from packagedcode.models import DependentPackage +from packagedcode import HANDLER_BY_DATASOURCE_ID class TestLinuxKernelModule: @@ -237,3 +239,10 @@ def test_build_package_data_handles_multiple_authors(self): 'Alice', 'Bob', ] + + def test_datasource_id_is_registered(self): + handler = HANDLER_BY_DATASOURCE_ID[ + lkm.LinuxKernelModuleHandler.datasource_id + ] + + assert handler is lkm.LinuxKernelModuleHandler \ No newline at end of file From a936c9eddd0e0a1ed104f0e9caa7fb1ee0d63c99 Mon Sep 17 00:00:00 2001 From: diana-galeana <162429082+diana-galeana@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:56:15 +0000 Subject: [PATCH 07/10] Add tests for alias pass-through and multi-value metadata fields in lkm.py Add self to AUTHORS.rst Signed-off-by: diana-galeana <162429082+diana-galeana@users.noreply.github.com> --- AUTHORS.rst | 1 + tests/packagedcode/test_lkm.py | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 38de31aefc..d84a325506 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -27,6 +27,7 @@ The following organizations or individuals have contributed to ScanCode: - Daniel Eder @daniel-eder - Dan Kegel @dankegel - Dennis Clark @DennisClark +- Diana Galeana @diana-galeana - Divyansh Sharma @Divyansh2512 - Duncan Howe @Duncan-Howe - Felix Kauselmann @selmf diff --git a/tests/packagedcode/test_lkm.py b/tests/packagedcode/test_lkm.py index 70dd3ffd69..f3f6db3d78 100644 --- a/tests/packagedcode/test_lkm.py +++ b/tests/packagedcode/test_lkm.py @@ -245,4 +245,18 @@ def test_datasource_id_is_registered(self): lkm.LinuxKernelModuleHandler.datasource_id ] - assert handler is lkm.LinuxKernelModuleHandler \ No newline at end of file + assert handler is lkm.LinuxKernelModuleHandler + + def test_build_package_data_multiple_alias_entries(self): + metadata = { + 'license': ['GPL'], + 'alias': ['usbcore', 'cfg80211'], + } + + package_data = lkm.LinuxKernelModuleHandler.build_package_data( + metadata=metadata, + location='/tmp/example.ko', + ) + + assert package_data.extra_data['alias'] == ['usbcore', 'cfg80211'] + assert 'depends' not in package_data.extra_data \ No newline at end of file From 01188e734255b8a549e7dfaaa7aede8ee356a429 Mon Sep 17 00:00:00 2001 From: OctavioValdiviaMendoza Date: Sat, 1 Aug 2026 02:17:18 -0700 Subject: [PATCH 08/10] Add pyelftools to development dependencies Signed-off-by: OctavioValdiviaMendoza --- pyproject-scancode-toolkit-mini.toml | 1 + pyproject.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/pyproject-scancode-toolkit-mini.toml b/pyproject-scancode-toolkit-mini.toml index a816bb2de7..7233bad1ba 100644 --- a/pyproject-scancode-toolkit-mini.toml +++ b/pyproject-scancode-toolkit-mini.toml @@ -88,6 +88,7 @@ dependencies = [ "plugincode >= 32.0.0", "publicsuffix2", "pyahocorasick >= 2.3.0", + "pyelftools >= 0.32", "pygmars >= 1.0.0", "pygments >= 1.0.0", "pymaven_patch >= 0.2.8", diff --git a/pyproject.toml b/pyproject.toml index cbd405f2bd..42d99a4a66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,6 +89,7 @@ dependencies = [ "plugincode >= 32.0.0", "publicsuffix2", "pyahocorasick >= 2.3.0", + "pyelftools >= 0.32", "pygmars >= 1.0.0", "pygments >= 1.0.0", "pymaven_patch >= 0.2.8", From a104528ebdc9b1861b60321b0b028b6e7187a131 Mon Sep 17 00:00:00 2001 From: OctavioValdiviaMendoza Date: Thu, 6 Aug 2026 03:29:01 -0700 Subject: [PATCH 09/10] Refactor Linux kernel module metadata as a file scan plugin Signed-off-by: OctavioValdiviaMendoza --- CHANGELOG.rst | 2 +- pyproject-scancode-toolkit.toml | 1 + pyproject.toml | 1 + src/packagedcode/__init__.py | 3 - src/packagedcode/lkm.py | 208 -------------------------------- src/scancode/plugin_lkm.py | 130 ++++++++++++++++++++ 6 files changed, 133 insertions(+), 212 deletions(-) delete mode 100644 src/packagedcode/lkm.py create mode 100644 src/scancode/plugin_lkm.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 67ccd1e5f4..98efe78b41 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,7 +8,7 @@ Next release ``licensedcode-data``. https://github.com/aboutcode-org/scancode-toolkit/pull/5056 -- Add a package data handler for compiled Linux Kernel Module (``.ko``) +- Add a file-level scan plugin for compiled Linux Kernel Module (``.ko``) files that extracts metadata from the ELF ``.modinfo`` section. v33.0.0rc1 - 2026-05-14 diff --git a/pyproject-scancode-toolkit.toml b/pyproject-scancode-toolkit.toml index 37f90b388e..83788d10ce 100644 --- a/pyproject-scancode-toolkit.toml +++ b/pyproject-scancode-toolkit.toml @@ -267,6 +267,7 @@ facet = "summarycode.facet:AddFacet" # module for details and doc. [project.entry-points.scancode_scan] info = "scancode.plugin_info:InfoScanner" +lkm = "scancode.plugin_lkm:LinuxKernelModuleScanner" licenses = "licensedcode.plugin_license:LicenseScanner" copyrights = "cluecode.plugin_copyright:CopyrightScanner" packages = "packagedcode.plugin_package:PackageScanner" diff --git a/pyproject.toml b/pyproject.toml index 42d99a4a66..9fde2bf6b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -272,6 +272,7 @@ facet = "summarycode.facet:AddFacet" # module for details and doc. [project.entry-points.scancode_scan] info = "scancode.plugin_info:InfoScanner" +lkm = "scancode.plugin_lkm:LinuxKernelModuleScanner" licenses = "licensedcode.plugin_license:LicenseScanner" copyrights = "cluecode.plugin_copyright:CopyrightScanner" packages = "packagedcode.plugin_package:PackageScanner" diff --git a/src/packagedcode/__init__.py b/src/packagedcode/__init__.py index 0c8e75c6cd..fa5c85eb07 100644 --- a/src/packagedcode/__init__.py +++ b/src/packagedcode/__init__.py @@ -26,7 +26,6 @@ from packagedcode import godeps from packagedcode import golang from packagedcode import haxe -from packagedcode import lkm from packagedcode import maven from packagedcode import misc from packagedcode import npm @@ -234,8 +233,6 @@ debian.DebianInstalledMd5sumFilelistHandler, debian.DebianInstalledStatusDatabaseHandler, - lkm.LinuxKernelModuleHandler, - rpm.RpmLicenseFilesHandler, rpm.RpmMarinerContainerManifestHandler, ] diff --git a/src/packagedcode/lkm.py b/src/packagedcode/lkm.py deleted file mode 100644 index 6204e745ec..0000000000 --- a/src/packagedcode/lkm.py +++ /dev/null @@ -1,208 +0,0 @@ -# -# Copyright (c) nexB Inc. and others. All rights reserved. -# ScanCode is a trademark of nexB Inc. -# SPDX-License-Identifier: Apache-2.0 -# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. -# See https://github.com/nexB/scancode-toolkit for support or download. -# See https://aboutcode.org for more information about nexB OSS projects. -# - -import os - -from typing import Dict -from typing import Iterator -from typing import List -from typing import Optional - -from elftools.common.exceptions import ELFError -from elftools.elf.elffile import ELFFile -from packageurl import PackageURL - -from packagedcode.models import DatafileHandler -from packagedcode.models import DependentPackage -from packagedcode.models import PackageData -from packagedcode.models import Party - - -class LinuxKernelModuleHandler(DatafileHandler): - """ - Extract package metadata from compiled Linux Kernel Module ELF files. - """ - datasource_id = 'linux_kernel_module' - datasource_type = 'sys' - supported_oses = ('linux',) - default_package_type = 'linux-kernel-module' - path_patterns = ('*.ko',) - description = 'Linux Kernel Module' - documentation_url = 'https://docs.kernel.org/kbuild/modules.html' - - @classmethod - def parse(cls, location: str, package_only=False) -> Iterator[PackageData]: - metadata = cls.extract_modinfo(location) - - if not metadata: - return - - yield cls.build_package_data( - metadata=metadata, - location=location, - package_only=package_only, - ) - - - @staticmethod - def extract_modinfo(location: str) -> Dict[str, List[str]]: - """ - Return .modinfo metadata as a mapping of keys to lists of values. - - Multiple values are preserved because fields such as 'author', 'alias', - and 'firmware' may appear more than once. - """ - - metadata: Dict[str, List[str]] = {} - - try: - with open(location, 'rb') as module_file: - elf_file = ELFFile(module_file) - - # Restrict parsing to .modinfo instead of searching arbitrary binary data. - modinfo_section = elf_file.get_section_by_name('.modinfo') - if modinfo_section is None: - return {} - - # Read only the raw bytes stored in the ELF .modinfo section. - raw_bytes = modinfo_section.data() - - except (ELFError, OSError): - return {} - - # Entries in .modinfo are NUL-terminated key=value strings. - for raw_entry in raw_bytes.split(b'\x00'): - if not raw_entry: - continue - - entry = raw_entry.decode('utf-8', errors='replace') - - if '=' not in entry: - continue - - key, value = entry.split('=', 1) - - if not key: - continue - - metadata.setdefault(key, []).append(value) - - return metadata - - @staticmethod - def get_first(metadata: Dict[str, List[str]], key: str) -> Optional[str]: - values = metadata.get(key) or [] - return values[0] if values else None - - @staticmethod - def get_dependency_names( - metadata: Dict[str, List[str]] - ) -> List[str]: - dependencies = [] - - # The depends field lists modules required when this module is loaded. - for depends_entry in metadata.get('depends', []): - dependencies.extend( - dependency.strip() - for dependency in depends_entry.split(',') - if dependency.strip() - ) - - return dependencies - - @classmethod - def get_dependent_packages( - cls, - metadata: Dict[str, List[str]], - ) -> List[DependentPackage]: - dependency_names = cls.get_dependency_names(metadata) - - return [ - DependentPackage( - purl=PackageURL( - type=cls.default_package_type, - name=dependency_name, - ).to_string(), - extracted_requirement=None, - scope='runtime', - is_runtime=True, - is_optional=False, - ) - for dependency_name in dependency_names - ] - - @classmethod - def build_package_data( - cls, - metadata: Dict[str, List[str]], - location: str, - package_only: bool = False, - ) -> PackageData: - """ - Return PackageData built from raw .modinfo metadata. - """ - - # Represent every declared author as a package party. - parties = [] - for author in metadata.get('author', []): - author = author.strip() - - if author: - parties.append( - Party( - type='person', - role='author', - name=author, - ) - ) - - filename = os.path.basename(location) - name = filename[:-3] if filename.endswith('.ko') else filename - - normalized_keys = { - 'author', - 'description', - 'license', - 'version', - 'depends', - } - - #Preserve additional metadata that is not mapped to PackageData fields. - extra_data = { - key: values - for key, values in metadata.items() - if key not in normalized_keys - } - - # PackageData stores only the first value for these normalized fields. - # Preserve all values in extra_data when a field occurs more than once. - for key in ('description', 'license', 'version'): - values = metadata.get(key, []) - if len(values) > 1: - extra_data[key] = values - - dependencies = cls.get_dependent_packages(metadata) - - package_data = dict( - datasource_id=cls.datasource_id, - type=cls.default_package_type, - name=name, - version=cls.get_first(metadata, 'version'), - description=cls.get_first(metadata, 'description'), - extracted_license_statement=cls.get_first(metadata, 'license'), - parties=parties, - dependencies=dependencies, - extra_data=extra_data, - ) - - return PackageData.from_data( - package_data=package_data, - package_only=package_only, - ) - \ No newline at end of file diff --git a/src/scancode/plugin_lkm.py b/src/scancode/plugin_lkm.py new file mode 100644 index 0000000000..41392b8378 --- /dev/null +++ b/src/scancode/plugin_lkm.py @@ -0,0 +1,130 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# ScanCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +from typing import Dict +from typing import List + +import attr + +from commoncode.cliutils import OTHER_SCAN_GROUP +from commoncode.cliutils import PluggableCommandLineOption +from elftools.common.exceptions import ELFError +from elftools.elf.elffile import ELFFile +from plugincode.scan import ScanPlugin +from plugincode.scan import scan_impl + + +@scan_impl +class LinuxKernelModuleScanner(ScanPlugin): + """ + Scan Linux kernel module files for metadata stored in their ELF + '.modinfo' section. + """ + + resource_attributes = dict( + linux_kernel_module=attr.ib(default=None, repr=False), + ) + + run_order = 9 + sort_order = 9 + + options = [ + PluggableCommandLineOption( + ('--lkm',), + is_flag=True, + default=False, + help='Scan Linux kernel module files for .modinfo metadata.', + help_group=OTHER_SCAN_GROUP, + ) + ] + + def is_enabled(self, lkm, **kwargs): + return lkm + + def get_scanner(self, **kwargs): + return scan_linux_kernel_module + + +def scan_linux_kernel_module(location, **kwargs): + """ + Return a mapping of Linux kernel module metadata found in the '.ko' file + at 'location'. Return an empty mapping for other files and for files that + do not contain usable '.modinfo' metadata. + """ + if not location.lower().endswith('.ko'): + return {} + + metadata = extract_modinfo(location) + if not metadata: + return {} + + # The .modinfo "depends" value is a comma-separated list of required + # kernel module names, not a list of package-management dependencies. + metadata['depends'] = get_dependency_names(metadata) + + return dict(linux_kernel_module=metadata) + + +def extract_modinfo(location: str) -> Dict[str, List[str]]: + """ + Extract '.modinfo' metadata from the Linux kernel module file at 'location'. + + Return '.modinfo' metadata as a mapping of keys to lists of values. + + Multiple values are preserved because fields such as 'author', 'alias', + and 'firmware' may occur more than once. + """ + metadata: Dict[str, List[str]] = {} + + try: + with open(location, 'rb') as module_file: + elf_file = ELFFile(module_file) + modinfo_section = elf_file.get_section_by_name('.modinfo') + if modinfo_section is None: + return {} + + raw_bytes = modinfo_section.data() + + except (ELFError, OSError): + return {} + + # Entries in .modinfo are NUL-terminated key=value strings. + for raw_entry in raw_bytes.split(b'\x00'): + if not raw_entry: + continue + + entry = raw_entry.decode('utf-8', errors='replace') + if '=' not in entry: + continue + + key, value = entry.split('=', 1) + if not key: + continue + + metadata.setdefault(key, []).append(value) + + return metadata + + +def get_dependency_names(metadata: Dict[str, List[str]]) -> List[str]: + """ + Returns a list of strings, each being a kernel module name + Normalized fields to a list of strings derived from the '.modinfo' metadata. The 'depends' field is a + comma-separated list of required kernel module names + """ + dependency_names = [] + + for depends_entry in metadata.get('depends', []): + dependency_names.extend( + dependency.strip() + for dependency in depends_entry.split(',') + if dependency.strip() + ) + + return dependency_names From 3d3746ba58bdbe16a04344b0bbbb444ec82825c2 Mon Sep 17 00:00:00 2001 From: OctavioValdiviaMendoza Date: Thu, 6 Aug 2026 04:01:35 -0700 Subject: [PATCH 10/10] Update tests for Linux kernel module scan plugin Signed-off-by: OctavioValdiviaMendoza --- tests/packagedcode/test_lkm.py | 262 ------------------------------ tests/scancode/test_plugin_lkm.py | 127 +++++++++++++++ 2 files changed, 127 insertions(+), 262 deletions(-) delete mode 100644 tests/packagedcode/test_lkm.py create mode 100644 tests/scancode/test_plugin_lkm.py diff --git a/tests/packagedcode/test_lkm.py b/tests/packagedcode/test_lkm.py deleted file mode 100644 index f3f6db3d78..0000000000 --- a/tests/packagedcode/test_lkm.py +++ /dev/null @@ -1,262 +0,0 @@ -import shutil -import os -from pathlib import Path -from xml.sax import handler - -import pytest - -from packagedcode import lkm -from packagedcode.models import DatafileHandler -from packagedcode.models import PackageData -from packagedcode.models import Party -from packagedcode.models import DependentPackage -from packagedcode import HANDLER_BY_DATASOURCE_ID - - -class TestLinuxKernelModule: - - test_data_dir = Path(__file__).resolve().parents[2] / "tests" / "licensedcode" / "data" / "query" - - def test_is_linux_kernel_module(self): - test_file = self.test_data_dir / "eeepc_acpi.ko" - - assert lkm.LinuxKernelModuleHandler.is_datafile( - str(test_file) - ) - - def test_extract_modinfo(self): - test_file = self.test_data_dir / "eeepc_acpi.ko" - - metadata = lkm.LinuxKernelModuleHandler.extract_modinfo( - str(test_file) - ) - - assert "license" in metadata - assert "description" in metadata - assert "author" in metadata - - def test_get_first(self): - metadata = { - "license": [ - "GPL", - "MIT", - ] - } - - result = lkm.LinuxKernelModuleHandler.get_first( - metadata, - "license", - ) - - assert result == "GPL" - - def test_get_dependency_names(self): - metadata = { - "depends": [ - "usbcore,cfg80211,mac80211" - ] - } - - result = lkm.LinuxKernelModuleHandler.get_dependency_names( - metadata - ) - - assert result == [ - "usbcore", - "cfg80211", - "mac80211", - ] - - def test_get_dependency_names_handles_multiple_entries(self): - metadata = { - "depends": [ - "usbcore,cfg80211", - "mac80211,netdev", - ] - } - - result = lkm.LinuxKernelModuleHandler.get_dependency_names( - metadata - ) - - assert result == [ - "usbcore", - "cfg80211", - "mac80211", - "netdev", - ] - - def test_get_first_missing_optional_value_returns_none(self): - metadata = { - "license": [ - "GPL", - ] - } - - result = lkm.LinuxKernelModuleHandler.get_first( - metadata, - "version", - ) - - assert result is None - - def test_parse_elf_without_modinfo_section_returns_empty(self, tmp_path): - if os.name == 'nt': - pytest.skip("Test currently depends on the Unix /bin/ls ELF binary'") - test_file = tmp_path / "no-modinfo.ko" - shutil.copyfile("/bin/ls", test_file) - - package = list( - lkm.LinuxKernelModuleHandler.parse( - str(test_file) - ) - ) - - assert package == [] - - def test_parse_invalid_lkm(self, tmp_path): - test_file = tmp_path / "fake.ko" - test_file.write_bytes(b"not an elf file") - - package = list( - lkm.LinuxKernelModuleHandler.parse( - str(test_file) - ) - ) - - assert package == [] - - def test_parse_linux_kernel_module(self): - test_file = self.test_data_dir / 'eeepc_acpi.ko' - - packages = list( - lkm.LinuxKernelModuleHandler.parse( - str(test_file) - ) - ) - - assert len(packages) == 1 - - package = packages[0] - - assert package.datasource_id == 'linux_kernel_module' - assert package.type == 'linux-kernel-module' - assert package.name == 'eeepc_acpi' - assert package.extracted_license_statement - assert package.description - assert package.parties - - def test_get_dependent_packages(self): - metadata = { - 'depends': [ - 'usbcore,cfg80211', - ] - } - - dependencies = ( - lkm.LinuxKernelModuleHandler.get_dependent_packages(metadata) - ) - - assert len(dependencies) == 2 - - assert dependencies[0].purl == ( - 'pkg:linux-kernel-module/usbcore' - ) - assert dependencies[0].scope == 'runtime' - assert dependencies[0].is_runtime is True - assert dependencies[0].is_optional is False - assert dependencies[0].extracted_requirement is None - - assert dependencies[1].purl == ( - 'pkg:linux-kernel-module/cfg80211' - ) - - def test_build_package_data_includes_dependencies(self): - metadata = { - 'license': ['GPL'], - 'depends': ['usbcore,cfg80211'], - } - - package_data = ( - lkm.LinuxKernelModuleHandler.build_package_data( - metadata=metadata, - location='/tmp/example.ko', - ) - ) - - assert package_data.name == 'example' - assert len(package_data.dependencies) == 2 - assert package_data.dependencies[0].purl == ( - 'pkg:linux-kernel-module/usbcore' - ) - assert package_data.dependencies[1].purl == ( - 'pkg:linux-kernel-module/cfg80211' - ) - - def test_build_package_data_preserves_unknown_metadata(self): - metadata = { - 'license': ['GPL'], - 'vermagic': ['6.8.0 SMP preempt mod_unload'], - 'srcversion': ['ABC123'], - 'firmware': ['example.bin'], - } - - package_data = ( - lkm.LinuxKernelModuleHandler.build_package_data( - metadata=metadata, - location='/tmp/example.ko', - ) - ) - - assert package_data.extra_data["vermagic"] == [ - "6.8.0 SMP preempt mod_unload" - ] - - assert package_data.extra_data["srcversion"] == [ - "ABC123" - ] - - assert package_data.extra_data["firmware"] == [ - "example.bin" - ] - - def test_build_package_data_handles_multiple_authors(self): - metadata = { - 'author': [ - 'Alice', - 'Bob', - ], - } - - package_data = ( - lkm.LinuxKernelModuleHandler.build_package_data( - metadata=metadata, - location='/tmp/example.ko', - ) - ) - - assert [party.name for party in package_data.parties] == [ - 'Alice', - 'Bob', - ] - - def test_datasource_id_is_registered(self): - handler = HANDLER_BY_DATASOURCE_ID[ - lkm.LinuxKernelModuleHandler.datasource_id - ] - - assert handler is lkm.LinuxKernelModuleHandler - - def test_build_package_data_multiple_alias_entries(self): - metadata = { - 'license': ['GPL'], - 'alias': ['usbcore', 'cfg80211'], - } - - package_data = lkm.LinuxKernelModuleHandler.build_package_data( - metadata=metadata, - location='/tmp/example.ko', - ) - - assert package_data.extra_data['alias'] == ['usbcore', 'cfg80211'] - assert 'depends' not in package_data.extra_data \ No newline at end of file diff --git a/tests/scancode/test_plugin_lkm.py b/tests/scancode/test_plugin_lkm.py new file mode 100644 index 0000000000..953286921a --- /dev/null +++ b/tests/scancode/test_plugin_lkm.py @@ -0,0 +1,127 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# ScanCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/nexB/scancode-toolkit for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +import json +from pathlib import Path + +from scancode import plugin_lkm +from scancode.cli_test_utils import run_scan_click + + +class TestLinuxKernelModuleScanner: + + test_data_dir = ( + Path(__file__).resolve().parents[2] + / 'tests' + / 'licensedcode' + / 'data' + / 'query' + ) + + def test_extract_modinfo(self): + test_file = self.test_data_dir / 'eeepc_acpi.ko' + + metadata = plugin_lkm.extract_modinfo(str(test_file)) + + assert 'license' in metadata + assert 'description' in metadata + assert 'author' in metadata + + def test_get_dependency_names(self): + metadata = { + 'depends': [ + 'usbcore,cfg80211,mac80211', + ] + } + + result = plugin_lkm.get_dependency_names(metadata) + + assert result == [ + 'usbcore', + 'cfg80211', + 'mac80211', + ] + + def test_get_dependency_names_handles_multiple_entries(self): + metadata = { + 'depends': [ + 'usbcore,cfg80211', + 'mac80211,netdev', + ] + } + + result = plugin_lkm.get_dependency_names(metadata) + + assert result == [ + 'usbcore', + 'cfg80211', + 'mac80211', + 'netdev', + ] + + def test_scan_unrelated_file_returns_empty_mapping(self): + test_file = self.test_data_dir / 'apache-2.0.LICENSE' + + result = plugin_lkm.scan_linux_kernel_module(str(test_file)) + + assert result == {} + + def test_scan_invalid_lkm_returns_empty_mapping(self, tmp_path): + test_file = tmp_path / 'fake.ko' + test_file.write_bytes(b'not an elf file') + + result = plugin_lkm.scan_linux_kernel_module(str(test_file)) + + assert result == {} + + def test_scan_linux_kernel_module(self): + test_file = self.test_data_dir / 'eeepc_acpi.ko' + + result = plugin_lkm.scan_linux_kernel_module(str(test_file)) + + assert list(result) == ['linux_kernel_module'] + + metadata = result['linux_kernel_module'] + assert metadata['license'] == ['GPL'] + assert metadata['description'] == ['Asus EeePC Hotkey Driver'] + assert metadata['author'] == [ + 'Julien Lerouge, Karol Kozimor, Eric Cooper', + ] + assert metadata['depends'] == [] + assert metadata['srcversion'] == ['7FD8A46D43685ADB0819A28'] + assert metadata['vermagic'] == [ + '2.6.24-19-generic SMP mod_unload 586 ', + ] + + def test_plugin_supplies_file_scanner(self): + scanner = plugin_lkm.LinuxKernelModuleScanner() + + assert scanner.get_scanner() is plugin_lkm.scan_linux_kernel_module + assert scanner.is_enabled(lkm=True) + assert not scanner.is_enabled(lkm=False) + assert list(scanner.resource_attributes) == ['linux_kernel_module'] + + def test_lkm_scan_adds_file_metadata_without_package_data(self, tmp_path): + test_file = self.test_data_dir / 'eeepc_acpi.ko' + result_file = tmp_path / 'lkm-scan.json' + + run_scan_click([ + '--lkm', + '--json-pp', + str(result_file), + str(test_file), + ]) + + with result_file.open(encoding='utf-8') as results: + scanned_file = json.load(results)['files'][0] + + metadata = scanned_file['linux_kernel_module'] + assert metadata['license'] == ['GPL'] + assert metadata['depends'] == [] + assert 'package_data' not in scanned_file