Repository: osbuild/osbuild
Pull request #2001 (2025-02-05 10:38:49)

stages/systemd.unit.create: support .swap units
Add support for .swap systemd units to the org.osbuild.systemd.unit.create stage.


Patch:
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 240f6986..de38c968 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -25,7 +25,7 @@ jobs:
       - name: "Run Linters"
         uses: osbuild/containers/src/actions/privdocker@552e30cf1b4ed19c6ddaa57f96c342b3dff4227b
         with:
-          image: ghcr.io/osbuild/osbuild-ci:latest-202501201758
+          image: ghcr.io/osbuild/osbuild-ci:latest-202502250751
           run: |
             make lint
 
diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml
index 0082eb11..aba0a99d 100644
--- a/.github/workflows/generate.yml
+++ b/.github/workflows/generate.yml
@@ -49,7 +49,7 @@ jobs:
     - name: "Regenerate Test Data"
       uses: osbuild/containers/src/actions/privdocker@552e30cf1b4ed19c6ddaa57f96c342b3dff4227b
       with:
-        image: ghcr.io/osbuild/osbuild-ci:latest-202501201758
+        image: ghcr.io/osbuild/osbuild-ci:latest-202502250751
         run: |
           make test-data
           git diff --exit-code -- ./test/data
diff --git a/.mypy.ini b/.mypy.ini
index c1af4012..1a0e5d67 100644
--- a/.mypy.ini
+++ b/.mypy.ini
@@ -18,3 +18,5 @@ ignore_missing_imports = True
 [mypy-libdnf5.*]
 ignore_missing_imports = True
 
+[mypy-license_expression.*]
+ignore_missing_imports = True
diff --git a/Schutzfile b/Schutzfile
index b5cb013b..b639ff3e 100644
--- a/Schutzfile
+++ b/Schutzfile
@@ -50,7 +50,7 @@
           {
             "title": "fedora-updates",
             "name": "fedora-updates",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f40/f40-x86_64-updates-released-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f40/f40-x86_64-updates-released-20250301"
           }
         ],
         "aarch64": [
@@ -62,7 +62,7 @@
           {
             "title": "fedora-updates",
             "name": "fedora-updates",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f40/f40-aarch64-updates-released-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f40/f40-aarch64-updates-released-20250301"
           }
         ]
       }
@@ -81,7 +81,7 @@
           {
             "title": "fedora-updates",
             "name": "fedora-updates",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f41/f41-x86_64-updates-released-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f41/f41-x86_64-updates-released-20250301"
           }
         ],
         "aarch64": [
@@ -93,7 +93,7 @@
           {
             "title": "fedora-updates",
             "name": "fedora-updates",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f41/f41-aarch64-updates-released-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f41/f41-aarch64-updates-released-20250301"
           }
         ]
       }
@@ -107,34 +107,34 @@
           {
             "title": "baseos",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-x86_64-baseos-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-x86_64-baseos-20250301"
           },
           {
             "title": "appstream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-x86_64-appstream-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-x86_64-appstream-20250301"
           },
           {
             "title": "crb",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-x86_64-crb-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-x86_64-crb-20250301"
           }
         ],
         "aarch64": [
           {
             "title": "baseos",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-aarch64-baseos-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-aarch64-baseos-20250301"
           },
           {
             "title": "appstream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-aarch64-appstream-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-aarch64-appstream-20250301"
           },
           {
             "title": "crb",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-aarch64-crb-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-aarch64-crb-20250301"
           }
         ]
       }
@@ -148,34 +148,34 @@
           {
             "title": "baseos",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-x86_64-baseos-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-x86_64-baseos-20250301"
           },
           {
             "title": "appstream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-x86_64-appstream-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-x86_64-appstream-20250301"
           },
           {
             "title": "crb",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-x86_64-crb-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-x86_64-crb-20250301"
           }
         ],
         "aarch64": [
           {
             "title": "baseos",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-aarch64-baseos-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-aarch64-baseos-20250301"
           },
           {
             "title": "appstream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-aarch64-appstream-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-aarch64-appstream-20250301"
           },
           {
             "title": "crb",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-aarch64-crb-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el10/cs10-aarch64-crb-20250301"
           }
         ]
       }
@@ -230,34 +230,34 @@
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-BaseOS",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.6-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.6-20250301"
           },
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-AppStream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.6-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.6-20250301"
           },
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-CRB",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-crb-n9.6-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-crb-n9.6-20250301"
           }
         ],
         "aarch64": [
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-BaseOS",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-baseos-n9.6-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-baseos-n9.6-20250301"
           },
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-AppStream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-appstream-n9.6-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-appstream-n9.6-20250301"
           },
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-CRB",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-crb-n9.6-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-crb-n9.6-20250301"
           }
         ]
       }
@@ -271,34 +271,34 @@
           {
             "title": "RHEL-10-RPMREPO-NIGHTLY-BaseOS",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-x86_64-baseos-n10.0-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-x86_64-baseos-n10.0-20250301"
           },
           {
             "title": "RHEL-10-RPMREPO-NIGHTLY-AppStream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-x86_64-appstream-n10.0-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-x86_64-appstream-n10.0-20250301"
           },
           {
             "title": "RHEL-10-RPMREPO-NIGHTLY-CRB",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-x86_64-crb-n10.0-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-x86_64-crb-n10.0-20250301"
           }
         ],
         "aarch64": [
           {
             "title": "RHEL-10-RPMREPO-NIGHTLY-BaseOS",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-aarch64-baseos-n10.0-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-aarch64-baseos-n10.0-20250301"
           },
           {
             "title": "RHEL-10-RPMREPO-NIGHTLY-AppStream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-aarch64-appstream-n10.0-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-aarch64-appstream-n10.0-20250301"
           },
           {
             "title": "RHEL-10-RPMREPO-NIGHTLY-CRB",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-aarch64-crb-n10.0-20250218"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el10/el10-aarch64-crb-n10.0-20250301"
           }
         ]
       }
diff --git a/osbuild.spec b/osbuild.spec
index ef1f5d37..c8196959 100644
--- a/osbuild.spec
+++ b/osbuild.spec
@@ -148,6 +148,13 @@ Requires: python3-libdnf5 >= 5.2.1
 Requires: python3-dnf
 %endif
 
+%if 0%{?fedora}
+# RHEL / CS does not have python3-license-expression
+# It is needed for validating license expressions in RPM packages when generating SBOMs
+# While SBOMs can be generated also without this package, it is recommended to have it.
+Recommends: python3-license-expression
+%endif
+
 # osbuild 125 added a new "solver" field and osbuild-composer only
 # supports this since 116
 Conflicts: osbuild-composer <= 115
diff --git a/osbuild/solver/dnf.py b/osbuild/solver/dnf.py
index 13affb9c..99217d5e 100755
--- a/osbuild/solver/dnf.py
+++ b/osbuild/solver/dnf.py
@@ -17,7 +17,7 @@ from osbuild.util.sbom.spdx import sbom_pkgset_to_spdx2_doc
 
 
 class DNF(SolverBase):
-    def __init__(self, request, persistdir, cache_dir):
+    def __init__(self, request, persistdir, cache_dir, license_index_path=None):
         arch = request["arch"]
         releasever = request.get("releasever")
         module_platform_id = request["module_platform_id"]
@@ -93,6 +93,9 @@ class DNF(SolverBase):
         # enable module resolving
         self.base_module = dnf.module.module_base.ModuleBase(self.base)
 
+        # Custom license index file path use for SBOM generation
+        self.license_index_path = license_index_path
+
     @staticmethod
     def _dnfrepo(desc, parent_conf=None):
         """Makes a dnf.repo.Repo out of a JSON repository description"""
@@ -167,15 +170,14 @@ class DNF(SolverBase):
     def _timestamp_to_rfc3339(timestamp):
         return datetime.utcfromtimestamp(timestamp).strftime('%Y-%m-%dT%H:%M:%SZ')
 
-    @staticmethod
-    def _sbom_for_pkgset(pkgset: List[dnf.package.Package]) -> Dict:
+    def _sbom_for_pkgset(self, pkgset: List[dnf.package.Package]) -> Dict:
         """
         Create an SBOM document for the given package set.
 
         For now, only SPDX v2 is supported.
         """
         pkgset = dnf_pkgset_to_sbom_pkgset(pkgset)
-        spdx_doc = sbom_pkgset_to_spdx2_doc(pkgset)
+        spdx_doc = sbom_pkgset_to_spdx2_doc(pkgset, self.license_index_path)
         return spdx_doc.to_dict()
 
     def dump(self):
