@@ -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}"
@@ -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)
@@ -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")