diff --git a/.yamllint b/.yamllint
index bd129bcd..e91e6974 100644
--- a/.yamllint
+++ b/.yamllint
@@ -21,3 +21,4 @@ ignore:
- ansible/vaults/
- .venv
- node_modules/
+ - .tekton/ # Directory managed by Konflux
diff --git a/docs/img/index-image.spdx.svg b/docs/img/index-image.spdx.svg
new file mode 100644
index 00000000..3f127cd2
--- /dev/null
+++ b/docs/img/index-image.spdx.svg
@@ -0,0 +1,143 @@
+
+
+
+
+
diff --git a/docs/sboms/oci_image_sbom.md b/docs/sboms/oci_image_sbom.md
new file mode 100644
index 00000000..b6e6a2a8
--- /dev/null
+++ b/docs/sboms/oci_image_sbom.md
@@ -0,0 +1,56 @@
+# SBOM for Image Index
+
+The Mobster tool is capable of generating SBOMs for OCI image indexes based
+on the guidelines from the
+[Red Hat Product Security](https://github.com/RedHatProductSecurity/security-data-guidelines).
+
+## Usage
+
+```bash
+# First get index manifest using buildah
+buildah manifest inspect registry.redhat.io/ubi10-beta/ubi@sha256:f817eb70b083c93b4d6b47e1daae292d662e3427f5e73c5e8f513695e5afc7cc > ./index-image-manifest.json
+
+# Then generate SBOM using Mobster
+mobster generate \
+ --output index.sbom.spdx.json \
+ oci-index \
+ --index-image-pullspec "registry.redhat.io/ubi10-beta/ubi:latest" \
+ --index-image-digest "sha256:f817eb70b083c93b4d6b47e1daae292d662e3427f5e73c5e8f513695e5afc7cc" \
+ --index-manifest-path ./index-image-manifest.json
+```
+
+
+**List of arguments:**
+
+- `--index-image-pullspec`
+ - Must be in the format `repository/image:tag`
+ - Example value `registry.redhat.io/ubi10-beta/ubi:latest`
+- `--index-image-digest`
+ - Must be in the format `algorithm:hexvalue`
+ - Example value `sha256:f817eb70b083c93b4d6b47e1daae292d662e3427f5e73c5e8f513695e5afc7cc`
+- `--index-manifest-path`
+ - Path to a file containing a json output of `buildah manifest inspect` command
+ - File contents MUST be a valid JSON
+ - See example in [index_manifest.json](../../tests/data/index_manifest.json)
+- `--output`
+ - Path where the SBOM should be written
+
+
+## Example
+
+The example SBOM generated by the above command is available in
+[tests/data/index_manifest_sbom.spdx.json](../../tests/data/index_manifest_sbom.spdx.json).
+
+# Structure of the generated SBOM
+
+The generated SBOM has following structure:
+```
+ - SPDXRef-DOCUMENT
+ - SPDXRef-image-index
+ - Image-amd64 (VARIANT_OF)
+ - Image-arm64 (VARIANT_OF)
+ - Image-ppc64le (VARIANT_OF)
+ - Image-s390x (VARIANT_OF)
+```
+
+
diff --git a/src/mobster/cmd/generate.py b/src/mobster/cmd/generate.py
index 8cb8f8bb..cfb423eb 100644
--- a/src/mobster/cmd/generate.py
+++ b/src/mobster/cmd/generate.py
@@ -3,9 +3,24 @@
import json
import logging
from abc import ABC
+from datetime import datetime, timezone
from typing import Any
+from uuid import uuid4
+
+from spdx_tools.spdx.model.actor import Actor, ActorType
+from spdx_tools.spdx.model.checksum import Checksum, ChecksumAlgorithm
+from spdx_tools.spdx.model.document import CreationInfo, Document
+from spdx_tools.spdx.model.package import (
+ ExternalPackageRef,
+ ExternalPackageRefCategory,
+ Package,
+)
+from spdx_tools.spdx.model.relationship import Relationship, RelationshipType
+from spdx_tools.spdx.model.spdx_no_assertion import SpdxNoAssertion
+from spdx_tools.spdx.writer.write_anything import write_file
from mobster.cmd.base import Command
+from mobster.image import Image
LOGGER = logging.getLogger(__name__)
@@ -16,7 +31,7 @@ class GenerateCommand(Command, ABC):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
- self._content: dict[str, Any] | None = None
+ self._content: Any = None
@property
def content(self) -> Any:
@@ -55,15 +70,206 @@ class GenerateOciIndexCommand(GenerateCommand):
Command to generate an SBOM document for an OCI index image.
"""
+ INDEX_IMAGE_MANIFEST_MEDIA_TYPES = [
+ "application/vnd.oci.image.index.v1+json",
+ "application/vnd.docker.distribution.manifest.list.v2+json",
+ ]
+
+ IMAGE_MANIFEST_MEDIA_TYPES = [
+ "application/vnd.oci.image.manifest.v1+json",
+ "application/vnd.docker.distribution.manifest.v2+json",
+ ]
+
+ DOC_ELEMENT_ID = "SPDXRef-DOCUMENT"
+ INDEX_ELEMENT_ID = "SPDXRef-image-index"
+
+ def get_package(self, image: Image, spdx_id: str) -> Package:
+ """
+ Transform the parsed image object into SPDX package object.
+
+
+ Args:
+ image (Image): A parsed image object.
+ spdx_id (str): An SPDX ID for the image.
+
+ Returns:
+ Package: A package object representing the OCI image.
+ """
+
+ package = Package(
+ spdx_id=spdx_id,
+ name=image.name if not image.arch else f"{image.name}_{image.arch}",
+ version=image.tag,
+ download_location=SpdxNoAssertion(),
+ supplier=Actor(ActorType.ORGANIZATION, "Red Hat"),
+ license_declared=SpdxNoAssertion(),
+ files_analyzed=False,
+ external_references=[
+ ExternalPackageRef(
+ category=ExternalPackageRefCategory.PACKAGE_MANAGER,
+ reference_type="purl",
+ locator=image.purl(),
+ )
+ ],
+ checksums=[
+ Checksum(
+ algorithm=ChecksumAlgorithm.SHA256,
+ value=image.digest_hex_val,
+ )
+ ],
+ )
+
+ return package
+
+ def get_index_image_relationship(self, spdx_id: str) -> Relationship:
+ """
+ Get a relationship for the OCI index image in relation to the SPDX document.
+ This relationship indicates that the document describes the index image.
+
+ Args:
+ spdx_id (str): An SPDX ID for the index image.
+
+ Returns:
+ Relationship: A SPDX relationship object for the index image.
+ """
+ return Relationship(
+ spdx_element_id=self.DOC_ELEMENT_ID,
+ relationship_type=RelationshipType.DESCRIBES,
+ related_spdx_element_id=spdx_id,
+ )
+
+ def get_child_image_relationship(self, spdx_id: str) -> Relationship:
+ """
+ Get a relationship for the child image in relation to the OCI index image.
+ This relationship indicates that the child image is
+ a variant of the index image.
+
+ Args:
+ spdx_id (str): An SPDX ID for the child image.
+
+ Returns:
+ Relationship: A SPDX relationship object for the child image.
+ """
+ return Relationship(
+ spdx_element_id=spdx_id,
+ relationship_type=RelationshipType.VARIANT_OF,
+ related_spdx_element_id=self.INDEX_ELEMENT_ID,
+ )
+
+ def get_child_packages(
+ self, index_image: Image
+ ) -> tuple[list[Package], list[Relationship]]:
+ """
+ Get child packages from the OCI index image.
+ """
+ packages = []
+ relationships = []
+
+ with open(self.cli_args.index_manifest_path, encoding="utf8") as manifest_file:
+ index_manifest = json.load(manifest_file)
+
+ if index_manifest["mediaType"] not in self.INDEX_IMAGE_MANIFEST_MEDIA_TYPES:
+ raise ValueError(
+ "Invalid input file detected, requires `buildah manifest inspect` json."
+ )
+
+ LOGGER.debug("Inspecting OCI index image: %s", index_manifest)
+
+ for manifest in index_manifest["manifests"]:
+ if manifest["mediaType"] not in self.IMAGE_MANIFEST_MEDIA_TYPES:
+ LOGGER.warning(
+ "Skipping manifest with unsupported media type: %s",
+ manifest["mediaType"],
+ )
+ continue
+
+ arch = manifest.get("platform", {}).get("architecture")
+
+ LOGGER.info("Found child image with architecture: %s", arch)
+
+ arch_image = Image(
+ arch=arch,
+ name=index_image.name,
+ digest=self.cli_args.index_image_digest,
+ tag=index_image.tag,
+ repository=index_image.repository,
+ )
+ spdx_id = arch_image.propose_spdx_id()
+ package = self.get_package(
+ arch_image,
+ spdx_id,
+ )
+ relationship = self.get_child_image_relationship(spdx_id)
+
+ packages.append(package)
+ relationships.append(relationship)
+
+ return packages, relationships
+
+ def get_creation_info(self, index_image: Image) -> CreationInfo:
+ """
+ Create the creation information for the SPDX document.
+
+ Args:
+ index_image (Image): An OCI index image object.
+
+ Returns:
+ CreationInfo: A creation information object for the SPDX document.
+ """
+ sbom_name = f"{index_image.repository}@{index_image.digest}"
+ return CreationInfo(
+ spdx_version="SPDX-2.3",
+ spdx_id=self.DOC_ELEMENT_ID,
+ name=sbom_name,
+ data_license="CC0-1.0",
+ document_namespace="https://konflux-ci.dev/spdxdocs/"
+ f"{index_image.name}-{index_image.tag}-{uuid4()}",
+ creators=[
+ Actor(ActorType.ORGANIZATION, "Red Hat"),
+ Actor(ActorType.TOOL, "Konflux CI"),
+ Actor(ActorType.TOOL, "Mobster"),
+ ],
+ created=datetime.now(timezone.utc),
+ )
+
async def execute(self) -> Any:
"""
- Generate an SBOM document for OCI index.
+ Generate an SBOM document for OCI index in SPDX format.
"""
- # Placeholder for the actual implementation
- LOGGER.debug("Generating SBOM document for OCI index")
- self._content = {}
+ LOGGER.info("Generating SBOM document for OCI index")
+
+ index_image = Image.from_image_index_url_and_digest(
+ self.cli_args.index_image_pullspec, self.cli_args.index_image_digest
+ )
+
+ main_package = self.get_package(index_image, self.INDEX_ELEMENT_ID)
+ main_relationship = self.get_index_image_relationship(self.INDEX_ELEMENT_ID)
+ component_packages, component_relationships = self.get_child_packages(
+ index_image
+ )
+
+ # Assemble a complete SPDX document
+ document = Document(
+ creation_info=self.get_creation_info(index_image),
+ packages=[main_package] + component_packages,
+ relationships=[main_relationship] + component_relationships,
+ )
+
+ self._content = document
return self.content
+ async def save(self) -> None:
+ """
+ Convert SPDX document to JSON and save it to a file.
+ """
+ if self.cli_args.output and self._content:
+ LOGGER.info("Saving SBOM document to '%s'", self.cli_args.output)
+ write_file(
+ self._content,
+ str(self.cli_args.output),
+ validate=True,
+ )
+
class GenerateProductCommand(GenerateCommand):
"""
diff --git a/src/mobster/image.py b/src/mobster/image.py
new file mode 100644
index 00000000..69dd1358
--- /dev/null
+++ b/src/mobster/image.py
@@ -0,0 +1,101 @@
+"""An image module for representing OCI images."""
+
+import hashlib
+from dataclasses import dataclass
+
+from packageurl import PackageURL
+
+
+@dataclass
+class Image:
+ """
+ Dataclass representing an oci image.
+ """
+
+ repository: str
+ name: str
+ digest: str
+ tag: str
+ arch: str | None
+
+ @staticmethod
+ def from_image_index_url_and_digest(
+ image_tag_pullspec: str,
+ image_digest: str,
+ arch: str | None = None,
+ ) -> "Image":
+ """
+ Create an Image object from the image URL and digest.
+
+ Args:
+ image_tag_pullspec (str): Image pullspec in the format
+ /:
+ image_digest (str): Image digest in the format sha256:
+ arch (str | None, optional): Image architecure if present. Defaults to None.
+
+ Returns:
+ Image: A representation of the OCI image.
+ """
+ repository, tag = image_tag_pullspec.rsplit(":", 1)
+ _, name = repository.rsplit("/", 1)
+
+ return Image(
+ repository=repository,
+ name=name,
+ digest=image_digest,
+ tag=tag,
+ arch=arch,
+ )
+
+ @property
+ def digest_algo(self) -> str:
+ """
+ Get the algorithm used for the digest.
+
+ Returns:
+ str: An uppercase string representing the algorithm used for the digest.
+ """
+ algo, _ = self.digest.split(":")
+ return algo.upper()
+
+ @property
+ def digest_hex_val(self) -> str:
+ """
+ A digest value in hex format.
+
+ Returns:
+ str: A hex string representing the digest value.
+ """
+ _, val = self.digest.split(":")
+ return val
+
+ def purl(self) -> str:
+ """
+ A package URL representation of the image in string format.
+
+ Returns:
+ str: Package URL string.
+ """
+ qualifiers = {"repository_url": self.repository}
+ if self.arch is not None:
+ qualifiers["arch"] = self.arch
+
+ purl = PackageURL(
+ type="oci",
+ name=self.name,
+ version=self.digest,
+ qualifiers=qualifiers,
+ ).to_string()
+
+ return purl
+
+ def propose_spdx_id(self) -> str:
+ """
+ Generate a proposed SPDX ID for the image.
+ The ID is generated using the image name and a SHA-256 hash of the package URL.
+
+ Returns:
+ str: A proposed SPDX ID for the image.
+ """
+ purl_hex_digest = hashlib.sha256(self.purl().encode()).hexdigest()
+ return f"SPDXRef-image-{self.name}-{purl_hex_digest}"
diff --git a/tests/cmd/test_generate.py b/tests/cmd/test_generate.py
index 4d22fdcb..1fc5c679 100644
--- a/tests/cmd/test_generate.py
+++ b/tests/cmd/test_generate.py
@@ -1,6 +1,9 @@
from unittest.mock import MagicMock, mock_open, patch
import pytest
+from spdx_tools.spdx.model.document import CreationInfo
+from spdx_tools.spdx.model.package import Package
+from spdx_tools.spdx.model.relationship import Relationship, RelationshipType
from mobster.cmd.generate import (
GenerateModelcarCommand,
@@ -9,6 +12,7 @@
GenerateOciIndexCommand,
GenerateProductCommand,
)
+from mobster.image import Image
@pytest.mark.asyncio
@@ -31,10 +35,196 @@ async def test_GenerateOciImageCommand_save(mock_dump: MagicMock) -> None:
@pytest.mark.asyncio
-async def test_GenerateOciIndexCommand_execute() -> None:
+@patch("mobster.cmd.generate.Document")
+@patch("mobster.cmd.generate.GenerateOciIndexCommand.get_creation_info")
+@patch("mobster.cmd.generate.GenerateOciIndexCommand.get_child_packages")
+@patch("mobster.cmd.generate.GenerateOciIndexCommand.get_index_image_relationship")
+@patch("mobster.cmd.generate.GenerateOciIndexCommand.get_package")
+@patch("mobster.cmd.generate.Image.from_image_index_url_and_digest")
+async def test_GenerateOciIndexCommand_execute(
+ mock_image: MagicMock,
+ mock_get_package: MagicMock,
+ mock_index_relationship: MagicMock,
+ mock_child_packages: MagicMock,
+ mock_get_creation_info: MagicMock,
+ mock_doc: MagicMock,
+) -> None:
command = GenerateOciIndexCommand(MagicMock())
- assert await command.execute() == {}
+ mock_child_packages.return_value = ([], [])
+
+ result = await command.execute()
+ assert result == mock_doc.return_value
+
+ mock_get_package.assert_called_once()
+ mock_index_relationship.assert_called_once()
+ mock_child_packages.assert_called_once()
+ mock_doc.assert_called_once_with(
+ creation_info=mock_get_creation_info.return_value,
+ packages=[mock_get_package.return_value] + mock_child_packages.return_value[0],
+ relationships=[
+ mock_index_relationship.return_value,
+ ]
+ + mock_child_packages.return_value[1],
+ )
+
+
+@pytest.mark.asyncio
+@patch("mobster.cmd.generate.write_file")
+async def test_GenerateOciIndexCommand_save(
+ mock_write_file: MagicMock,
+) -> None:
+ args = MagicMock()
+ args.output = "/tmp/test.json"
+ command = GenerateOciIndexCommand(args)
+
+ command._content = MagicMock()
+
+ await command.save()
+
+ mock_write_file.assert_called_once_with(
+ command._content,
+ args.output,
+ validate=True,
+ )
+
+
+def test_GenerateOciIndexCommand_get_package() -> None:
+ args = MagicMock()
+ command = GenerateOciIndexCommand(args)
+ mock_image = Image.from_image_index_url_and_digest(
+ "registry/repo:tag", "sha256:1234567890abcdef"
+ )
+ result = command.get_package(mock_image, "fake_spdx_id")
+
+ assert isinstance(result, Package)
+ assert result.spdx_id == "fake_spdx_id"
+ assert result.name == mock_image.name
+ assert result.checksums[0].value == mock_image.digest_hex_val
+
+
+def test_GenerateOciIndexCommand_get_index_image_relationship() -> None:
+ args = MagicMock()
+ command = GenerateOciIndexCommand(args)
+
+ result = command.get_index_image_relationship("fake_spdx_id")
+
+ assert isinstance(result, Relationship)
+ assert result.spdx_element_id == command.DOC_ELEMENT_ID
+ assert result.relationship_type == RelationshipType.DESCRIBES
+ assert result.related_spdx_element_id == "fake_spdx_id"
+
+
+def test_GenerateOciIndexCommand_get_child_image_relationship() -> None:
+ args = MagicMock()
+ command = GenerateOciIndexCommand(args)
+
+ result = command.get_child_image_relationship("fake_spdx_id")
+
+ assert isinstance(result, Relationship)
+ assert result.spdx_element_id == "fake_spdx_id"
+ assert result.relationship_type == RelationshipType.VARIANT_OF
+ assert result.related_spdx_element_id == command.INDEX_ELEMENT_ID
+
+
+@patch("mobster.cmd.generate.GenerateOciIndexCommand.get_child_image_relationship")
+@patch("mobster.cmd.generate.GenerateOciIndexCommand.get_package")
+@patch("mobster.cmd.generate.json.load")
+def test_GenerateOciIndexCommand_get_child_packages(
+ mock_json_load: MagicMock,
+ mock_get_package: MagicMock,
+ mock_get_child_image_relationship: MagicMock,
+) -> None:
+ args = MagicMock()
+ args.index_image_pullspec = "registry/repo:tag"
+ args.index_image_digest = "sha256:1234567890abcdef"
+ command = GenerateOciIndexCommand(args)
+
+ mock_image = Image.from_image_index_url_and_digest(
+ "registry/repo:tag", "sha256:1234567890abcdef"
+ )
+ mock_manifest = {
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.oci.image.index.v1+json",
+ "manifests": [
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:4b4976d86eefeedab6884c9d2923206c6c3c2e247120"
+ "6f97fd9d7aaaecbc04ac",
+ "platform": {"architecture": "amd64", "os": "linux"},
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:c85623b2a5822b6e101efb05424919da653e7c15e2e3"
+ "e150871c48957087d65a",
+ "platform": {"architecture": "arm64", "os": "linux"},
+ },
+ {
+ "mediaType": "fake_media_type",
+ "digest": "sha256:c856",
+ },
+ ],
+ }
+ mock_json_load.return_value = mock_manifest
+
+ result = command.get_child_packages(mock_image)
+
+ assert mock_get_package.call_count == 2
+ assert mock_get_child_image_relationship.call_count == 2
+
+ packages, relationships = result
+ assert len(packages) == 2
+ assert len(relationships) == 2
+
+
+@patch("mobster.cmd.generate.GenerateOciIndexCommand.get_child_image_relationship")
+@patch("mobster.cmd.generate.GenerateOciIndexCommand.get_package")
+@patch("mobster.cmd.generate.json.load")
+def test_GenerateOciIndexCommand_get_child_packages_unknown(
+ mock_json_load: MagicMock,
+ mock_get_package: MagicMock,
+ mock_get_child_image_relationship: MagicMock,
+) -> None:
+ args = MagicMock()
+ args.index_image_pullspec = "registry/repo:tag"
+ args.index_image_digest = "sha256:1234567890abcdef"
+ command = GenerateOciIndexCommand(args)
+
+ mock_image = Image.from_image_index_url_and_digest(
+ "registry/repo:tag", "sha256:1234567890abcdef"
+ )
+ mock_manifest = {
+ "schemaVersion": 2,
+ "mediaType": "uknown_media_type",
+ "manifests": [],
+ }
+ mock_json_load.return_value = mock_manifest
+
+ with pytest.raises(ValueError):
+ command.get_child_packages(mock_image)
+
+
+@patch("mobster.cmd.generate.GenerateOciIndexCommand.get_child_image_relationship")
+@patch("mobster.cmd.generate.GenerateOciIndexCommand.get_package")
+@patch("mobster.cmd.generate.json.load")
+def test_GenerateOciIndexCommand_get_creation_info(
+ mock_json_load: MagicMock,
+ mock_get_package: MagicMock,
+ mock_get_child_image_relationship: MagicMock,
+) -> None:
+ args = MagicMock()
+ args.index_image_pullspec = "registry/repo:tag"
+ args.index_image_digest = "sha256:1234567890abcdef"
+ command = GenerateOciIndexCommand(args)
+
+ mock_image = Image.from_image_index_url_and_digest(
+ "registry/repo:tag", "sha256:1234567890abcdef"
+ )
+
+ result = command.get_creation_info(mock_image)
+
+ assert isinstance(result, CreationInfo)
+ assert result.spdx_id == command.DOC_ELEMENT_ID
@pytest.mark.asyncio
diff --git a/tests/cmd/test_generate_oci_index.py b/tests/cmd/test_generate_oci_index.py
new file mode 100644
index 00000000..ba4b48c0
--- /dev/null
+++ b/tests/cmd/test_generate_oci_index.py
@@ -0,0 +1,46 @@
+import json
+import pathlib
+import tempfile
+from unittest.mock import MagicMock
+
+import pytest
+
+from mobster.cmd.generate import GenerateOciIndexCommand
+
+
+@pytest.mark.asyncio
+async def test_generate_oci_index_sbom() -> None:
+ """
+ This test verifies the generation of an OCI index SBOM end-to-end.
+ """
+
+ args = MagicMock()
+ current_dir = pathlib.Path(__file__).parent.resolve()
+ args.index_manifest_path = current_dir.parent / "data/index_manifest.json"
+ args.index_image_pullspec = "registry.redhat.io/ubi10-beta/ubi:latest"
+ args.index_image_digest = (
+ "sha256:4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac"
+ )
+
+ expected_output_path = current_dir.parent / "data/index_manifest_sbom.spdx.json"
+ with open(expected_output_path, encoding="utf8") as expected_file:
+ expected_output = json.load(expected_file)
+
+ command = GenerateOciIndexCommand(args)
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ args.output = pathlib.Path(temp_dir) / "index_manifest_sbom.spdx.json"
+ await command.execute()
+ await command.save()
+
+ assert command._content is not None
+ with open(args.output, encoding="utf8") as result_file:
+ result = json.load(result_file)
+
+ # Copy dynamic values from expected output
+ result["creationInfo"]["created"] = expected_output["creationInfo"][
+ "created"
+ ]
+ result["documentNamespace"] = expected_output["documentNamespace"]
+
+ assert result == expected_output
diff --git a/tests/data/index_manifest.json b/tests/data/index_manifest.json
new file mode 100644
index 00000000..23eb7d02
--- /dev/null
+++ b/tests/data/index_manifest.json
@@ -0,0 +1,42 @@
+{
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.oci.image.index.v1+json",
+ "manifests": [
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac",
+ "size": 659,
+ "platform": {
+ "architecture": "amd64",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:c85623b2a5822b6e101efb05424919da653e7c15e2e3e150871c48957087d65a",
+ "size": 659,
+ "platform": {
+ "architecture": "arm64",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:874debf2354befc6ce90ccee2cbdab4abe2bf437aa6f4ab7649f7aeb4d57373a",
+ "size": 659,
+ "platform": {
+ "architecture": "s390x",
+ "os": "linux"
+ }
+ },
+ {
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
+ "digest": "sha256:c8799d5d851ddd76729ebb7227818f8c0030c4443c9e8542a75ff393fc90ce12",
+ "size": 659,
+ "platform": {
+ "architecture": "ppc64le",
+ "os": "linux"
+ }
+ }
+ ]
+}
diff --git a/tests/data/index_manifest_sbom.spdx.json b/tests/data/index_manifest_sbom.spdx.json
new file mode 100644
index 00000000..40712b7b
--- /dev/null
+++ b/tests/data/index_manifest_sbom.spdx.json
@@ -0,0 +1,154 @@
+{
+ "SPDXID": "SPDXRef-DOCUMENT",
+ "creationInfo": {
+ "created": "2025-05-06T13:14:21Z",
+ "creators": [
+ "Organization: Red Hat",
+ "Tool: Konflux CI",
+ "Tool: Mobster"
+ ]
+ },
+ "dataLicense": "CC0-1.0",
+ "name": "registry.redhat.io/ubi10-beta/ubi@sha256:4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac",
+ "spdxVersion": "SPDX-2.3",
+ "documentNamespace": "https://konflux-ci.dev/spdxdocs/ubi-latest-aa76aa8c-13ce-4c13-bdf1-f16dd9a9ff54",
+ "packages": [
+ {
+ "SPDXID": "SPDXRef-image-index",
+ "checksums": [
+ {
+ "algorithm": "SHA256",
+ "checksumValue": "4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac"
+ }
+ ],
+ "downloadLocation": "NOASSERTION",
+ "externalRefs": [
+ {
+ "referenceCategory": "PACKAGE_MANAGER",
+ "referenceLocator": "pkg:oci/ubi@sha256:4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac?repository_url=registry.redhat.io/ubi10-beta/ubi",
+ "referenceType": "purl"
+ }
+ ],
+ "filesAnalyzed": false,
+ "licenseDeclared": "NOASSERTION",
+ "name": "ubi",
+ "supplier": "Organization: Red Hat",
+ "versionInfo": "latest"
+ },
+ {
+ "SPDXID": "SPDXRef-image-ubi-eed2511e5b2f6c7891d79466ef64ed13e9e29e8fe536709666084922e3ff2ff6",
+ "checksums": [
+ {
+ "algorithm": "SHA256",
+ "checksumValue": "4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac"
+ }
+ ],
+ "downloadLocation": "NOASSERTION",
+ "externalRefs": [
+ {
+ "referenceCategory": "PACKAGE_MANAGER",
+ "referenceLocator": "pkg:oci/ubi@sha256:4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac?arch=amd64&repository_url=registry.redhat.io/ubi10-beta/ubi",
+ "referenceType": "purl"
+ }
+ ],
+ "filesAnalyzed": false,
+ "licenseDeclared": "NOASSERTION",
+ "name": "ubi_amd64",
+ "supplier": "Organization: Red Hat",
+ "versionInfo": "latest"
+ },
+ {
+ "SPDXID": "SPDXRef-image-ubi-2ded713f6a4f846c080a42f73f1e1af6f5b4a7653825480f0e0f1d86dec89375",
+ "checksums": [
+ {
+ "algorithm": "SHA256",
+ "checksumValue": "4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac"
+ }
+ ],
+ "downloadLocation": "NOASSERTION",
+ "externalRefs": [
+ {
+ "referenceCategory": "PACKAGE_MANAGER",
+ "referenceLocator": "pkg:oci/ubi@sha256:4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac?arch=arm64&repository_url=registry.redhat.io/ubi10-beta/ubi",
+ "referenceType": "purl"
+ }
+ ],
+ "filesAnalyzed": false,
+ "licenseDeclared": "NOASSERTION",
+ "name": "ubi_arm64",
+ "supplier": "Organization: Red Hat",
+ "versionInfo": "latest"
+ },
+ {
+ "SPDXID": "SPDXRef-image-ubi-e568fd454597f2450f75978641c020c50874cf4760e986a1cd9f4a1b3fbc2c4c",
+ "checksums": [
+ {
+ "algorithm": "SHA256",
+ "checksumValue": "4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac"
+ }
+ ],
+ "downloadLocation": "NOASSERTION",
+ "externalRefs": [
+ {
+ "referenceCategory": "PACKAGE_MANAGER",
+ "referenceLocator": "pkg:oci/ubi@sha256:4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac?arch=s390x&repository_url=registry.redhat.io/ubi10-beta/ubi",
+ "referenceType": "purl"
+ }
+ ],
+ "filesAnalyzed": false,
+ "licenseDeclared": "NOASSERTION",
+ "name": "ubi_s390x",
+ "supplier": "Organization: Red Hat",
+ "versionInfo": "latest"
+ },
+ {
+ "SPDXID": "SPDXRef-image-ubi-b7c40d971296f5b6197d0a0833e91f4172720d37db4089ebb1159a9bbe68b52e",
+ "checksums": [
+ {
+ "algorithm": "SHA256",
+ "checksumValue": "4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac"
+ }
+ ],
+ "downloadLocation": "NOASSERTION",
+ "externalRefs": [
+ {
+ "referenceCategory": "PACKAGE_MANAGER",
+ "referenceLocator": "pkg:oci/ubi@sha256:4b4976d86eefeedab6884c9d2923206c6c3c2e2471206f97fd9d7aaaecbc04ac?arch=ppc64le&repository_url=registry.redhat.io/ubi10-beta/ubi",
+ "referenceType": "purl"
+ }
+ ],
+ "filesAnalyzed": false,
+ "licenseDeclared": "NOASSERTION",
+ "name": "ubi_ppc64le",
+ "supplier": "Organization: Red Hat",
+ "versionInfo": "latest"
+ }
+ ],
+ "relationships": [
+ {
+ "spdxElementId": "SPDXRef-DOCUMENT",
+ "relatedSpdxElement": "SPDXRef-image-index",
+ "relationshipType": "DESCRIBES"
+ },
+ {
+ "spdxElementId": "SPDXRef-image-ubi-eed2511e5b2f6c7891d79466ef64ed13e9e29e8fe536709666084922e3ff2ff6",
+ "relatedSpdxElement": "SPDXRef-image-index",
+ "relationshipType": "VARIANT_OF"
+ },
+ {
+ "spdxElementId": "SPDXRef-image-ubi-2ded713f6a4f846c080a42f73f1e1af6f5b4a7653825480f0e0f1d86dec89375",
+ "relatedSpdxElement": "SPDXRef-image-index",
+ "relationshipType": "VARIANT_OF"
+ },
+ {
+ "spdxElementId": "SPDXRef-image-ubi-e568fd454597f2450f75978641c020c50874cf4760e986a1cd9f4a1b3fbc2c4c",
+ "relatedSpdxElement": "SPDXRef-image-index",
+ "relationshipType": "VARIANT_OF"
+ },
+ {
+ "spdxElementId": "SPDXRef-image-ubi-b7c40d971296f5b6197d0a0833e91f4172720d37db4089ebb1159a9bbe68b52e",
+ "relatedSpdxElement": "SPDXRef-image-index",
+ "relationshipType": "VARIANT_OF"
+ }
+ ]
+}
diff --git a/tests/test_image.py b/tests/test_image.py
new file mode 100644
index 00000000..00932384
--- /dev/null
+++ b/tests/test_image.py
@@ -0,0 +1,38 @@
+from mobster.image import Image
+
+
+def test_image() -> None:
+ """
+ Test the from_image_index_url_and_digest method of the Image class.
+ """
+ image_tag_pullspec = "registry.example.com/repo/image:tag"
+ image_digest = (
+ "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
+ )
+ arch = "amd64"
+
+ image = Image.from_image_index_url_and_digest(
+ image_tag_pullspec, image_digest, arch
+ )
+
+ assert image.repository == "registry.example.com/repo/image"
+ assert image.name == "image"
+ assert image.digest == image_digest
+ assert image.tag == "tag"
+ assert image.arch == arch
+
+ assert image.digest_algo == "SHA256"
+ assert image.digest_hex_val == (
+ "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
+ )
+ assert (
+ image.purl()
+ == "pkg:oci/image@sha256:1234567890abcdef1234567890abcdef1234567890abcdef123456"
+ "7890abcdef?arch=amd64&repository_url=registry.example.com/repo/image"
+ )
+
+ assert (
+ image.propose_spdx_id()
+ == "SPDXRef-image-image-73e355ba72fbb39f9249a171eb05bed259d998d5f747b5001ad42fb"
+ "1bda26e6a"
+ )