diff --git a/osbuild/solver/dnf5.py b/osbuild/solver/dnf5.py
index 9b7a2271..58e33e71 100755
--- a/osbuild/solver/dnf5.py
+++ b/osbuild/solver/dnf5.py
@@ -56,7 +56,7 @@ class DNF5(SolverBase):
     """
 
     # pylint: disable=too-many-arguments
-    def __init__(self, request, persistdir, cachedir):
+    def __init__(self, request, persistdir, cachedir, license_index_path=None):
         arch = request["arch"]
         releasever = request.get("releasever")
         module_platform_id = request["module_platform_id"]
@@ -167,6 +167,9 @@ class DNF5(SolverBase):
         except RuntimeError as e:
             raise RepoError(e) from e
 
+        # Custom license index file path use for SBOM generation
+        self.license_index_path = license_index_path
+
     _BASEARCH_MAP = _invert({
         'aarch64': ('aarch64',),
         'alpha': ('alpha', 'alphaev4', 'alphaev45', 'alphaev5', 'alphaev56',
@@ -278,15 +281,14 @@ class DNF5(SolverBase):
     def _timestamp_to_rfc3339(timestamp):
         return datetime.utcfromtimestamp(timestamp).strftime('%Y-%m-%dT%H:%M:%SZ')
 
-    @staticmethod
-    def _sbom_for_pkgset(pkgset: List[dnf5.rpm.Package]) -> Dict:
+    def _sbom_for_pkgset(self, pkgset: List[dnf5.rpm.Package]) -> Dict:
         """
         Create an SBOM document for the given package set.
 
         For now, only SPDX v2 is supported.
         """
         pkgset = dnf_pkgset_to_sbom_pkgset(pkgset)
-        spdx_doc = sbom_pkgset_to_spdx2_doc(pkgset)
+        spdx_doc = sbom_pkgset_to_spdx2_doc(pkgset, self.license_index_path)
         return spdx_doc.to_dict()
 
     def dump(self):
diff --git a/osbuild/util/sbom/spdx.py b/osbuild/util/sbom/spdx.py
index 94eb787e..541452b3 100644
--- a/osbuild/util/sbom/spdx.py
+++ b/osbuild/util/sbom/spdx.py
@@ -1,11 +1,79 @@
+import os
 from datetime import datetime
-from typing import List, Union
+from typing import Dict, List, Optional, Union
 from uuid import uuid4
 
 import osbuild
 import osbuild.util.sbom.model as sbom_model
 import osbuild.util.sbom.spdx2 as spdx2
 
+try:
+    from license_expression import ExpressionError, get_spdx_licensing
+except ImportError:
+    get_spdx_licensing = None
+    ExpressionError = None
+
+
+class SpdxLicenseExpressionCreator:
+    """
+    Class for creating SPDX license expressions from license strings.
+
+    This class uses the license-expression package to parse license strings and convert them to SPDX license, if
+    possible.
+
+    The class object also keeps track of all extracted licensing information objects that were created during the
+    conversion process. The extracted licensing information objects are stored in a dictionary, where the key is the
+    license reference ID and the value is the ExtractedLicensingInfo object.
+    """
+
+    def __init__(self, license_index_location=None):
+        self._extracted_license_infos: Dict[str, spdx2.ExtractedLicensingInfo] = {}
+        self._spdx_licensing = None
+
+        if get_spdx_licensing:
+            if license_index_location:
+                self._spdx_licensing = get_spdx_licensing(license_index_location)
+            else:
+                self._spdx_licensing = get_spdx_licensing()
+        elif license_index_location:
+            raise ValueError("The license-expression package is not available. "
+                             "Specify the license index location has no effect.")
+
+    def _to_extracted_license_info(self, license_str: str) -> spdx2.ExtractedLicensingInfo:
+        eli = spdx2.ExtractedLicensingInfo(license_str)
+        return self._extracted_license_infos.setdefault(eli.license_ref_id, eli)
+
+    def ensure_license_expression(self, license_str: str) -> Union[str, spdx2.ExtractedLicensingInfo]:
+        """
+        Convert a license string to a valid SPDX license expression or wrap it in an ExtractedLicensingInfo object.
+
+        This function uses the license-expression package to parse the license string and convert it to an SPDX license
+        expression. If the license string can't be parsed and converted to an SPDX license expression, it is wrapped in an
+        ExtractedLicensingInfo object.
+
+        If the license-expression package is not available, the license string is always wrapped in an
+        ExtractedLicensingInfo object.
+
+        License strings that are already SPDX license ref IDs are returned as is.
+        """
+        if license_str.startswith("LicenseRef-"):
+            # The license string is already an SPDX license ref ID.
+            return license_str
+
+        if self._spdx_licensing is None:
+            return self._to_extracted_license_info(license_str)
+
+        try:
+            return str(self._spdx_licensing.parse(license_str, validate=True, strict=True))
+        except ExpressionError:
+            return self._to_extracted_license_info(license_str)
+
+    def extracted_license_infos(self) -> List[spdx2.ExtractedLicensingInfo]:
+        """
+        Return a list of all extracted licensing information objects that were created during the conversion process.
+        """
+        return list(self._extracted_license_infos.values())
+
 
 def spdx2_checksum_algorithm(algorithm: sbom_model.ChecksumAlgorithm) -> spdx2.ChecksumAlgorithm:
     if algorithm == sbom_model.ChecksumAlgorithm.SHA1:
@@ -41,9 +109,12 @@ def create_spdx2_document():
     return doc
 
 
-def sbom_pkgset_to_spdx2_doc(pkgset: List[sbom_model.BasePackage]) -> spdx2.Document:
+def sbom_pkgset_to_spdx2_doc(
+        pkgset: List[sbom_model.BasePackage],
+        license_index_location: Optional[os.PathLike] = None) -> spdx2.Document:
     doc = create_spdx2_document()
     relationships = []
+    license_expr_creator = SpdxLicenseExpressionCreator(license_index_location)
 
     for pkg in pkgset:
 
@@ -51,13 +122,15 @@ def sbom_pkgset_to_spdx2_doc(pkgset: List[sbom_model.BasePackage]) -> spdx2.Docu
         if pkg.download_url:
             download_location = pkg.download_url
 
+        license_declared = license_expr_creator.ensure_license_expression(pkg.license_declared)
+
         p = spdx2.Package(
             spdx_id=f"SPDXRef-{pkg.uuid()}",
             name=pkg.name,
             download_location=download_location,
             version=pkg.version,
             files_analyzed=False,
-            license_declared=pkg.license_declared,
+            license_declared=license_declared,
             external_references=[
                 spdx2.ExternalPackageRef(
                     category=spdx2.ExternalPackageRefCategory.PACKAGE_MANAGER,
@@ -120,4 +193,8 @@ def sbom_pkgset_to_spdx2_doc(pkgset: List[sbom_model.BasePackage]) -> spdx2.Docu
 
     doc.relationships = relationships
 
+    extracted_license_infos = license_expr_creator.extracted_license_infos()
+    if len(extracted_license_infos) > 0:
+        doc.extracted_licensing_infos = extracted_license_infos
+
     return doc
diff --git a/osbuild/util/sbom/spdx2/__init__.py b/osbuild/util/sbom/spdx2/__init__.py
index cc6d3647..a01aee7c 100644
--- a/osbuild/util/sbom/spdx2/__init__.py
+++ b/osbuild/util/sbom/spdx2/__init__.py
@@ -9,6 +9,7 @@ from .model import (
     Document,
     ExternalPackageRef,
     ExternalPackageRefCategory,
+    ExtractedLicensingInfo,
     NoAssertionValue,
     NoneValue,
     Package,
@@ -24,6 +25,7 @@ __all__ = [
     "CreatorType",
     "Document",
     "ExternalPackageRef",
+    "ExtractedLicensingInfo",
     "ExternalPackageRefCategory",
     "NoAssertionValue",
     "NoneValue",
diff --git a/osbuild/util/sbom/spdx2/model.py b/osbuild/util/sbom/spdx2/model.py
index 2de5bbbd..b47f1cf4 100644
--- a/osbuild/util/sbom/spdx2/model.py
+++ b/osbuild/util/sbom/spdx2/model.py
@@ -3,6 +3,7 @@ A base implementation of SPDX 2.3 model, as described on:
 https://spdx.github.io/spdx-spec/v2.3/
 """
 
