From: Stefano Tondo <[email protected]> Previous implementation only captured explicit RDEPENDS from recipe variables, missing implicit runtime dependencies auto-detected by Yocto's packaging system (shared libraries like libc6, libssl3, libz1).
This commit updates get_dependencies_by_scope() to: - Accept package parameter to read package-specific manifests - Read package manifests (PKGDATA) after packaging completes - Parse RDEPENDS including auto-detected shared library dependencies - Handle split packages correctly (multiple packages per recipe) - Fall back to recipe-level RDEPENDS if manifest unavailable Also clarifies that recursive dependency expansion is unnecessary: - Each package is processed separately in create_package_spdx() - Each package's direct dependencies are added as SPDX relationships - The resulting SBOM contains the complete dependency graph - SBOM consumers can traverse the graph for transitive dependencies Fixes lifecycle scope classification to capture ALL runtime dependencies (explicit + implicit). Signed-off-by: Stefano Tondo <[email protected]> Cc: "Ross Burton" <[email protected]> --- meta/classes/spdx-common.bbclass | 53 +++++++++---- meta/lib/oe/spdx30_tasks.py | 112 ++++++++++++++++++++++++++- meta/lib/oeqa/selftest/cases/spdx.py | 78 +++++++++++++++++++ 3 files changed, 227 insertions(+), 16 deletions(-) diff --git a/meta/classes/spdx-common.bbclass b/meta/classes/spdx-common.bbclass index 3110230c9e..fab99df75d 100644 --- a/meta/classes/spdx-common.bbclass +++ b/meta/classes/spdx-common.bbclass @@ -36,21 +36,44 @@ SPDX_LICENSES ??= "${COREBASE}/meta/files/spdx-licenses.json" SPDX_CUSTOM_ANNOTATION_VARS ??= "" -SPDX_CONCLUDED_LICENSE ??= "" -SPDX_CONCLUDED_LICENSE[doc] = "The license concluded by manual or external \ - license analysis. This should only be set when explicit license analysis \ - (manual review or external scanning tools) has been performed and a license \ - conclusion has been reached. When unset or empty, no concluded license is \ - included in the SBOM, indicating that no license analysis was performed. \ - When differences from the declared LICENSE are found, the preferred approach \ - is to correct the LICENSE field in the recipe and contribute the fix upstream \ - to OpenEmbedded. Use this variable locally only when upstream contribution is \ - not immediately possible or when the license conclusion is environment-specific. \ - Supports package-specific overrides via SPDX_CONCLUDED_LICENSE:${PN}. \ - This allows tracking license analysis results in SBOM while maintaining recipe \ - LICENSE field for build compatibility. \ - Example: SPDX_CONCLUDED_LICENSE = 'MIT & Apache-2.0' or \ - SPDX_CONCLUDED_LICENSE:${PN} = 'MIT & Apache-2.0'" +# Dependency scope classification +# --------------------------------- +# SPDX 3.0 supports three lifecycle scopes for dependencies: +# +# 1. runtime (LifecycleScopeType.runtime) +# - Dependencies needed to RUN the software +# - Automatically populated via RDEPENDS/RRECOMMENDS +# - Examples: shared libraries, interpreters, runtime packages +# +# 2. build (LifecycleScopeType.build) +# - Dependencies needed to BUILD the software +# - Calculated as: DEPENDS - RDEPENDS +# - Examples: compilers, build tools, -dev packages, test frameworks +# +# 3. test (LifecycleScopeType.test) +# - Must be explicitly marked via SPDX_FORCE_TEST_SCOPE +# - By default, test dependencies are classified as 'build' +# - Use only if you need to distinguish test from build dependencies +# +# This universal approach works for ALL ecosystems (C/C++, Rust, Go, npm, +# Python, Perl, etc.) because Yocto's packaging system already filters +# dev/test/build dependencies from runtime dependencies. + +# Escape hatches for edge cases (space-separated package names) +SPDX_FORCE_BUILD_SCOPE ??= "" +SPDX_FORCE_BUILD_SCOPE[doc] = "Space-separated list of dependencies to force into \ + build scope, overriding automatic classification. Use this when a dependency is \ + incorrectly classified as runtime. Example: SPDX_FORCE_BUILD_SCOPE = 'some-tool'" + +SPDX_FORCE_TEST_SCOPE ??= "" +SPDX_FORCE_TEST_SCOPE[doc] = "Space-separated list of dependencies to force into \ + test scope. Use this when you need to explicitly mark test dependencies. \ + Example: SPDX_FORCE_TEST_SCOPE = 'pytest-native mock-native'" + +SPDX_FORCE_RUNTIME_SCOPE ??= "" +SPDX_FORCE_RUNTIME_SCOPE[doc] = "Space-separated list of dependencies to force into \ + runtime scope, overriding automatic classification. Use this when a build tool is \ + actually needed at runtime. Example: SPDX_FORCE_RUNTIME_SCOPE = 'some-build-tool'" SPDX_MULTILIB_SSTATE_ARCHS ??= "${SSTATE_ARCHS}" diff --git a/meta/lib/oe/spdx30_tasks.py b/meta/lib/oe/spdx30_tasks.py index 99f2892dfb..c0268e8d02 100644 --- a/meta/lib/oe/spdx30_tasks.py +++ b/meta/lib/oe/spdx30_tasks.py @@ -911,7 +911,93 @@ def create_package_spdx(d): common_objset.doc.creationInfo ) + def get_dependencies_by_scope(d, package): + """ + Classify dependencies by SPDX LifecycleScopeType using Yocto's + native DEPENDS/RDEPENDS mechanism. + + This universal approach works for all ecosystems (C/C++, Rust, Go, + npm, Python, Perl, etc.) because Yocto's packaging system already + filters dev/test/build dependencies from runtime dependencies. + + + Note: Returns only DIRECT dependencies (not transitive/recursive). + Recursive expansion is unnecessary because: + 1. Each package is processed separately in create_package_spdx() + 2. Each package's direct dependencies are added as SPDX relationships + 3. The resulting SBOM contains the complete dependency graph + 4. SBOM consumers can traverse the graph for transitive dependencies + + Args: + d: BitBake datastore + package: Package name (e.g., 'acl', 'libacl1') + + Returns dict: { + 'runtime': set(), # LifecycleScopeType.runtime + 'build': set(), # LifecycleScopeType.build + 'test': set() # LifecycleScopeType.test (if explicitly marked) + } + """ + pn = d.getVar('PN') + + # Get all build-time dependencies + all_build = set((d.getVar('DEPENDS') or '').split()) + + # Get runtime dependencies from package manifest (includes auto-detected) + # This captures implicit shared library dependencies that Yocto detects + # during packaging (e.g., libc6, libssl3, libz1) + runtime = set() + + # Read package manifest to get actual runtime dependencies + try: + pkg_data = oe.packagedata.read_subpkgdata_dict(package, d) + # Extract RDEPENDS from manifest - format is "pkg1 (>= version) pkg2" + rdepends_str = pkg_data.get('RDEPENDS', '') + rrecommends_str = pkg_data.get('RRECOMMENDS', '') + + # Parse dependencies, removing version constraints + for dep in rdepends_str.split(): + # Skip version specifiers like "(>=", "2.42)", etc. + if dep and not dep.startswith('(') and not dep.endswith(')'): + runtime.add(dep) + + for dep in rrecommends_str.split(): + if dep and not dep.startswith('(') and not dep.endswith(')'): + runtime.add(dep) + + bb.debug(2, f"Package {package}: runtime deps from manifest: {runtime}") + except Exception as e: + # Fallback to recipe-level RDEPENDS if manifest not available + bb.warn(f"Could not read package manifest for {package}: {e}") + runtime.update((d.getVar('RDEPENDS:' + package) or '').split()) + runtime.update((d.getVar('RRECOMMENDS:' + package) or '').split()) + + # Non-runtime = everything in DEPENDS but not in RDEPENDS + non_runtime = all_build - runtime + + # Apply manual overrides for edge cases + force_build = set((d.getVar('SPDX_FORCE_BUILD_SCOPE') or '').split()) + force_test = set((d.getVar('SPDX_FORCE_TEST_SCOPE') or '').split()) + force_runtime = set((d.getVar('SPDX_FORCE_RUNTIME_SCOPE') or '').split()) + + # Apply overrides + runtime = (runtime | force_runtime) - force_build - force_test + build = (non_runtime | force_build) - force_runtime - force_test + test = force_test + + return { + 'runtime': runtime, + 'build': build, + 'test': test + } + runtime_spdx_deps = set() + build_spdx_deps = set() + test_spdx_deps = set() + + # Get dependency scope classification using universal approach + # Pass the package name to read from package manifest + deps_by_scope = get_dependencies_by_scope(d, package) deps = bb.utils.explode_dep_versions2(localdata.getVar("RDEPENDS") or "") seen_deps = set() @@ -943,7 +1029,15 @@ def create_package_spdx(d): ) dep_package_cache[dep] = dep_spdx_package - runtime_spdx_deps.add(dep_spdx_package) + # Determine scope based on universal classification + if dep in deps_by_scope['runtime'] or dep_pkg in deps_by_scope['runtime']: + runtime_spdx_deps.add(dep_spdx_package) + elif dep in deps_by_scope['test'] or dep_pkg in deps_by_scope['test']: + test_spdx_deps.add(dep_spdx_package) + else: + # If it's in RDEPENDS but not classified as runtime or test, + # treat as runtime (this shouldn't happen normally) + runtime_spdx_deps.add(dep_spdx_package) seen_deps.add(dep) if runtime_spdx_deps: @@ -954,6 +1048,22 @@ def create_package_spdx(d): [oe.sbom30.get_element_link_id(dep) for dep in runtime_spdx_deps], ) + if build_spdx_deps: + pkg_objset.new_scoped_relationship( + [spdx_package], + oe.spdx30.RelationshipType.dependsOn, + oe.spdx30.LifecycleScopeType.build, + [oe.sbom30.get_element_link_id(dep) for dep in build_spdx_deps], + ) + + if test_spdx_deps: + pkg_objset.new_scoped_relationship( + [spdx_package], + oe.spdx30.RelationshipType.dependsOn, + oe.spdx30.LifecycleScopeType.test, + [oe.sbom30.get_element_link_id(dep) for dep in test_spdx_deps], + ) + oe.sbom30.write_recipe_jsonld_doc(d, pkg_objset, "packages", deploydir) oe.sbom30.write_recipe_jsonld_doc(d, common_objset, "common-package", deploydir) diff --git a/meta/lib/oeqa/selftest/cases/spdx.py b/meta/lib/oeqa/selftest/cases/spdx.py index 41ef52fce1..c874247f24 100644 --- a/meta/lib/oeqa/selftest/cases/spdx.py +++ b/meta/lib/oeqa/selftest/cases/spdx.py @@ -414,3 +414,81 @@ class SPDX30Check(SPDX3CheckBase, OESelftestTestCase): value, ["enabled", "disabled"], f"Unexpected PACKAGECONFIG value '{value}' for {key}" ) + + def test_lifecycle_scope_dependencies(self): + """ + Test that lifecycle scope classification correctly captures both explicit + and implicit runtime dependencies by reading package manifests. + + This test verifies that: + 1. Runtime dependencies include implicit shared library dependencies (e.g., libc6, libz1) + 2. Build dependencies are properly classified + 3. Dependencies are read from package manifests, not just recipe variables + 4. Split packages are handled correctly + + Uses 'acl' as test target because it has well-known implicit dependencies + (glibc, libacl) that are auto-detected by Yocto's packaging. + """ + objset = self.check_recipe_spdx( + "acl", + "{DEPLOY_DIR_SPDX}/{SSTATE_PKGARCH}/packages/package-acl.spdx.json", + ) + + # Find the acl package element + acl_package = None + for pkg in objset.foreach_type(oe.spdx30.software_Package): + if hasattr(pkg, 'name') and pkg.name == 'acl': + acl_package = pkg + break + + self.assertIsNotNone(acl_package, "Unable to find acl package in SPDX") + + # Find runtime dependencies (LifecycleScopeType.runtime) + runtime_deps = [] + build_deps = [] + + for rel in objset.foreach_type(oe.spdx30.Relationship): + # Check if this relationship is from our acl package + if (hasattr(rel, 'from_') and + hasattr(rel.from_, '_id') and + rel.from_._id == acl_package._id): + + # Check the lifecycle scope + if hasattr(rel, 'scope'): + scope_value = rel.scope[0] if isinstance(rel.scope, list) else rel.scope + + # Get the dependency name + if hasattr(rel, 'to') and len(rel.to) > 0: + dep_element = rel.to[0] + if hasattr(dep_element, 'name'): + dep_name = dep_element.name + + if scope_value == oe.spdx30.LifecycleScopeType.runtime: + runtime_deps.append(dep_name) + self.logger.info(f"Found runtime dependency: {dep_name}") + elif scope_value == oe.spdx30.LifecycleScopeType.build: + build_deps.append(dep_name) + self.logger.info(f"Found build dependency: {dep_name}") + + # Verify we found runtime dependencies + self.assertTrue( + len(runtime_deps) > 0, + "No runtime dependencies found - lifecycle scope may not be working" + ) + + # Verify implicit dependencies are captured + # acl should depend on glibc (implicit shared library dependency) + has_glibc = any('glibc' in dep.lower() for dep in runtime_deps) + self.assertTrue( + has_glibc, + f"Expected implicit glibc runtime dependency not found. Found: {runtime_deps}" + ) + + # Verify libacl is in runtime dependencies (explicit dependency) + has_libacl = any('libacl' in dep.lower() for dep in runtime_deps) + self.assertTrue( + has_libacl, + f"Expected libacl runtime dependency not found. Found: {runtime_deps}" + ) + + self.logger.info(f"Test passed: Found {len(runtime_deps)} runtime deps, {len(build_deps)} build deps") -- 2.53.0
-=-=-=-=-=-=-=-=-=-=-=- Links: You receive all messages sent to this group. View/Reply Online (#231572): https://lists.openembedded.org/g/openembedded-core/message/231572 Mute This Topic: https://lists.openembedded.org/mt/117922394/21656 Group Owner: [email protected] Unsubscribe: https://lists.openembedded.org/g/openembedded-core/unsub [[email protected]] -=-=-=-=-=-=-=-=-=-=-=-
