[PATCH v2 03/18] spdx30: Add ecosystem-specific PURL generation

Stefano Tondo
2026-02-21T05:10:23Z

Add a function that identifies ecosystem-specific PURLs (cargo, golang,
pypi, npm, cpan, nuget, maven) for dependency packages, working alongside
oe.purl.get_base_purl() which provides pkg:yocto PURLs.

Key design decision: Does NOT return pkg:generic fallback. This ensures:
- No overlap with the base pkg:yocto generation
- Packages get BOTH purls: pkg:yocto/layer/pkg@ver AND pkg:cargo/pkg@ver
- Maximum traceability for compliance tools

Detects ecosystems via:
- Unambiguous file extensions (.crate for Rust)
- Recipe inheritance (pypi, npm, cpan, nuget, maven classes)
- BitBake variables (GO_IMPORT, PYPI_PACKAGE, MAVEN_GROUP_ID)

Signed-off-by: Stefano Tondo <stefano.tondo.ext@siemens.com>
---
meta/lib/oe/spdx30_tasks.py | 113 ++++++++++++++++++++++++++++++++++++
1 file changed, 113 insertions(+)

diff --git a/meta/lib/oe/spdx30_tasks.py b/meta/lib/oe/spdx30_tasks.py
index 789b39bd93..0ee39ffcd5 100644
--- a/meta/lib/oe/spdx30_tasks.py
+++ b/meta/lib/oe/spdx30_tasks.py
@@ -13,12 +13,125 @@ import oe.spdx30
import oe.spdx_common
import oe.sdk
import os
+import re

from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path

+
+def extract_dependency_metadata(d, file_name):
+ """Extract ecosystem-specific PURL for dependency packages.
+
+ Uses recipe metadata to identify ecosystem PURLs (cargo, golang, pypi,
+ npm, cpan, nuget, maven). Returns (version, purl) or (None, None).
+ Does NOT return pkg:generic; base pkg:yocto is handled by get_base_purl().
+ """
+
+ pv = d.getVar("PV")
+ version = pv if pv else None
+ purl = None
+
+ # Rust crate (.crate extension is unambiguous)
+ if file_name.endswith('.crate'):
+ crate_match = re.match(r'^(.+?)-(\d+\.\d+\.\d+(?:\.\d+)?(?:[-+][\w.]+)?)\.crate$', file_name)
+ if crate_match:
+ name = crate_match.group(1)
+ version = crate_match.group(2)
+ purl = f"pkg:cargo/{name}@{version}"
+ return (version, purl)
+
+ # Go module via GO_IMPORT variable
+ go_import = d.getVar("GO_IMPORT")
+ if go_import and version:
+ purl = f"pkg:golang/{go_import}@{version}"
+ return (version, purl)
+
+ # Go module from filename with explicit hosting domain
+ go_match = re.match(
+ r'^((?:github|gitlab|gopkg|golang|go\.googlesource)\.com\.[\w.]+(?:\.[\w-]+)*?)-(v?\d+\.\d+\.\d+(?:[-+][\w.]+)?)\.',
+ file_name
+ )
+ if go_match:
+ module_path = go_match.group(1).replace('.', '/', 1)
+ parts = module_path.split('/', 1)
+ if len(parts) == 2:
+ domain = parts[0]
+ path = parts[1].replace('.', '/')
+ module_path = f"{domain}/{path}"
+
+ version = go_match.group(2)
+ purl = f"pkg:golang/{module_path}@{version}"
+ return (version, purl)
+
+ # PyPI package
+ if bb.data.inherits_class("pypi", d) and version:
+ pypi_package = d.getVar("PYPI_PACKAGE")
+ if pypi_package:
+ # Normalize per PEP 503
+ name = re.sub(r"[-_.]+", "-", pypi_package).lower()
+ purl = f"pkg:pypi/{name}@{version}"
+ return (version, purl)
+
+ # NPM package
+ if bb.data.inherits_class("npm", d) and version:
+ bpn = d.getVar("BPN")
+ if bpn:
+ name = bpn[4:] if bpn.startswith('npm-') else bpn
+ purl = f"pkg:npm/{name}@{version}"
+ return (version, purl)
+
+ # CPAN package
+ if bb.data.inherits_class("cpan", d) and version:
+ bpn = d.getVar("BPN")
+ if bpn:
+ if bpn.startswith('perl-'):
+ name = bpn[5:]
+ elif bpn.startswith('libperl-'):
+ name = bpn[8:]
+ else:
+ name = bpn
+ purl = f"pkg:cpan/{name}@{version}"
+ return (version, purl)
+
+ # NuGet package
+ if (bb.data.inherits_class("nuget", d) or bb.data.inherits_class("dotnet", d)) and version:
+ bpn = d.getVar("BPN")
+ if bpn:
+ if bpn.startswith('dotnet-'):
+ name = bpn[7:]
+ elif bpn.startswith('nuget-'):
+ name = bpn[6:]
+ else:
+ name = bpn
+ purl = f"pkg:nuget/{name}@{version}"
+ return (version, purl)
+
+ # Maven package
+ if bb.data.inherits_class("maven", d) and version:
+ group_id = d.getVar("MAVEN_GROUP_ID")
+ artifact_id = d.getVar("MAVEN_ARTIFACT_ID")
+
+ if group_id and artifact_id:
+ purl = f"pkg:maven/{group_id}/{artifact_id}@{version}"
+ return (version, purl)
+ else:
+ bpn = d.getVar("BPN")
+ if bpn:
+ if bpn.startswith('maven-'):
+ name = bpn[6:]
+ elif bpn.startswith('java-'):
+ name = bpn[5:]
+ else:
+ name = bpn
+ purl = f"pkg:maven/{name}@{version}"
+ return (version, purl)
+
+ # Base pkg:yocto PURL is handled by oe.purl.get_base_purl()
+ return (version, None)
+
+
def walk_error(err):
bb.error(f"ERROR walking {err.filename}: {err}")

--
2.53.0