+import hashlib
 import re
 from datetime import datetime, timezone
 from enum import Enum, auto
@@ -216,6 +217,60 @@ class Checksum():
         }
 
 
+def normalize_name_for_license_id(name: str) -> str:
+    """
+    Normalize a license name to be used within an SPDX license ID.
+
+    The function does the following things:
+    - Ensures that the returned string contains only letters, numbers, "." and/or "-".
+        All other characters are replaced with "-".
+    - Deduplicates consecutive "." and "-" characters.
+
+    See also:
+    https://spdx.github.io/spdx-spec/v2.3/other-licensing-information-detected/#1011-description:
+    """
+    normalized_name = re.sub(r"[^a-zA-Z0-9.-]", "-", name)
+    normalized_name = re.sub(r"([.-])\1+", r"\1", normalized_name)
+    return normalized_name
+
+
+def generate_license_id(extracted_text: str, name: Optional[str] = None) -> str:
+    """
+    Generate a unique SPDX license ID by hashing the extracted text using SHA-256.
+
+    If a license name is provided, include it in the license ID.
+    """
+    extracted_text_hash = hashlib.sha256(extracted_text.encode()).hexdigest()
+    if name is not None:
+        return f"LicenseRef-{normalize_name_for_license_id(name)}-{extracted_text_hash}"
+    return f"LicenseRef-{extracted_text_hash}"
+
+
+class ExtractedLicensingInfo():
+    """
+    Represents extracted licensing information for a license not on the SPDX License List.
+
+    https://spdx.github.io/spdx-spec/v2.3/other-licensing-information-detected/
+    """
+
+    def __init__(self, extracted_text: str, name: Optional[str] = None) -> None:
+        self.extracted_text = extracted_text
+        self.name = name
+        self.license_ref_id = generate_license_id(self.extracted_text, self.name)
+
+    def __str__(self):
+        return self.license_ref_id
+
+    def to_dict(self):
+        d = {
+            "licenseId": self.license_ref_id,
+            "extractedText": self.extracted_text,
+        }
+        if self.name:
+            d["name"] = self.name
+        return d
+
+
 # pylint: disable=too-many-instance-attributes
 class Package(EntityWithSpdxId):
     """Represents an SPDX package."""
@@ -230,7 +285,7 @@ class Package(EntityWithSpdxId):
         checksums: Optional[List[Checksum]] = None,
         homepage: Optional[Union[str, NoAssertionValue, NoneValue]] = None,
         source_info: Optional[str] = None,
-        license_declared: Optional[Union[str, NoAssertionValue, NoneValue]] = None,
+        license_declared: Optional[Union[str, ExtractedLicensingInfo, NoAssertionValue, NoneValue]] = None,
         summary: Optional[str] = None,
         description: Optional[str] = None,
         external_references: Optional[List[ExternalPackageRef]] = None,
@@ -324,15 +379,19 @@ class Document():
         creation_info: CreationInfo,
         packages: Optional[List[Package]] = None,
         relationships: Optional[List[Relationship]] = None,
+        extracted_licensing_infos: Optional[List[ExtractedLicensingInfo]] = None,
     ) -> None:
         self.creation_info = creation_info
         self.packages = packages or []
         self.relationships = relationships or []
+        self.extracted_licensing_infos = extracted_licensing_infos or []
 
     def to_dict(self):
         d = self.creation_info.to_dict()
         for package in self.packages:
             d.setdefault("packages", []).append(package.to_dict())
+        for extracted_licensing_info in self.extracted_licensing_infos:
+            d.setdefault("hasExtractedLicensingInfos", []).append(extracted_licensing_info.to_dict())
         for relationship in self.relationships:
             d.setdefault("relationships", []).append(relationship.to_dict())
         return d
diff --git a/stages/org.osbuild.systemd.unit.create b/stages/org.osbuild.systemd.unit.create
index 625f62b8..74f6e295 100755
--- a/stages/org.osbuild.systemd.unit.create
+++ b/stages/org.osbuild.systemd.unit.create
@@ -19,6 +19,8 @@ def validate(filename, cfg):
         raise ValueError(f"Error: {filename} unit requires Mount section")
     if filename.endswith(".socket") and "Socket" not in cfg:
         raise ValueError(f"Error: {filename} unit requires Socket section")
+    if filename.endswith(".swap") and "Swap" not in cfg:
+        raise ValueError(f"Error: {filename} unit requires Swap section")
 
 
 def main(tree, options):
diff --git a/stages/org.osbuild.systemd.unit.create.meta.json b/stages/org.osbuild.systemd.unit.create.meta.json
index f8cc3c58..5acc979f 100644
--- a/stages/org.osbuild.systemd.unit.create.meta.json
+++ b/stages/org.osbuild.systemd.unit.create.meta.json
@@ -46,6 +46,11 @@
     "    - 'Service' - string",
     "    - 'RuntimeDirectory' - string",
     "    - 'RemoveOnStop' - bool",
+    "  - 'Swap' section",
+    "    - 'What' - string",
+    "    - 'Priority' - integer",
+    "    - 'Options' - string",
+    "    - 'TimeoutSec' - string",
     "  - 'Install' section",
     "    - 'WantedBy' - [string]",
     "    - 'RequiredBy' - [string]"
@@ -59,7 +64,7 @@
     "properties": {
       "filename": {
         "type": "string",
-        "pattern": "^[\\w:.\\\\-]+[@]{0,1}[\\w:.\\\\-]*\\.(service|mount|socket)$"
+        "pattern": "^[\\w:.\\\\-]+[@]{0,1}[\\w:.\\\\-]*\\.(service|mount|socket|swap)$"
       },
       "unit-type": {
         "type": "string",
@@ -100,6 +105,11 @@
                   "required": [
                     "Socket"
                   ]
+                },
+                {
+                  "required": [
+                    "Swap"
+                  ]
                 }
               ]
             }
@@ -119,6 +129,11 @@
                   "required": [
                     "Service"
                   ]
+                },
+                {
+                  "required": [
+                    "Swap"
+                  ]
                 }
               ]
             }
@@ -138,6 +153,35 @@
                   "required": [
                     "Socket"
                   ]
+                },
+                {
+                  "required": [
+                    "Swap"
+                  ]
+                }
+              ]
+            }
+          },
+          {
+            "required": [
+              "Swap"
+            ],
+            "not": {
+              "anyOf": [
+                {
+                  "required": [
+                    "Service"
+                  ]
+                },
+                {
+                  "required": [
+                    "Socket"
+                  ]
+                },
+                {
+                  "required": [
+                    "Mount"
+                  ]
                 }
               ]
             }
@@ -342,6 +386,32 @@
               }
             }
           },
