Skip to content

Commit 652b247

Browse files
committed
Initial sbom support
1 parent f57d99f commit 652b247

File tree

4 files changed

+448
-23
lines changed

4 files changed

+448
-23
lines changed

relenv/__main__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from argparse import ArgumentParser
1111
from types import ModuleType
1212

13-
from . import build, buildenv, check, create, fetch, pyversions, toolchain
13+
from . import build, buildenv, check, create, fetch, pyversions, sbom, toolchain
1414
from .common import __version__
1515

1616

@@ -41,6 +41,7 @@ def setup_cli() -> ArgumentParser:
4141
check,
4242
buildenv,
4343
pyversions,
44+
sbom,
4445
]
4546
for mod in modules_to_setup:
4647
mod.setup_parser(subparsers)

relenv/build/common/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
patch_file,
2323
update_sbom_checksums,
2424
copy_sbom_files,
25+
generate_relenv_sbom,
2526
)
2627

2728
from .builder import (
@@ -45,6 +46,7 @@
4546
"patch_file",
4647
"update_sbom_checksums",
4748
"copy_sbom_files",
49+
"generate_relenv_sbom",
4850
# Builders (specific build functions)
4951
"build_openssl",
5052
"build_openssl_fips",

relenv/build/common/install.py

Lines changed: 204 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,19 @@
1818
import shutil
1919
import sys
2020
import tarfile
21+
import time
2122
from types import ModuleType
22-
from typing import IO, MutableMapping, Optional, Sequence, Union, TYPE_CHECKING
23+
from typing import (
24+
Any,
25+
Dict,
26+
IO,
27+
List,
28+
MutableMapping,
29+
Optional,
30+
Sequence,
31+
Union,
32+
TYPE_CHECKING,
33+
)
2334

2435
from relenv.common import (
2536
LINUX,
@@ -414,31 +425,201 @@ def copy_sbom_files(dirs: Dirs) -> None:
414425
Copy SBOM files from Python source to the prefix directory.
415426
416427
SBOM files (Software Bill of Materials) document the build dependencies
417-
and source file checksums. These files are available in Python 3.12+.
428+
and source file checksums. These files are available in Python 3.12.2+.
418429
419430
:param dirs: The working directories
420431
:type dirs: ``relenv.build.common.Dirs``
421432
"""
422-
# Find the Python source directory in dirs.sources
423-
python_source = None
424-
if dirs.sources.exists():
425-
# Look for Python-{version} directory
426-
for entry in dirs.sources.iterdir():
427-
if entry.is_dir() and entry.name.startswith("Python-"):
428-
python_source = entry
429-
break
430-
431-
if python_source:
432-
sbom_files = ["sbom.spdx.json", "externals.spdx.json"]
433-
source_misc_dir = python_source / "Misc"
434-
for sbom_file in sbom_files:
435-
source_sbom = source_misc_dir / sbom_file
436-
if source_sbom.exists():
437-
dest_sbom = pathlib.Path(dirs.prefix) / sbom_file
438-
shutil.copy2(str(source_sbom), str(dest_sbom))
439-
log.info("Copied %s to archive", sbom_file)
440-
else:
441-
log.debug("SBOM file %s not found (Python < 3.12?)", sbom_file)
433+
# Find the Python source directory for the specific version being built
434+
python_version = dirs.version
435+
python_source = dirs.sources / f"Python-{python_version}"
436+
437+
if not python_source.exists():
438+
log.debug("Python source directory not found: %s", python_source)
439+
return
440+
441+
sbom_files = ["sbom.spdx.json", "externals.spdx.json"]
442+
source_misc_dir = python_source / "Misc"
443+
for sbom_file in sbom_files:
444+
source_sbom = source_misc_dir / sbom_file
445+
if source_sbom.exists():
446+
dest_sbom = pathlib.Path(dirs.prefix) / sbom_file
447+
shutil.copy2(str(source_sbom), str(dest_sbom))
448+
log.info("Copied %s to archive", sbom_file)
449+
else:
450+
log.debug("SBOM file %s not found (Python < 3.12.2?)", sbom_file)
451+
452+
453+
def generate_relenv_sbom(env: MutableMapping[str, str], dirs: Dirs) -> None:
454+
"""
455+
Generate relenv-sbom.spdx.json documenting actual dependencies in the build.
456+
457+
This SBOM documents what relenv actually built, which may differ from Python's
458+
externals.spdx.json (which documents Python's intended/bundled dependencies).
459+
460+
Only generates SBOM for Python 3.12+ (when Python started including SBOM files).
461+
462+
:param env: The environment dictionary
463+
:type env: dict
464+
:param dirs: The working directories
465+
:type dirs: ``relenv.build.common.Dirs``
466+
"""
467+
# Only generate SBOM for Python 3.12+
468+
python_version = dirs.version
469+
version_parts = python_version.split(".")
470+
if len(version_parts) >= 2:
471+
major = int(version_parts[0])
472+
minor = int(version_parts[1])
473+
if major < 3 or (major == 3 and minor < 12):
474+
log.debug(
475+
"Skipping relenv-sbom.spdx.json generation for Python %s (< 3.12)",
476+
python_version,
477+
)
478+
return
479+
480+
from .builder import get_dependency_version
481+
import relenv
482+
483+
platform_map = {
484+
"linux": "linux",
485+
"darwin": "darwin",
486+
"win32": "win32",
487+
}
488+
platform = platform_map.get(sys.platform, sys.platform)
489+
490+
# Build dependency list - get versions from python-versions.json
491+
packages: List[Dict[str, Any]] = []
492+
493+
# Define dependencies we build (these are the ones relenv compiles)
494+
# Order matters - list them in a logical grouping
495+
relenv_deps = [
496+
# Compression libraries
497+
("bzip2", "https://sourceware.org/pub/bzip2/bzip2-{version}.tar.gz"),
498+
(
499+
"xz",
500+
"https://github.com/tukaani-project/xz/releases/download/v{version}/xz-{version}.tar.xz",
501+
),
502+
(
503+
"zlib",
504+
"https://github.com/madler/zlib/releases/download/v{version}/zlib-{version}.tar.gz",
505+
),
506+
# Crypto and security
507+
(
508+
"openssl",
509+
"https://github.com/openssl/openssl/releases/download/openssl-{version}/openssl-{version}.tar.gz",
510+
),
511+
(
512+
"libxcrypt",
513+
"https://github.com/besser82/libxcrypt/releases/download/v{version}/libxcrypt-{version}.tar.xz",
514+
),
515+
# Database
516+
("sqlite", "https://sqlite.org/{year}/sqlite-autoconf-{sqliteversion}.tar.gz"),
517+
("gdbm", "https://ftp.gnu.org/gnu/gdbm/gdbm-{version}.tar.gz"),
518+
# Terminal libraries
519+
("ncurses", "https://ftp.gnu.org/gnu/ncurses/ncurses-{version}.tar.gz"),
520+
("readline", "https://ftp.gnu.org/gnu/readline/readline-{version}.tar.gz"),
521+
# Other libraries
522+
(
523+
"libffi",
524+
"https://github.com/libffi/libffi/releases/download/v{version}/libffi-{version}.tar.gz",
525+
),
526+
(
527+
"uuid",
528+
"https://sourceforge.net/projects/libuuid/files/libuuid-{version}.tar.gz",
529+
),
530+
]
531+
532+
# Linux-specific dependencies
533+
if sys.platform == "linux":
534+
relenv_deps.extend(
535+
[
536+
(
537+
"tirpc",
538+
"https://downloads.sourceforge.net/project/libtirpc/"
539+
"libtirpc/{version}/libtirpc-{version}.tar.bz2",
540+
),
541+
(
542+
"krb5",
543+
"https://kerberos.org/dist/krb5/{major_minor}/krb5-{version}.tar.gz",
544+
),
545+
]
546+
)
547+
548+
for dep_name, url_template in relenv_deps:
549+
dep_info = get_dependency_version(dep_name, platform)
550+
if dep_info:
551+
version = dep_info["version"]
552+
url = dep_info.get("url", url_template).format(
553+
version=version,
554+
sqliteversion=dep_info.get("sqliteversion", ""),
555+
year=dep_info.get("year", "2025"),
556+
major_minor=".".join(version.split(".")[:2]),
557+
)
558+
checksum = dep_info.get("sha256", "")
559+
560+
package: Dict[str, Any] = {
561+
"SPDXID": f"SPDXRef-PACKAGE-{dep_name}",
562+
"name": dep_name,
563+
"versionInfo": version,
564+
"downloadLocation": url,
565+
"primaryPackagePurpose": "SOURCE",
566+
"licenseConcluded": "NOASSERTION",
567+
}
568+
569+
if checksum:
570+
package["checksums"] = [
571+
{
572+
"algorithm": "SHA256",
573+
"checksumValue": checksum,
574+
}
575+
]
576+
577+
packages.append(package)
578+
579+
# Add Python runtime packages installed via pip
580+
# These are determined at finalize time after pip install
581+
python_lib = pathlib.Path(dirs.prefix) / "lib"
582+
for entry in python_lib.glob("python*/site-packages/*.dist-info"):
583+
# Parse package name and version from dist-info directory
584+
# Format: package-version.dist-info
585+
dist_name = entry.name.replace(".dist-info", "")
586+
if "-" in dist_name:
587+
parts = dist_name.rsplit("-", 1)
588+
if len(parts) == 2:
589+
pkg_name, pkg_version = parts
590+
package2: Dict[str, Any] = {
591+
"SPDXID": f"SPDXRef-PACKAGE-python-{pkg_name}",
592+
"name": pkg_name,
593+
"versionInfo": pkg_version,
594+
"downloadLocation": "NOASSERTION",
595+
"primaryPackagePurpose": "LIBRARY",
596+
"licenseConcluded": "NOASSERTION",
597+
"comment": "Python package installed via pip",
598+
}
599+
packages.append(package2)
600+
601+
# Create the SBOM document
602+
sbom = {
603+
"SPDXID": "SPDXRef-DOCUMENT",
604+
"spdxVersion": "SPDX-2.3",
605+
"name": f"relenv-{env.get('RELENV_PY_VERSION', 'unknown')}-{env.get('RELENV_HOST', 'unknown')}",
606+
"dataLicense": "CC0-1.0",
607+
"creationInfo": {
608+
"created": f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}",
609+
"creators": [
610+
f"Tool: relenv-{relenv.__version__}",
611+
],
612+
"comment": "This SBOM documents the actual dependencies built and installed by relenv. "
613+
"It may differ from Python's externals.spdx.json which documents Python's intended dependencies.",
614+
},
615+
"packages": packages,
616+
}
617+
618+
# Write the SBOM file
619+
sbom_path = pathlib.Path(dirs.prefix) / "relenv-sbom.spdx.json"
620+
with io.open(sbom_path, "w") as fp:
621+
json.dump(sbom, fp, indent=2)
622+
log.info("Generated relenv-sbom.spdx.json with %d packages", len(packages))
442623

443624

444625
def finalize(
@@ -587,6 +768,7 @@ def runpip(pkg: Union[str, os.PathLike[str]], upgrade: bool = False) -> None:
587768
runpip("relenv", upgrade=True)
588769

589770
copy_sbom_files(dirs)
771+
generate_relenv_sbom(env, dirs)
590772

591773
globs = [
592774
"/bin/python*",

0 commit comments

Comments
 (0)