+          "Swap": {
+            "additionalProperties": false,
+            "type": "object",
+            "description": "'Swap' configuration section of a unit file",
+            "required": [
+              "What"
+            ],
+            "properties": {
+              "What": {
+                "description": "Absolute path to device node",
+                "type": "string"
+              },
+              "Priority": {
+                "descriptions": "Swap priority to use when activating the swap device or file",
+                "type": "integer"
+              },
+              "Options": {
+                "descriptions": "May contain an option string for the swap device",
+                "type": "string"
+              },
+              "TimeoutSec": {
+                "descriptions": "Configures the time to wait for the swapon command to finish",
+                "type": "string"
+              }
+            }
+          },
           "Install": {
             "additionalProperties": false,
             "type": "object",
diff --git a/tools/osbuild-depsolve-dnf b/tools/osbuild-depsolve-dnf
index 3877d32f..186121aa 100755
--- a/tools/osbuild-depsolve-dnf
+++ b/tools/osbuild-depsolve-dnf
@@ -20,7 +20,8 @@ config = {"use_dnf5": False}
 config_path = os.environ.get("OSBUILD_SOLVER_CONFIG") or "/usr/lib/osbuild/solver.json"
 try:
     with open(config_path, encoding="utf-8") as f:
-        config = json.load(f)
+        loaded_config = json.load(f)
+        config.update(loaded_config)
 except FileNotFoundError:
     pass
 
@@ -58,7 +59,7 @@ def solve(request, cache_dir):
 
     with tempfile.TemporaryDirectory() as persistdir:
         try:
-            solver = Solver(request, persistdir, cache_dir)
+            solver = Solver(request, persistdir, cache_dir, config.get("license_index_path"))
             if command == "dump":
                 result = solver.dump()
             elif command == "depsolve":
diff --git a/tox.ini b/tox.ini
index 87a5e7ce..e2b862ae 100644
--- a/tox.ini
+++ b/tox.ini
@@ -19,6 +19,7 @@ deps =
     iniparse
     pyyaml
     toml
+    license_expression
     pykickstart
     # required by pykickstart but not pulled in automatically :/
     requests


Test patch:
diff --git a/.github/workflows/test-on-centos.yml b/.github/workflows/test-on-centos.yml
index 0ae08679..3fd650a1 100644
--- a/.github/workflows/test-on-centos.yml
+++ b/.github/workflows/test-on-centos.yml
@@ -22,7 +22,7 @@ jobs:
       - name: "Run in container"
         uses: addnab/docker-run-action@v3
         with:
-          image: quay.io/osbuild/osbuild-ci-c${{ matrix.centos.version }}s:latest-202501201758
+          image: quay.io/osbuild/osbuild-ci-c${{ matrix.centos.version }}s:latest-202502250751
           options: --privileged -v ${{ github.workspace }}:/osbuild --workdir /osbuild
           run: |
             python3 -m pytest \
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 034a844c..e3886e60 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -30,7 +30,7 @@ jobs:
     - name: "Run"
       uses: osbuild/containers/src/actions/privdocker@552e30cf1b4ed19c6ddaa57f96c342b3dff4227b
       with:
-        image: ghcr.io/osbuild/osbuild-ci:latest-202501201758
+        image: ghcr.io/osbuild/osbuild-ci:latest-202502250751
         run: |
           # Hacky replacement of container storage driver:
           # The default overlayfs doesn't work in the runner, so let's change
@@ -69,7 +69,7 @@ jobs:
           # Using 4 workers is a bit arbitrary, "auto" is probably too aggressive.
           TEST_WORKERS: "-n 4"
         with:
-          image: ghcr.io/osbuild/osbuild-ci:latest-202501201758
+          image: ghcr.io/osbuild/osbuild-ci:latest-202502250751
           run: |
             OSBUILD_TEST_STORE="${{ env.OSBUILD_TEST_STORE }}" \
             tox -e "py36" -- ${{ env.TEST_WORKERS }} test.run.test_assemblers
diff --git a/stages/test/test_systemd_unit_create.py b/stages/test/test_systemd_unit_create.py
index 96ca747e..d4f9de42 100644
--- a/stages/test/test_systemd_unit_create.py
+++ b/stages/test/test_systemd_unit_create.py
@@ -37,9 +37,18 @@ STAGE_NAME = "org.osbuild.systemd.unit.create"
     ),
     (
         {
-            "filename": "foo.mount",
+            "filename": "foo.socket",
             "config": {
-                "Mount": {"What": "", "Where": ""},
+                "Socket": {"ListenStream": "/run/test/api.socket"},
+            },
+        },
+        "",
+    ),
+    (
+        {
+            "filename": "foo.swap",
+            "config": {
+                "Swap": {"What": ""},
             },
         },
         "",
@@ -77,6 +86,11 @@ STAGE_NAME = "org.osbuild.systemd.unit.create"
                                             "Mount": {"What": "", "Where": ""}, "Install": {}}},
      "{'Unit': {}, 'Service': {}, 'Mount': {'What': '', 'Where': ''}, "
      "'Install': {}} is not valid under any of the given schemas"),
+
+    # # .swap unit with Service section
+    ({"filename": "foo.swap", "config": {"Unit": {}, "Service": {}, "Swap": {"What": ""}, "Install": {}}},
+     "{'Unit': {}, 'Service': {}, 'Swap': {'What': ''}, "
+     "'Install': {}} is not valid under any of the given schemas"),
 ])
 @pytest.mark.parametrize("stage_schema", ["1"], indirect=True)
 def test_schema_validation(stage_schema, test_data, expected_err):
@@ -118,9 +132,18 @@ def test_schema_validation(stage_schema, test_data, expected_err):
     ),
     (
         {
-            "filename": "foo.mount",
+            "filename": "foo.socket",
             "config": {
-                "Mount": {"What": "", "Where": ""},
+                "Socket": {},
+            },
+        },
+        "",
+    ),
+    (
+        {
+            "filename": "foo.swap",
+            "config": {
+                "Swap": {},
             },
         },
         "",
@@ -130,6 +153,10 @@ def test_schema_validation(stage_schema, test_data, expected_err):
      "Error: something.service unit requires Service section"),
     ({"filename": "data-gifs-cats.mount", "config": {"Unit": {}, "Service": {}, "Install": {}}},
      "Error: data-gifs-cats.mount unit requires Mount section"),
+    ({"filename": "data-gifs-cats.socket", "config": {"Unit": {}, "Service": {}, "Install": {}}},
+     "Error: data-gifs-cats.socket unit requires Socket section"),
+    ({"filename": "data-gifs-cats.swap", "config": {"Unit": {}, "Service": {}, "Install": {}}},
+     "Error: data-gifs-cats.swap unit requires Swap section"),
 ])
 def test_name_config_match(tmp_path, stage_module, test_data, expected_err):
     expected_unit_path = tmp_path / "usr/lib/systemd/system"
diff --git a/test/data/spdx/README.md b/test/data/spdx/README.md
new file mode 100644
index 00000000..869560a0
--- /dev/null
+++ b/test/data/spdx/README.md
@@ -0,0 +1,4 @@
+# This directory contains SPDX-related files used for testing
+
+- `spdx-schema-v2.3.1.json` is the SPDX 2.3.1 JSON schema file from the [spdx-spec upstream](https://github.com/spdx/spdx-spec/blob/62f3e7cbd448a19f0099fdbd707d8b9b4fbddfdd/schemas/spdx-schema.json)
+- `custom-license-index.json` is a custom license DB JSON file which includes only definition for `MIT` license and a new non-existent license `GPLv2` at the end of the file. The latest actual license DB JSON files can be downloaded from the [ScanCode LicenseDB JSON index](https://scancode-licensedb.aboutcode.org/index.json). See also [SPDX licenses](https://spdx.org/licenses/) and [ScanCode LicenseDB help](https://scancode-licensedb.aboutcode.org/help.html).
diff --git a/test/data/spdx/custom-license-index.json b/test/data/spdx/custom-license-index.json
new file mode 100644
index 00000000..9f298a45
--- /dev/null
+++ b/test/data/spdx/custom-license-index.json
@@ -0,0 +1,31 @@
+[
+  {
+    "license_key": "mit",
+    "category": "Permissive",
+    "spdx_license_key": "MIT",
+    "other_spdx_license_keys": [
+      "LicenseRef-MIT-Bootstrap",
+      "LicenseRef-MIT-Discord",
+      "LicenseRef-MIT-TC",
+      "LicenseRef-MIT-Diehl"
+    ],
+    "is_exception": false,
+    "is_deprecated": false,
+    "json": "mit.json",
+    "yaml": "mit.yml",
+    "html": "mit.html",
+    "license": "mit.LICENSE"
+  },
+  {
+    "license_key": "GPLv2",
+    "category": "Permissive",
+    "spdx_license_key": "GPLv2",
+    "other_spdx_license_keys": [],
+    "is_exception": false,
+    "is_deprecated": false,
+    "json": "GPLv2.json",
+    "yaml": "GPLv2.yml",
+    "html": "GPLv2.html",
+    "license": "GPLv2.LICENSE"
+  }
+]
diff --git a/test/data/stages/systemd.unit.create/a.json b/test/data/stages/systemd.unit.create/a.json
index a917a0ae..69976377 100644
--- a/test/data/stages/systemd.unit.create/a.json
+++ b/test/data/stages/systemd.unit.create/a.json
@@ -672,6 +672,27 @@
             "filename": "/etc/systemd/user/create-directory.service",
             "size": "0"
           }
+        },
+        {
+          "type": "org.osbuild.truncate",
+          "options": {
+            "filename": "/usr/lib/systemd/system/dev-disk-by-uuid-4f581be3-e8c2-4b44-9456-54df3ab92944.swap",
+            "size": "0"
+          }
+        },
+        {
+          "type": "org.osbuild.truncate",
+          "options": {
+            "filename": "/usr/lib/systemd/system/service.socket",
+            "size": "0"
+          }
+        },
+        {
+          "type": "org.osbuild.truncate",
+          "options": {
+            "filename": "/usr/lib/systemd/system/data.mount",
+            "size": "0"
+          }
         }
       ]
     }
diff --git a/test/data/stages/systemd.unit.create/a.mpp.yaml b/test/data/stages/systemd.unit.create/a.mpp.yaml
index 381cf897..8b773366 100644
--- a/test/data/stages/systemd.unit.create/a.mpp.yaml
+++ b/test/data/stages/systemd.unit.create/a.mpp.yaml
@@ -29,7 +29,20 @@ pipelines:
             mpp-eval: gpgkeys
           exclude:
             docs: true
+
       - type: org.osbuild.truncate
         options:
           filename: "/etc/systemd/user/create-directory.service"
           size: '0'
+      - type: org.osbuild.truncate
+        options:
+          filename: "/usr/lib/systemd/system/dev-disk-by-uuid-4f581be3-e8c2-4b44-9456-54df3ab92944.swap"
+          size: '0'
+      - type: org.osbuild.truncate
+        options:
+          filename: "/usr/lib/systemd/system/service.socket"
+          size: '0'
+      - type: org.osbuild.truncate
+        options:
+          filename: "/usr/lib/systemd/system/data.mount"
+          size: '0'
diff --git a/test/data/stages/systemd.unit.create/b.json b/test/data/stages/systemd.unit.create/b.json
index 63a96b83..cc9f36b0 100644
--- a/test/data/stages/systemd.unit.create/b.json
+++ b/test/data/stages/systemd.unit.create/b.json
@@ -710,6 +710,49 @@
               }
             }
           }
+        },
+        {
+          "type": "org.osbuild.systemd.unit.create",
+          "options": {
+            "filename": "data.mount",
+            "config": {
+              "Unit": {
+                "DefaultDependencies": true
+              },
+              "Mount": {
+                "What": "/dev/sdb1",
+                "Where": "/data"
+              }
+            }
+          }
+        },
+        {
+          "type": "org.osbuild.systemd.unit.create",
+          "options": {
+            "filename": "service.socket",
+            "config": {
+              "Unit": {
+                "DefaultDependencies": true
+              },
+              "Socket": {
+                "ListenStream": "/run/service/api.socket"
+              }
+            }
+          }
+        },
+        {
+          "type": "org.osbuild.systemd.unit.create",
+          "options": {
+            "filename": "dev-disk-by-uuid-4f581be3-e8c2-4b44-9456-54df3ab92944.swap",
+            "config": {
+              "Unit": {
+                "DefaultDependencies": true
+              },
+              "Swap": {
+                "What": "/dev/disk/by-uuid/4f581be3-e8c2-4b44-9456-54df3ab92944"
+              }
+            }
+          }
         }
       ]
     }
diff --git a/test/data/stages/systemd.unit.create/b.mpp.yaml b/test/data/stages/systemd.unit.create/b.mpp.yaml
index 6ff459d3..5aa896f8 100644
--- a/test/data/stages/systemd.unit.create/b.mpp.yaml
+++ b/test/data/stages/systemd.unit.create/b.mpp.yaml
@@ -59,3 +59,31 @@ pipelines:
                 - local-fs.target
               RequiredBy:
                 - multi-user.target
+
+      - type: org.osbuild.systemd.unit.create
+        options:
+          filename: data.mount
+          config:
+            Unit:
+              DefaultDependencies: true
+            Mount:
+              What: "/dev/sdb1"
+              Where: "/data"
+
+      - type: org.osbuild.systemd.unit.create
+        options:
+          filename: service.socket
+          config:
+            Unit:
+              DefaultDependencies: true
+            Socket:
+              ListenStream: "/run/service/api.socket"
+
+      - type: org.osbuild.systemd.unit.create
+        options:
+          filename: "dev-disk-by\x2duuid-4f581be3\x2de8c2\x2d4b44\x2d9456\x2d54df3ab92944.swap"
+          config:
+            Unit:
+              DefaultDependencies: true
+            Swap:
+              What: "/dev/disk/by-uuid/4f581be3-e8c2-4b44-9456-54df3ab92944"
diff --git a/test/data/stages/systemd.unit.create/diff.json b/test/data/stages/systemd.unit.create/diff.json
index 425aeb34..dd936245 100644
--- a/test/data/stages/systemd.unit.create/diff.json
+++ b/test/data/stages/systemd.unit.create/diff.json
@@ -14,6 +14,24 @@
         "sha256:69aa663d58cc063ea2d7659ac06354335c26e69e8fdb0036f952b082a70b5642"
       ]
     },
+    "/usr/lib/systemd/system/dev-disk-by-uuid-4f581be3-e8c2-4b44-9456-54df3ab92944.swap": {
+      "content": [
+        "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+        "sha256:4bb9e774438a2857a3845936c551b945f2ed6edabec3f9955d8e7d6b794a5a59"
+      ]
+    },
+    "/usr/lib/systemd/system/service.socket": {
+      "content": [
+        "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+        "sha256:2c029a69c7a57f920c99a3537c8db87b26825436e131a87106e25a69197d7eb6"
+      ]
+    },
+    "/usr/lib/systemd/system/data.mount": {
+      "content": [
+        "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+        "sha256:2f7e14d9785faa8e9b31188473cb5f87a7bc2a37b31b4c0c533f0e3bfd9d427d"
+      ]
+    },
     "/usr/lib/sysimage/rpm/rpmdb.sqlite": {
       "content": [
         null,
diff --git a/test/mod/test_util_sbom_spdx.py b/test/mod/test_util_sbom_spdx.py
index 8fd15375..6ea904a6 100644
--- a/test/mod/test_util_sbom_spdx.py
+++ b/test/mod/test_util_sbom_spdx.py
@@ -3,11 +3,63 @@ import os
 import pytest
 
 import osbuild
-from osbuild.util.sbom.spdx import create_spdx2_document, sbom_pkgset_to_spdx2_doc, spdx2_checksum_algorithm
-from osbuild.util.sbom.spdx2.model import CreatorType, ExternalPackageRefCategory, RelationshipType
-
-testutil_dnf4 = pytest.importorskip("osbuild.testutil.dnf4")
-bom_dnf = pytest.importorskip("osbuild.util.sbom.dnf")
+from osbuild.util.sbom.spdx import (
+    SpdxLicenseExpressionCreator,
+    create_spdx2_document,
+    sbom_pkgset_to_spdx2_doc,
+    spdx2_checksum_algorithm,
+)
+from osbuild.util.sbom.spdx2.model import (
+    CreatorType,
+    ExternalPackageRefCategory,
+    ExtractedLicensingInfo,
+    RelationshipType,
+)
+
+from ..test import patch_license_expression
+
+CUSTOM_LICENSE_DB_LOCATION = "./test/data/spdx/custom-license-index.json"
+
+
+@pytest.mark.parametrize("licensing_available", (True, False))
+def test_spdxlicenseexpressionfactory_license_expression_availability(licensing_available):
+    with patch_license_expression(licensing_available) as mocked_licensing:
+        lf = SpdxLicenseExpressionCreator()
+        license_expression = lf.ensure_license_expression("MIT")
+
+        license_expression2 = lf.ensure_license_expression("LicenseRef-123")
+        assert license_expression2 == "LicenseRef-123"
+
+        if licensing_available:
+            assert mocked_licensing is not None
+            # The license string should be a SPDX license expression string.
+            assert license_expression == "MIT"
+            assert len(lf.extracted_license_infos()) == 0
+        else:
+            assert mocked_licensing is None
+            # The license string should be wrapped in an ExtractedLicensingInfo,
+            # because the license-expression package is not available.
+            assert isinstance(license_expression, ExtractedLicensingInfo)
+            assert str(license_expression).startswith("LicenseRef-")
+            assert license_expression.extracted_text == "MIT"
+            assert len(lf.extracted_license_infos()) == 1
+
+
+@pytest.mark.parametrize("licensing_available", (True, False))
+def test_spdxlicenseexpressionfactory_custom_license_index(licensing_available):
+    with patch_license_expression(licensing_available) as mocked_licensing:
+        if licensing_available:
+            assert mocked_licensing is not None
+            lf = SpdxLicenseExpressionCreator(CUSTOM_LICENSE_DB_LOCATION)
+            license_text = "GPLv2"
+            license_expression = lf.ensure_license_expression(license_text)
+            assert license_expression == license_text
+            assert len(lf.extracted_license_infos()) == 0
+        else:
+            assert mocked_licensing is None
+            with pytest.raises(ValueError, match="The license-expression package is not available. " +
+                               "Specify the license index location has no effect."):
+                lf = SpdxLicenseExpressionCreator(CUSTOM_LICENSE_DB_LOCATION)
 
 
 def test_create_spdx2_document():
@@ -36,41 +88,82 @@ def test_create_spdx2_document():
     assert doc1_dict == doc2_dict
 
 
-def test_sbom_pkgset_to_spdx2_doc():
+@pytest.mark.parametrize("licensing_available", (True, False))
+@pytest.mark.parametrize("license_index_location", (None, CUSTOM_LICENSE_DB_LOCATION))
+def test_sbom_pkgset_to_spdx2_doc(licensing_available, license_index_location):
+    testutil_dnf4 = pytest.importorskip("osbuild.testutil.dnf4")
+    bom_dnf = pytest.importorskip("osbuild.util.sbom.dnf")
+
     dnf_pkgset = testutil_dnf4.depsolve_pkgset([os.path.abspath("./test/data/testrepos/baseos")], ["bash"])
     bom_pkgset = bom_dnf.dnf_pkgset_to_sbom_pkgset(dnf_pkgset)
-    doc = sbom_pkgset_to_spdx2_doc(bom_pkgset)
-
-    assert len(doc.packages) == len(bom_pkgset)
-    for spdx_pkg, bom_pkg in zip(doc.packages, bom_pkgset):
-        assert spdx_pkg.spdx_id == f"SPDXRef-{bom_pkg.uuid()}"
-        assert spdx_pkg.name == bom_pkg.name
-        assert spdx_pkg.version == bom_pkg.version
-        assert not spdx_pkg.files_analyzed
-        assert spdx_pkg.license_declared == bom_pkg.license_declared
-        assert spdx_pkg.download_location == bom_pkg.download_url
-        assert spdx_pkg.homepage == bom_pkg.homepage
-        assert spdx_pkg.summary == bom_pkg.summary
-        assert spdx_pkg.description == bom_pkg.description
-        assert spdx_pkg.source_info == bom_pkg.source_info()
-        assert spdx_pkg.built_date == bom_pkg.build_date
-
-        assert len(spdx_pkg.checksums) == 1
-        assert spdx_pkg.checksums[0].algorithm == spdx2_checksum_algorithm(list(bom_pkg.checksums.keys())[0])
-        assert spdx_pkg.checksums[0].value == list(bom_pkg.checksums.values())[0]
-
-        assert len(spdx_pkg.external_references) == 1
-        assert spdx_pkg.external_references[0].category == ExternalPackageRefCategory.PACKAGE_MANAGER
-        assert spdx_pkg.external_references[0].reference_type == "purl"
-        assert spdx_pkg.external_references[0].locator == bom_pkg.purl()
-
-    assert len([rel for rel in doc.relationships if rel.relationship_type ==
-                RelationshipType.DESCRIBES]) == len(bom_pkgset)
-
-    deps_count = sum(len(bom_pkg.depends_on) for bom_pkg in bom_pkgset)
-    assert len([rel for rel in doc.relationships if rel.relationship_type ==
-                RelationshipType.DEPENDS_ON]) == deps_count
-
-    optional_deps_count = sum(len(bom_pkg.optional_depends_on) for bom_pkg in bom_pkgset)
-    assert len([rel for rel in doc.relationships if rel.relationship_type ==
-                RelationshipType.OPTIONAL_DEPENDENCY_OF]) == optional_deps_count
+
+    with patch_license_expression(licensing_available) as _:
+        extracted_licensing_infos = set()
+
+        try:
+            doc = sbom_pkgset_to_spdx2_doc(bom_pkgset, license_index_location)
+        except ValueError:
+            # ValueError can be raised only if the license-expression package is not available
+            # and a custom license index file is used.
+            if not licensing_available and license_index_location:
+                return
+        else:
+            if not licensing_available and license_index_location:
+                pytest.fail("Expected a ValueError to be raised when the license-expression package is not available.")
+
+        assert len(doc.packages) == len(bom_pkgset)
+        for spdx_pkg, bom_pkg in zip(doc.packages, bom_pkgset):
+            assert spdx_pkg.spdx_id == f"SPDXRef-{bom_pkg.uuid()}"
+            assert spdx_pkg.name == bom_pkg.name
+            assert spdx_pkg.version == bom_pkg.version
+            assert not spdx_pkg.files_analyzed
+            assert spdx_pkg.download_location == bom_pkg.download_url
+            assert spdx_pkg.homepage == bom_pkg.homepage
+            assert spdx_pkg.summary == bom_pkg.summary
+            assert spdx_pkg.description == bom_pkg.description
+            assert spdx_pkg.source_info == bom_pkg.source_info()
+            assert spdx_pkg.built_date == bom_pkg.build_date
+
+            # If the license-expression package is available and no custom license index file is used, only the "MIT"
+            # license is converted as a valid SPDX license expression for our testing package set. Otherwise, also the
+            # "GPLv2" license is converted as a valid SPDX license expression.
+            valid_licenses = ["MIT"]
+            if license_index_location:
+                valid_licenses.append("GPLv2")
+
+            if licensing_available and bom_pkg.license_declared in valid_licenses:
+                assert isinstance(spdx_pkg.license_declared, str)
+                assert spdx_pkg.license_declared in valid_licenses
+            # If the license-expression package is not available, all licenses are converted
+            # to SPDX license references.
+            # The same applies to all licenses that are not "MIT" if the package is available,
+            # because the testing package set contains only "MIT" as a valid SPDX license expression.
+            else:
+                assert isinstance(spdx_pkg.license_declared, ExtractedLicensingInfo)
+                assert str(spdx_pkg.license_declared).startswith("LicenseRef-")
+                assert spdx_pkg.license_declared.extracted_text == bom_pkg.license_declared
+                extracted_licensing_infos.add(spdx_pkg.license_declared)
+
+            assert len(spdx_pkg.checksums) == 1
+            assert spdx_pkg.checksums[0].algorithm == spdx2_checksum_algorithm(list(bom_pkg.checksums.keys())[0])
+            assert spdx_pkg.checksums[0].value == list(bom_pkg.checksums.values())[0]
+
+            assert len(spdx_pkg.external_references) == 1
+            assert spdx_pkg.external_references[0].category == ExternalPackageRefCategory.PACKAGE_MANAGER
+            assert spdx_pkg.external_references[0].reference_type == "purl"
+            assert spdx_pkg.external_references[0].locator == bom_pkg.purl()
+
+        assert len([rel for rel in doc.relationships if rel.relationship_type ==
+                    RelationshipType.DESCRIBES]) == len(bom_pkgset)
+
+        deps_count = sum(len(bom_pkg.depends_on) for bom_pkg in bom_pkgset)
+        assert len([rel for rel in doc.relationships if rel.relationship_type ==
+                    RelationshipType.DEPENDS_ON]) == deps_count
+
+        optional_deps_count = sum(len(bom_pkg.optional_depends_on) for bom_pkg in bom_pkgset)
+        assert len([rel for rel in doc.relationships if rel.relationship_type ==
+                    RelationshipType.OPTIONAL_DEPENDENCY_OF]) == optional_deps_count
+
+        assert len(extracted_licensing_infos) > 0
+        assert sorted(extracted_licensing_infos, key=lambda x: x.license_ref_id) == \
+            sorted(doc.extracted_licensing_infos, key=lambda x: x.license_ref_id)
diff --git a/test/mod/test_util_sbom_spdx2_model.py b/test/mod/test_util_sbom_spdx2_model.py
index 0c4ec8cf..cfd28af3 100644
--- a/test/mod/test_util_sbom_spdx2_model.py
+++ b/test/mod/test_util_sbom_spdx2_model.py
@@ -1,3 +1,5 @@
+# pylint: disable=protected-access
+import hashlib
 import json
 from datetime import datetime
 
@@ -15,12 +17,15 @@ from osbuild.util.sbom.spdx2.model import (
     EntityWithSpdxId,
     ExternalPackageRef,
     ExternalPackageRefCategory,
+    ExtractedLicensingInfo,
     NoAssertionValue,
     NoneValue,
     Package,
     Relationship,
     RelationshipType,
     datetime_to_iso8601,
+    generate_license_id,
+    normalize_name_for_license_id,
 )
 
 zoneinfo = pytest.importorskip("zoneinfo")
@@ -231,6 +236,72 @@ def test_checksum_to_dict():
     }
 
 
+@pytest.mark.parametrize("name,expected_str", (
+    ("MIT", "MIT"),
+    ("MIT-2.0", "MIT-2.0"),
+    ("My License", "My-License"),
+    ("%$MIT))  ((@\",))", "-MIT-"),
+    ("MIT.and more-12345", "MIT.and-more-12345"),
+    ("......-----------.......---------", ".-.-"),
+    ("", ""))
+)
+def test_normalize_name_for_license_id(name, expected_str):
+    assert normalize_name_for_license_id(name) == expected_str
+
+
+@pytest.mark.parametrize("extracted_text,name", (
+    ("MIT", "test"),
+    ("MIT", None),
+    ("MIT and GPLv2", "MIT and GPLv2"))
+)
+def test_generate_license_id(extracted_text, name):
+    extracted_text_hash = hashlib.sha256(extracted_text.encode()).hexdigest()
+    expect = f"LicenseRef-{normalize_name_for_license_id(name)}-{extracted_text_hash}" \
+        if name else f"LicenseRef-{extracted_text_hash}"
+    assert generate_license_id(extracted_text, name) == expect
+
+
+# The __str__ just returns the license ID, which is generated by the _generate_license_id method
+# tested above. So, the test case looks the same for now.
+@pytest.mark.parametrize("extracted_text,name", (
+    ("MIT", "test"),
+    ("MIT", None),
+    ("MIT and GPLv2", "MIT and GPLv2"))
+)
+def test_extracted_licensing_info___str__(extracted_text, name):
+    extracted_text_hash = hashlib.sha256(extracted_text.encode()).hexdigest()
+    expect = f"LicenseRef-{normalize_name_for_license_id(name)}-{extracted_text_hash}" \
+        if name else f"LicenseRef-{extracted_text_hash}"
+    assert str(ExtractedLicensingInfo(extracted_text, name)) == expect
+
+
+@pytest.mark.parametrize("test_case", (
+    {
+        "instance_args": {
+            "extracted_text": "MIT and GPLv2",
+        },
+        "expected": {
+            "licenseId": "LicenseRef-7805d4303e817ddd5f86dcf6541af84daac5c5b4a8ad1fb4cd14def8a4ca3d13",
+            "extractedText": "MIT and GPLv2"
+        }
+    },
+    {
+        "instance_args": {
+            "extracted_text": "MIT and GPLv2",
+            "name": "MIT and GPLv2",
+        },
+        "expected": {
+            "licenseId": "LicenseRef-MIT-and-GPLv2-7805d4303e817ddd5f86dcf6541af84daac5c5b4a8ad1fb4cd14def8a4ca3d13",
+            "extractedText": "MIT and GPLv2",
+            "name": "MIT and GPLv2"
+        }
+    }
+))
+def test_extracted_licensing_info_to_dict(test_case):
+    p = ExtractedLicensingInfo(**test_case["instance_args"])
+    assert p.to_dict() == test_case["expected"]
+
+
 @pytest.mark.parametrize("test_case", (
     {
         "instance_args": {
@@ -308,6 +379,58 @@ def test_checksum_to_dict():
             ],
             "builtDate": "2024-11-15T13:33:59Z"
         }
+    },
+    # Test with ExtractedLicensingInfo
+    {
+        "instance_args": {
+            "spdx_id": "SPDXRef-package-1.2.3",
+            "name": "package",
+            "download_location": NoneValue(),
+            "files_analyzed": False,
+            "checksums": [
+                Checksum(ChecksumAlgorithm.SHA256, "123456")
+            ],
+            "version": "1.2.3",
+            "homepage": "https://example.org/package",
+            "source_info": "https://example.org/package-1.2.3.src.rpm",
+            "license_declared": ExtractedLicensingInfo("MIT and GPLv2"),
+            "summary": "A sample package",
+            "description": "A sample package description",
+            "external_references": [
+                ExternalPackageRef(
+                    ExternalPackageRefCategory.PACKAGE_MANAGER,
+                    "purl",
+                    "pkg:rpm:/example/package@1.2.3-1?arch=x86_64"
+                )
+            ],
+            "built_date": datetime(2024, 11, 15, 14, 33, 59, tzinfo=zoneinfo.ZoneInfo("Europe/Prague"))
+        },
+        "expected": {
+            "SPDXID": "SPDXRef-package-1.2.3",
+            "name": "package",
+            "downloadLocation": "NONE",
+            "filesAnalyzed": False,
+            "checksums": [
+                {
+                    "algorithm": "SHA256",
+                    "checksumValue": "123456"
+                }
+            ],
+            "versionInfo": "1.2.3",
+            "homepage": "https://example.org/package",
+            "sourceInfo": "https://example.org/package-1.2.3.src.rpm",
+            "licenseDeclared": "LicenseRef-7805d4303e817ddd5f86dcf6541af84daac5c5b4a8ad1fb4cd14def8a4ca3d13",
+            "summary": "A sample package",
+            "description": "A sample package description",
+            "externalRefs": [
+                {
+                    "referenceCategory": "PACKAGE-MANAGER",
+                    "referenceType": "purl",
+                    "referenceLocator": "pkg:rpm:/example/package@1.2.3-1?arch=x86_64"
+                }
+            ],
+            "builtDate": "2024-11-15T13:33:59Z"
+        }
     }
 ))
 def test_package_to_dict(test_case):
@@ -464,6 +587,147 @@ def test_relationship_to_dict(test_case):
                 }
             ]
         }
+    },
+    # Test with ExtractedLicensingInfo
+    {
+        "instance_args": {
+            "creation_info": CreationInfo(
+                "SPDX-2.3",
+                "SPDXRef-DOCUMENT",
+                "Sample-Document",
+                "https://example.com",
+                [Creator(CreatorType.TOOL, "Sample-Tool-123")],
+                datetime(2024, 11, 15, 14, 33, 59, tzinfo=zoneinfo.ZoneInfo("Europe/Prague")),
+                "Public Domain"
+            ),
+            "packages": [
+                Package(
+                    "SPDXRef-packageA-1.2.3",
+                    "package",
+                    "https://example.org/packageA-1.2.3.rpm"
+                ),
+                Package(
+                    "SPDXRef-packageB-3.2.1",
+                    "package",
+                    "https://example.org/packageB-3.2.1.rpm"
+                ),
+                Package(
+                    "SPDXRef-package-1.2.3",
+                    "package",
+                    NoneValue(),
+                    "1.2.3",
+                    False,
+                    [Checksum(ChecksumAlgorithm.SHA256, "123456")],
+                    "https://example.org/package",
+                    "https://example.org/package-1.2.3.src.rpm",
+                    ExtractedLicensingInfo("MIT and GPLv2"),
+                    "A sample package",
+                    "A sample package description",
+                    [
+                        ExternalPackageRef(
+                            ExternalPackageRefCategory.PACKAGE_MANAGER,
+                            "purl",
+                            "pkg:rpm:/example/package@1.2.3-1?arch=x86_64"
+                        )
+                    ],
+                    datetime(2024, 11, 15, 14, 33, 59, tzinfo=zoneinfo.ZoneInfo("Europe/Prague"))
+                ),
+            ],
+            "relationships": [
+                Relationship(
+                    "SPDXRef-DOCUMENT",
+                    RelationshipType.DESCRIBES,
+                    "SPDXRef-packageA-1.2.3"
+                ),
+                Relationship(
+                    "SPDXRef-DOCUMENT",
+                    RelationshipType.DESCRIBES,
+                    "SPDXRef-packageB-3.2.1"
+                ),
+                Relationship(
+                    "SPDXRef-packageA-1.2.3",
+                    RelationshipType.DEPENDS_ON,
+                    "SPDXRef-packageB-3.2.1"
+                )
+            ],
+            "extracted_licensing_infos": [
+                ExtractedLicensingInfo("MIT and GPLv2")
+            ]
+        },
+        "expected": {
+            "spdxVersion": "SPDX-2.3",
+            "SPDXID": "SPDXRef-DOCUMENT",
+            "name": "Sample-Document",
+            "dataLicense": "Public Domain",
+            "documentNamespace": "https://example.com",
+            "creationInfo": {
+                "created": "2024-11-15T13:33:59Z",
+                "creators": [
+                    "Tool: Sample-Tool-123"
+                ]
+            },
+            "packages": [
+                {
+                    "SPDXID": "SPDXRef-packageA-1.2.3",
+                    "name": "package",
+                    "downloadLocation": "https://example.org/packageA-1.2.3.rpm"
+                },
+                {
+                    "SPDXID": "SPDXRef-packageB-3.2.1",
+                    "name": "package",
+                    "downloadLocation": "https://example.org/packageB-3.2.1.rpm"
+                },
+                {
+                    "SPDXID": "SPDXRef-package-1.2.3",
+                    "name": "package",
+                    "downloadLocation": "NONE",
+                    "filesAnalyzed": False,
+                    "checksums": [
+                        {
+                            "algorithm": "SHA256",
+                            "checksumValue": "123456"
+                        }
+                    ],
+                    "versionInfo": "1.2.3",
+                    "homepage": "https://example.org/package",
+                    "sourceInfo": "https://example.org/package-1.2.3.src.rpm",
+                    "licenseDeclared": "LicenseRef-7805d4303e817ddd5f86dcf6541af84daac5c5b4a8ad1fb4cd14def8a4ca3d13",
+                    "summary": "A sample package",
+                    "description": "A sample package description",
+                    "externalRefs": [
+                        {
+                            "referenceCategory": "PACKAGE-MANAGER",
+                            "referenceType": "purl",
+                            "referenceLocator": "pkg:rpm:/example/package@1.2.3-1?arch=x86_64"
+                        }
+                    ],
+                    "builtDate": "2024-11-15T13:33:59Z"
+                }
+            ],
+            "relationships": [
+                {
+                    "spdxElementId": "SPDXRef-DOCUMENT",
+                    "relationshipType": "DESCRIBES",
+                    "relatedSpdxElement": "SPDXRef-packageA-1.2.3"
+                },
+                {
+                    "spdxElementId": "SPDXRef-DOCUMENT",
+                    "relationshipType": "DESCRIBES",
+                    "relatedSpdxElement": "SPDXRef-packageB-3.2.1"
+                },
+                {
+                    "spdxElementId": "SPDXRef-packageA-1.2.3",
+                    "relationshipType": "DEPENDS_ON",
+                    "relatedSpdxElement": "SPDXRef-packageB-3.2.1"
+                }
+            ],
+            "hasExtractedLicensingInfos": [
+                {
+                    'extractedText': 'MIT and GPLv2',
+                    'licenseId': 'LicenseRef-7805d4303e817ddd5f86dcf6541af84daac5c5b4a8ad1fb4cd14def8a4ca3d13',
+                }
+            ]
+        }
     }
 ))
 def test_document_to_dict(test_case):
diff --git a/test/test.py b/test/test.py
index 018bc4e0..fe2eec92 100644
--- a/test/test.py
+++ b/test/test.py
@@ -10,6 +10,9 @@ import subprocess
 import sys
 import tempfile
 import unittest
+from unittest.mock import patch
+
+import pytest
 
 import osbuild.meta
 from osbuild.objectstore import ObjectStore
@@ -496,3 +499,40 @@ class OSBuild(contextlib.AbstractContextManager):
             "cp", "--reflink=auto", "-a",
             os.path.join(from_path, "."), to_path
         ], check=True)
+
+
+class patch_license_expression:
+    """
+    Context manager to patch the license-expression package availability.
+
+    The context manager simulates the unavailability of the license-expression package by mocking the
+    `get_spdx_licensing()` module-level function. If the package should be made available
+    and it is available on the system, the function is passed through. Otherwise, pytest.skip() is called.
+    """
+
+    PATCH_TARGET = "osbuild.util.sbom.spdx.get_spdx_licensing"
+
+    def __init__(self, make_package_available):
+        self.make_package_available = make_package_available
+        self.patcher = None
+
+    def __enter__(self):
+        get_spdx_licensing = None
+        try:
+            # pylint: disable=import-outside-toplevel
+            from license_expression import get_spdx_licensing
+        except ImportError:
+            pass
+
+        if self.make_package_available:
+            if get_spdx_licensing:
+                self.patcher = patch(self.PATCH_TARGET, new=get_spdx_licensing)
+            else:
+                pytest.skip("The license-expression package is not available.")
+        else:
+            # The package is either not available or should be made unavailable, so make sure the function var is None.
+            self.patcher = patch(self.PATCH_TARGET, new=None)
+        return self.patcher.start()
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self.patcher.stop()
diff --git a/tools/test/test_depsolve.py b/tools/test/test_depsolve.py
index 21c5f735..3ba1bf92 100644
--- a/tools/test/test_depsolve.py
+++ b/tools/test/test_depsolve.py
@@ -40,6 +40,24 @@ def assert_dnf():
         raise RuntimeError("Cannot import dnf")
 
 
+def is_license_expression_available():
+    """
+    Check if the license-expression package is available.
+
+    The check is not done by importing the package in the current Python environment, because "osbuild-depsolve-dnf"
+    is run outside of any virtualenv that that the tests may run in. It is inside "osbuild-depsolve-dnf" where
+    the import for "license_expression" happens. Therefore the check is done by running an external Python script
+    outside the potential virtualenv.
+
+    For the same reason, we don't use `sys.executable` to run the script, because it may point to a different
+    Python interpreter than the one that will be used when `osbuild-depsolve-dnf` is executed.
+    """
+    cmd = ["/usr/bin/python3", "-c", "from license_expression import get_spdx_licensing as _"]
+    if sp.run(cmd, check=False).returncode != 0:
+        return False
+    return True
+
+
 def depsolve(transactions, cache_dir, dnf_config=None, repos=None, root_dir=None,
              opt_metadata=None, with_sbom=False) -> Tuple[dict, int]:
     if not repos and not root_dir:
@@ -1523,18 +1541,27 @@ def test_depsolve_config_combos(tmp_path, repo_servers, dnf_config, detect_fn):
                 assert res["solver"] == "dnf"
 
 
+# pylint: disable=too-many-branches
+@pytest.mark.parametrize("custom_license_db", [None, "./test/data/spdx/custom-license-index.json"])
 @pytest.mark.parametrize("with_sbom", [False, True])
 @pytest.mark.parametrize("dnf_config, detect_fn", [
     ({}, assert_dnf),
     ({"use_dnf5": False}, assert_dnf),
     ({"use_dnf5": True}, assert_dnf5),
 ], ids=["no-config", "dnf4", "dnf5"])
-def test_depsolve_sbom(tmp_path, repo_servers, dnf_config, detect_fn, with_sbom):
+def test_depsolve_sbom(tmp_path, repo_servers, dnf_config, detect_fn, with_sbom, custom_license_db):
     try:
         detect_fn()
     except RuntimeError as e:
         pytest.skip(str(e))
 
+    if custom_license_db:
+        if not is_license_expression_available():
+            pytest.skip("license_expression python module is not available")
+
+        dnf_config = dnf_config.copy()
+        dnf_config["license_index_path"] = custom_license_db
+
     test_case = depsolve_test_case_basic_2pkgs_2repos
     transactions = test_case["transactions"]
     repo_configs = get_test_case_repo_configs(test_case, repo_servers)
@@ -1548,6 +1575,7 @@ def test_depsolve_sbom(tmp_path, repo_servers, dnf_config, detect_fn, with_sbom)
     for repo in res["repos"].values():
         assert repo["gpgkeys"] == [TEST_KEY + repo["id"]]
         assert repo["sslverify"] is False
+
     if with_sbom:
         assert "sbom" in res
 
@@ -1560,6 +1588,25 @@ def test_depsolve_sbom(tmp_path, repo_servers, dnf_config, detect_fn, with_sbom)
         spdx_validator.validate(res["sbom"])
 
         assert {pkg["name"] for pkg in res["sbom"]["packages"]} == test_case["results"]["packages"]
+
+        license_expressions = [pkg["licenseDeclared"] for pkg in res["sbom"]["packages"]]
+        license_refs = [le for le in license_expressions if le.startswith("LicenseRef-")]
+        non_license_refs = [le for le in license_expressions if not le.startswith("LicenseRef-")]
+        if not is_license_expression_available():
+            # all license expressions shhould be converted to ExtractedLicensingInfo
+            assert len(license_refs) == len(license_expressions)
+            assert len(non_license_refs) == 0
+        else:
+            # some license expressions should not be converted to ExtractedLicensingInfo
+            assert len(license_refs) < len(license_expressions)
+            if custom_license_db:
+                assert len(non_license_refs) == 5
+                # "GPLv2" is not a valid SPDX license expression, but it is added in our custom license db
+                assert "GPLv2" in non_license_refs
+            else:
+                assert len(non_license_refs) == 2
+                assert "GPLv2" not in non_license_refs
+
     else:
         assert "sbom" not in res