diff --git a/docs/source/conf.py b/docs/source/conf.py index fc3caf24..35bc6d55 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -41,6 +41,9 @@ templates_path = ["_templates"] autoapi_template_dir = "source/autoapi_templates" +# Remove warnings for auto API template. +exclude_patterns = ["autoapi_templates/index.rst"] + # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # @@ -52,7 +55,7 @@ # General information about the project. project = "e3-core" -copyright = "2017, AdaCore" # noqa: A001 +project_copyright = "2017, AdaCore" author = "AdaCore" # The version info for the project you're documenting, acts as replacement for @@ -60,9 +63,9 @@ # built documents. # # The short X.Y version. -version = "21.0" +version = "24.0" # The full version, including alpha/beta/rc tags. -release = "21.0" +release = "24.0" # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" @@ -75,11 +78,11 @@ html_theme = "sphinx_rtd_theme" html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] - # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] +# html_static_path = ["_static"] +html_static_path = [] # -- Options for HTMLHelp output ------------------------------------------ diff --git a/setup.py b/setup.py index 9af29248..47040e66 100644 --- a/setup.py +++ b/setup.py @@ -71,6 +71,8 @@ "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Build Tools", ], packages=find_packages(where="src"), diff --git a/src/e3/dsse.py b/src/e3/dsse.py index 98209e01..4118d5da 100644 --- a/src/e3/dsse.py +++ b/src/e3/dsse.py @@ -54,7 +54,7 @@ def verify(self, certificate: str) -> bool: The current algorithm is to check that at least one signature correspond to the certificate given as parameter. This part should be improved - :param certifciate: path to the certificate containing the public key + :param certificate: path to the certificate containing the public key :return: True if one of the signature can be checked with the certificate """ # First get the public key diff --git a/src/e3/job/walk.py b/src/e3/job/walk.py index db0459bf..34b52580 100644 --- a/src/e3/job/walk.py +++ b/src/e3/job/walk.py @@ -20,6 +20,8 @@ class Walk: """An abstract class scheduling and executing a DAG of actions. + .. |ReturnValue| replace:: :class:`~e3.anod.status.ReturnValue` + :ivar actions: DAG of actions to perform. :vartype actions: DAG :ivar prev_fingerprints: A dict of e3.fingerprint.Fingerprint objects, @@ -35,13 +37,13 @@ class Walk: (with the job corresponding to a given entry in the DAG of actions). :vartype new_fingerprints: dict[str, Fingerprint | None] - :ivar job_status: A dictionary of job status (ReturnValue), indexed by + :ivar job_status: A dictionary of job status (|ReturnValue|), indexed by job unique IDs. - :vartype job_status: dict[str, ReturnValue] + :vartype job_status: dict[str, |ReturnValue|] :ivar scheduler: The scheduler used to schedule and execute all the actions. :vartype scheduler: e3.job.scheduler.Scheduler - """ + """ # noqa RST304 def __init__(self, actions: DAG): """Object initializer. diff --git a/src/e3/slsa/__init__.py b/src/e3/slsa/__init__.py new file mode 100644 index 00000000..9cee04ac --- /dev/null +++ b/src/e3/slsa/__init__.py @@ -0,0 +1 @@ +"""SLSA (Supply-chain Levels for Software Artifacts) package.""" diff --git a/src/e3/slsa/provenance.py b/src/e3/slsa/provenance.py new file mode 100644 index 00000000..ccec355a --- /dev/null +++ b/src/e3/slsa/provenance.py @@ -0,0 +1,1641 @@ +"""SLSA provenance package. + +Implementing https://slsa.dev/spec/v1.0/provenance. + +Purpose +======= +Describe how an artifact or set of artifacts was produced so that: + +- Consumers of the provenance can verify that the artifact was built according + to expectations. +- Others can rebuild the artifact, if desired. + +This predicate is the *RECOMMENDED* way to satisfy the +`SLSA v1.0 provenance requirements +`_. + +.. _SLSA: https://slsa.dev + +.. |ResourceDescriptor| replace:: :class:`ResourceDescriptor` +.. |ResourceURI| replace:: :class:`ResourceURI` +.. |SLSA| replace:: `SLSA`_ +.. |bool| replace:: :class:`bool` +.. |bytes| replace:: :class:`bytes` +.. |datetime| replace:: :class:`~datetime.datetime` +.. |dict| replace:: :class:`dict` +.. |json.dumps| replace:: :func:`json.dumps()` +.. |json.load| replace:: :func:`json.load()` +.. |str| replace:: :class:`str` +""" # noqa RST304 + +from __future__ import annotations + +import base64 +import json +import hashlib + +from datetime import datetime, timezone +from dateutil import parser as date_parser +from pathlib import Path +from typing import Any + + +class Builder(object): + """Predicate run details builder object. + + The build platform, or builder for short, represents the transitive closure + of all the entities that are, by necessity, + `trusted `_ + to faithfully run the build and record the provenance. + + This includes not only the software but the hardware and people involved in + running the service. + + For example, a particular instance of `Tekton `_ could + be a build platform, while Tekton itself is not. For more info, see + `Build model `_. + + The |id| **MUST** reflect the trust base that consumers care about. How + detailed to be is a judgement call. For example, GitHub Actions supports + both GitHub-hosted runners and self-hosted runners. The GitHub-hosted runner + might be a single identity because it’s all GitHub from the consumer’s + perspective. Meanwhile, each self-hosted runner might have its own identity + because not all runners are trusted by all consumers. + + Consumers MUST accept only specific signer-builder pairs. For example, + ``GitHub`` can sign provenance for the ``GitHub Actions`` builder, and + ``Google`` can sign provenance for the ``Google Cloud Build`` builder, but + ``GitHub`` cannot sign for the ``Google Cloud Build`` builder. + + Design rationale + ---------------- + The builder is distinct from the signer in order to support the case where + one signer generates attestations for more than one builder, as in the + ``GitHub Actions`` example above. The field is **REQUIRED**, even if it is + implicit from the signer, to aid readability and debugging. + + It is an object to allow additional fields in the future, in case one URI is + not sufficient. + + .. |builder.as_dict| replace:: :meth:`~Builder.as_dict` + .. |builder.as_json| replace:: :meth:`~Builder.as_json` + .. |id| replace:: :attr:`~Builder.id` + .. |builder.load_dict| replace:: :meth:`~Builder.load_dict` + .. |builder.load_json| replace:: :meth:`~Builder.load_json` + """ # noqa RST304 + + ATTR_BUILD_ID: str = "id" + ATTR_BUILDER_DEPENDENCIES: str = "builderDependencies" + ATTR_VERSION: str = "version" + + def __init__( + self, + build_id: TypeURI | str, + builder_dependencies: list[ResourceDescriptor], + version: dict[str, str], + ) -> None: + self.__id: TypeURI = ( + build_id if isinstance(build_id, TypeURI) else TypeURI(build_id) + ) + self.__dependencies: list[ResourceDescriptor] = builder_dependencies + self.__version: dict[str, str] = version + + def __eq__(self, other: object) -> bool: + """Check if this builder object is equal to *other*. + + :param other: The builder object to compare this with. + + :return: A |bool| set to **True** if both builders are equal, **False** + else. + """ # noqa RST304 + if isinstance(other, self.__class__): + return self.as_json() == other.as_json() + return False + + @property + def builder_dependencies(self) -> list[ResourceDescriptor]: + """Builder dependencies. + + Dependencies used by the orchestrator that are not run within the + workload and that do not affect the build, but might affect the + provenance generation or security guarantees. + """ + return self.__dependencies + + @property + def id(self) -> TypeURI: + """Build platform ID. + + URI indicating the transitive closure of the trusted build platform. + This is intended to be the sole determiner of the SLSA Build level. + + If a build platform has multiple modes of operations that have differing + security attributes or SLSA Build levels, each mode **MUST** have a + different |id| and **SHOULD** have a different signer identity. This is + to minimize the risk that a less secure mode compromises a more secure + one. + + The |id| URI **SHOULD** resolve to documentation explaining: + + - The scope of what this ID represents. + - The claimed SLSA Build level. + - The accuracy and completeness guarantees of the fields in the + provenance. + - Any fields that are generated by the tenant-controlled build process + and not verified by the trusted control plane, except for the + ``subject``. + - The interpretation of any extension fields. + + """ # noqa RST304 + return self.__id + + @property + def version(self) -> dict[str, str]: + """Builder version mapping. + + Map of names of components of the build platform to their version. + """ + return self.__version + + def as_dict(self) -> dict: + """Get the dictionary representation of this builder. + + :return: The dictionary representation of this builder. This should + be a valid JSON object (call to |json.load| succeeds). + + .. seealso:: |json.load| + """ # noqa RST304 + return { + self.ATTR_BUILD_ID: str(self.id), + self.ATTR_BUILDER_DEPENDENCIES: [ + desc.as_dict() for desc in self.builder_dependencies + ], + self.ATTR_VERSION: self.version, + } + + def as_json(self) -> str: + """Get the representation of this builder as a JSON string. + + The dictionary representing this builder (as returned by + |builder.as_dict|) is turned into a JSON string using |json.dumps| with + *sort_keys* set to **True**. + + .. seealso:: |builder.as_dict|, |builder.load_json| + """ # noqa RST304 + return json.dumps(self.as_dict(), sort_keys=True) + + @classmethod + def load_dict(cls, initializer: dict[str, Any]) -> Builder: + """Initialize a builder from a dictionary. + + :param initializer: The dictionary to initialize this builder with. + + :return: A builder object created from the input *initializer*. + + :raise ValueError: if the build ID is not defined in *initializer*. + + .. seealso:: |builder.as_dict| + """ # noqa RST304 + build_id: str | None = initializer.get(cls.ATTR_BUILD_ID) + if build_id is None: + raise ValueError("Invalid build ID (None)") + + builder: Builder = cls( + build_id=build_id, + builder_dependencies=[ + ResourceDescriptor.load_dict(rd) + for rd in initializer.get(cls.ATTR_BUILDER_DEPENDENCIES, []) + ], + version=initializer.get(cls.ATTR_VERSION, {}), + ) + + return builder + + @classmethod + def load_json(cls, initializer: str) -> Builder: + """Initialize a builder from a JSON string. + + :param initializer: The JSON string to initialize this builder with. + + :return: A builder object created from the input *initializer*. + + :raise ValueError: if the build ID is not defined in *initializer*. + + .. seealso:: |builder.as_json|, |builder.load_dict| + """ # noqa RST304 + return cls.load_dict(json.loads(initializer)) + + +class BuildMetadata(object): + """Build metadata representation. + + When the timestamp parameters (*started_on* or *finished_on*) are strings, + the |TIMESTAMP_FORMAT| format is used to convert them to |datetime| classes + using |strptime|. + + :param invocation_id: Identifier of this particular build invocation. + :param started_on: The timestamp of this build invocation start time. + :param finished_on: The timestamp of this build invocation finish time. + + .. |TIMESTAMP_FORMAT| replace:: :attr:`TIMESTAMP_FORMAT` + .. |bm.as_dict| replace:: :meth:`~BuildMetadata.as_dict` + .. |bm.as_json| replace:: :meth:`~BuildMetadata.as_json` + .. |bm.load_dict| replace:: :meth:`~BuildMetadata.load_dict` + .. |bm.load_json| replace:: :meth:`~BuildMetadata.load_json` + .. |strptime| replace:: :meth:`~datetime.strptime` + """ # noqa RST304 + + ATTR_INVOCATION_ID: str = "invocationId" + ATTR_STARTED_ON: str = "startedOn" + ATTR_FINISHED_ON: str = "finishedOn" + + TIMESTAMP_FORMAT: str = "%Y-%m-%dT%H:%M:%SZ" + """Timestamp format used to read/write |datetime| structures to strings.""" + + def __init__( + self, + invocation_id: str, + started_on: datetime, + finished_on: datetime, + ) -> None: + self.__invocation_id: str = invocation_id + self.__started_on: datetime = self.__validate_timestamp(started_on) + self.__finished_on: datetime = self.__validate_timestamp(finished_on) + + def __eq__(self, other: object) -> bool: + """Check if this build metadata object is equal to *other*. + + :param other: The build metadata object to compare this with. + + :return: A |bool| set to **True** if both build metadatas are equal, + **False** else. + """ # noqa RST304 + if isinstance(other, self.__class__): + return self.as_json() == other.as_json() + return False + + @property + def finished_on(self) -> datetime: + """The timestamp of when the build completed.""" + return self.__finished_on + + @property + def invocation_id(self) -> str: + """Build invocation identifier. + + Identifies this particular build invocation, which can be useful for + finding associated logs or other ad-hoc analysis. The exact meaning and + format is defined by |builder.id|; by default it is treated as opaque + and case-sensitive. + + The value **SHOULD** be globally unique. + + .. |builder.id| replace:: :attr:`Builder.id` + """ # noqa RST304 + return self.__invocation_id + + @property + def started_on(self) -> datetime: + """The timestamp of when the build started.""" + return self.__started_on + + # --------------------------- Public methods ---------------------------- # + + def as_dict(self) -> dict: + """Get the dictionary representation of this build metadata. + + :return: The dictionary representation of this build metadata. This + should be a valid JSON object (call to |json.load| succeeds). + + .. seealso:: |bm.as_json|, |json.load|, |bm.load_dict| + """ # noqa RST304 + return { + self.ATTR_INVOCATION_ID: self.invocation_id, + self.ATTR_STARTED_ON: self.started_on.strftime(self.TIMESTAMP_FORMAT), + self.ATTR_FINISHED_ON: self.finished_on.strftime(self.TIMESTAMP_FORMAT), + } + + def as_json(self) -> str: + """Get the representation of this build metadata as a JSON string. + + The dictionary representing this build metadata (as returned by + |bm.as_dict|) is turned into a JSON string using |json.dumps| with + *sort_keys* set to **True**. + + .. seealso:: |bm.as_dict|, |bm.load_json| + """ # noqa RST304 + return json.dumps(self.as_dict(), sort_keys=True) + + @classmethod + def load_dict(cls, initializer: dict[str, Any]) -> BuildMetadata: + """Initialize a build metadata from a dictionary. + + :param initializer: The dictionary to initialize this build metadata + with. + + :return: A build metadata object created from the input *initializer*. + + :raise ValueError: if the invocation ID is not defined in *initializer*, + or if the timestamps are invalid. + :raise TypeError: if the timestamps types are invalid. + + .. seealso:: |bm.as_dict|, |bm.load_json| + """ # noqa RST304 + invocation_id: str | None = initializer.get(cls.ATTR_INVOCATION_ID) + if invocation_id is None: + raise ValueError("Invalid invocation ID (None)") + + # Transform timestamp strings to datetime objects. + + started_on: datetime = date_parser.parse( + initializer.get(cls.ATTR_STARTED_ON, "") + ) + finished_on: datetime = date_parser.parse( + initializer.get(cls.ATTR_FINISHED_ON, "") + ) + + build_metadata: BuildMetadata = cls( + invocation_id=invocation_id, + started_on=started_on, + finished_on=finished_on, + ) + + return build_metadata + + @classmethod + def load_json(cls, initializer: str) -> BuildMetadata: + """Initialize a build metadata from a JSON string. + + :param initializer: The JSON string to initialize this build metadata + with. + + :return: A build metadata object created from the input *initializer*. + + :raise ValueError: if the build ID is not defined in *initializer*. + + .. seealso:: |builder.as_json|, |builder.load_dict| + """ # noqa RST304 + return cls.load_dict(json.loads(initializer)) + + # --------------------------- Private methods --------------------------- # + + @staticmethod + def __validate_timestamp(timestamp: datetime) -> datetime: + """Validate a timestamp.""" + valid_timestamp: datetime + if isinstance(timestamp, datetime): + # When converting to JSON representation, the microseconds + # are lost. Just remove them. + valid_timestamp = timestamp.utcnow().replace( + microsecond=0, tzinfo=timezone.utc + ) + else: + raise TypeError(f"Invalid timestamp type {type(timestamp)}") + + return valid_timestamp + + +class Statement(object): + """SLSA statement object. + + The Statement is the middle layer of the attestation, binding it to a + particular subject and unambiguously identifying the types of the + |Predicate|. + + :param statement_type: The URI identifier for the schema of the Statement. + :param subject: Set of software artifacts that the attestation applies to. + :param predicate_type: URI identifying the type of *predicate*. + :param predicate: Additional parameters of the |Predicate|. + + .. |Predicate| replace:: :class:`Predicate` + .. |predicateType| replace:: :attr:`~Predicate.predicatType` + .. |st.as_dict| replace:: :meth:`~Statement.as_dict` + .. |st.as_json| replace:: :meth:`~Statement.as_json` + .. |st.load_dict| replace:: :meth:`~Statement.load_dict` + .. |st.load_json| replace:: :meth:`~Statement.load_json` + """ # noqa RST304 + + ATTR_PREDICATE: str = "predicate" + ATTR_PREDICATE_TYPE: str = "predicateType" + ATTR_SUBJECT: str = "subject" + ATTR_TYPE: str = "_type" + + SCHEMA_TYPE_VALUE: str = "https://in-toto.io/Statement/v1" + PREDICATE_TYPE_VALUE: str = "https://slsa.dev/provenance/v1" + + def __init__( + self, + statement_type: TypeURI | str, + subject: list[ResourceDescriptor], + predicate_type: TypeURI | str = PREDICATE_TYPE_VALUE, + predicate: Predicate | None = None, + ) -> None: + self.__type: TypeURI = ( + TypeURI(statement_type) + if isinstance(statement_type, str) + else statement_type + ) + self.__subject: list[ResourceDescriptor] = subject + self.__predicate_type: TypeURI = ( + TypeURI(predicate_type) + if isinstance(predicate_type, str) + else predicate_type + ) + self.__predicate: Predicate | None = predicate + + def __eq__(self, other: object) -> bool: + """Check if this statement is equal to *other*. + + :param other: The statement object to compare this with. + + :return: A |bool| set to **True** if both statements are equal, + **False** else. + """ # noqa RST304 + if isinstance(other, self.__class__): + return self.as_json() == other.as_json() + return False + + @property + def type(self) -> TypeURI: + """Identifier for the schema of the Statement. + + Always https://in-toto.io/Statement/v1 for this version of the spec. + """ + return self.__type + + @property + def predicate(self) -> Predicate | None: + """Additional parameters of the |Predicate|. + + Unset is treated the same as set-but-empty. **MAY** be omitted if + |predicateType| fully describes the predicate. + """ # noqa RST304 + return self.__predicate + + @property + def predicate_type(self) -> TypeURI: + """URI identifying the type of the |Predicate|.""" # noqa RST304 + return self.__predicate_type + + @property + def subject(self) -> list[ResourceDescriptor]: + """Set of software artifacts that the attestation applies to. + + Each element represents a single software artifact. Each element + **MUST** have digest set. + + The name field may be used as an identifier to distinguish this artifact + from others within the subject. Similarly, other |ResourceDescriptor| + fields may be used as required by the context. The semantics are up to + the producer and consumer and they **MAY** use them when evaluating + policy. + + If the name is not meaningful, leave the field unset or use ``_``. For + example, a SLSA Provenance attestation might use the name to specify + output filename, expecting the consumer to only consider entries with a + particular name. Alternatively, a vulnerability scan attestation might + leave name unset because the results apply regardless of what the + artifact is named. + + If set, name and uri **SHOULD** be unique within subject. + + .. warning:: Subject artifacts are matched purely by digest, regardless + of content type. If this matters to you, please comment on + `GitHub Issue #28 + `_ + """ # noqa RST304 + return self.__subject + + def as_dict(self) -> dict: + """Get the dictionary representation of this statement. + + :return: The dictionary representation of this statement. This should + be a valid JSON object (call to |json.load| succeeds). + + .. seealso:: |json.load| + """ # noqa RST304 + return { + self.ATTR_TYPE: str(self.type), + self.ATTR_SUBJECT: [subject.as_dict() for subject in self.subject], + self.ATTR_PREDICATE_TYPE: str(self.predicate_type), + self.ATTR_PREDICATE: self.predicate.as_dict() if self.predicate else None, + } + + def as_json(self) -> str: + """Get the representation of this statement as a JSON string. + + The dictionary representing this statement (as returned by + |st.as_dict|) is turned into a JSON string using |json.dumps| with + *sort_keys* set to **True**. + + .. seealso:: |st.as_dict|, |st.load_json| + """ # noqa RST304 + return json.dumps(self.as_dict(), sort_keys=True) + + @classmethod + def load_dict(cls, initializer: dict[str, Any]) -> Statement: + """Initialize a statement from a dictionary. + + :param initializer: The dictionary to initialize this statement with. + + :return: A statement object created from the input *initializer*. + + :raise ValueError: if the statement type is not defined in + *initializer*. + + .. seealso:: |st.as_dict|, |st.load_json| + """ # noqa RST304 + statement_type: str | None = initializer.get(cls.ATTR_TYPE) + if statement_type is None: + raise ValueError("Invalid statement type (None)") + + predicate: dict = initializer.get(cls.ATTR_PREDICATE, {}) + + statement: Statement = cls( + statement_type=statement_type, + subject=[ + ResourceDescriptor.load_dict(rd) + for rd in initializer.get(cls.ATTR_SUBJECT, []) + ], + predicate_type=initializer.get( + cls.ATTR_PREDICATE_TYPE, cls.PREDICATE_TYPE_VALUE + ), + predicate=Predicate.load_dict(predicate) if predicate else None, + ) + + return statement + + @classmethod + def load_json(cls, initializer: str) -> Statement: + """Initialize a statement from a JSON string. + + :param initializer: The JSON string to initialize this statement with. + + :return: A statement object created from the input *initializer*. + + :raise ValueError: if the statement type is not defined in + *initializer*. + + .. seealso:: |st.as_json|, |st.load_dict| + """ # noqa RST304 + return cls.load_dict(json.loads(initializer)) + + +class ResourceDescriptor(object): + """Resource descriptor object. + + A size-efficient description of any software artifact or resource (mutable + or immutable). + + Though all fields are optional, a |ResourceDescriptor| **MUST** specify one + of |uri|, |digest| or |content| at a minimum. + + Further, a context that uses the |ResourceDescriptor| can require one + or more fields. For example, a predicate **MAY** require the name and digest + fields. + + :param uri: see |uri| + :param digest: see |digest| + :param name: see |name| + :param download_location: see |download_location| + :param media_type: see |media_type| + :param content: see |content| + :param resource_annotations: see |annotations| + + .. note:: Those requirements cannot override the minimum requirement of one + of |uri|, |digest|, or |content| specified here. + + .. |annotations| replace:: :attr:`~ResourceDescriptor.annotations` + .. |rd.as_dict| replace:: :meth:`~ResourceDescriptor.as_dict` + .. |rd.as_json| replace:: :meth:`~ResourceDescriptor.as_json` + .. |content| replace:: :attr:`~ResourceDescriptor.content` + .. |digest| replace:: :attr:`~ResourceDescriptor.digest` + .. |download_location| replace:: + :attr:`~ResourceDescriptor.download_location` + .. |is_valid| replace:: :attr:`~ResourceDescriptor.is_valid` + .. |rd.load_dict| replace:: :meth:`~ResourceDescriptor.load_dict` + .. |rd.load_json| replace:: :meth:`~ResourceDescriptor.load_json` + .. |media_type| replace:: :attr:`~ResourceDescriptor.media_type` + .. |name| replace:: :attr:`~ResourceDescriptor.name` + .. |uri| replace:: :attr:`~ResourceDescriptor.uri` + """ # noqa RST304 + + ATTR_ANNOTATIONS: str = "annotations" + ATTR_CONTENT: str = "content" + ATTR_DIGEST: str = "digest" + ATTR_DOWNLOAD_LOCATION: str = "downloadLocation" + ATTR_MEDIA_TYPE: str = "mediaType" + ATTR_NAME: str = "name" + ATTR_URI: str = "uri" + + # Order of attributes is taken out of the schema at + # https://slsa.dev/spec/v1.0/provenance + ATTRIBUTES: tuple = ( + ATTR_URI, + ATTR_DIGEST, + ATTR_NAME, + ATTR_DOWNLOAD_LOCATION, + ATTR_MEDIA_TYPE, + ATTR_CONTENT, + ATTR_ANNOTATIONS, + ) + """JSON attributes returned by the |rd.as_dict| method (if the given attribute + defines a value). + """ # noqa RST304 + + def __init__( + self, + uri: ResourceURI | str | None = None, + digest: dict[str, str] | None = None, + name: str | None = None, + download_location: ResourceURI | str | None = None, + media_type: str | None = None, + content: bytes | None = None, + resource_annotations: dict[str, Any] | None = None, + ) -> None: + self.__annotations: dict[str, Any] = resource_annotations or {} + self.__content: bytes | None = content + self.__digest: dict[str, str] = digest if digest is not None else {} + self.__download_location: ResourceURI | None = None + self.__media_type: str | None = media_type + self.__name: str | None = name + self.__uri: ResourceURI | None = None + + # Use the attribute setters for potential conversion. + if download_location: + self.download_location = download_location # type: ignore[assignment] + if uri: + self.uri = uri # type: ignore[assignment] + + def __eq__(self, other: object) -> bool: + """Check if this resource descriptor is equal to *other*. + + :param other: The resource descriptor object to compare this with. + + :return: A |bool| set to **True** if both resource descriptors are + equal, **False** else. + """ # noqa RST304 + if isinstance(other, self.__class__): + return self.as_json() == other.as_json() + return False + + @property + def annotations(self) -> dict[str, Any]: + """Resource descriptor additional information. + + This field MAY be used to provide additional information or metadata + about the resource or artifact that may be useful to the consumer when + evaluating the attestation against a policy. + + For maximum flexibility annotations may be any mapping from a field name + to any JSON value (string, number, object, array, boolean or null). + + The producer and consumer **SHOULD** agree on the semantics, and + acceptable fields and values in the annotations map. + + Producers **SHOULD** follow the same naming conventions for annotation + fields as for extension fields. + """ + return self.__annotations + + @annotations.setter + def annotations(self, value: dict[str, Any]) -> None: + if isinstance(value, dict): + self.__annotations = value + else: + raise TypeError(f"Invalid resource descriptor annotations type: {value}") + + @property + def content(self) -> bytes | None: + """The contents of the resource or artifact. + + This field is **REQUIRED** unless either |uri| or |digest| is set. + + The producer **MAY** use this field in scenarios where including the + contents of the resource/artifact directly in the attestation is deemed + more efficient for consumers than providing a pointer to another + location. + + To maintain size efficiency, the size of content **SHOULD** be less than + 1KB. + + The semantics are up to the producer and consumer. The |uri| or + |media_type| **MAY** be used by the producer as hints for how consumers + should parse content. + + :raise TypeError: When setting this field to something else than a + |bytes| or ``None``. + """ # noqa RST304 + return self.__content + + @content.setter + def content(self, value: bytes | None) -> None: + if isinstance(value, bytes) or value is None: + self.__content = value + else: + raise TypeError(f"Invalid resource descriptor content type: {value}") + + @property + def digest(self) -> dict[str, str]: + """A set of digests for this resource descriptor. + + A set of cryptographic digests of the contents of the resource or + artifact. + + This field is **REQUIRED** unless either |uri|, or |content| is set. + + When known, the producer **SHOULD** set this field to denote an + immutable artifact or resource. + + The producer and consumer **SHOULD** agree on acceptable algorithms. + + :raise TypeError: When setting this field to something else than a + |dict|. + """ # noqa RST304 + return self.__digest + + @digest.setter + def digest(self, value: dict[str, str]) -> None: + if isinstance(value, dict): + self.__digest = value + else: + raise TypeError(f"Invalid resource descriptor digest type: {value}") + + @property + def download_location(self) -> ResourceURI | None: + """Artifact download location. + + The location of the described resource or artifact, if different from + the |uri|. + + To enable automated downloads by consumers, the specified location + **SHOULD** be resolvable. + + :raise TypeError: When setting this field to something else than a + |ResourceURI| or ``None``. + """ # noqa RST304 + return self.__download_location + + @download_location.setter + def download_location(self, value: ResourceURI | str | None) -> None: + if isinstance(value, ResourceURI) or value is None: + self.__download_location = value + elif isinstance(value, str): + self.__download_location = ResourceURI(value) + else: + raise TypeError( + f"Invalid resource descriptor download location type: {value}" + ) + + @property + def is_valid(self) -> bool: + """Check if this resource descriptor is valid. + + To be valid, a resource descriptor should define at least one of the + following: + + - |content| + - |digest| + - |uri| + + :return: A |bool| set to **True** if at least one of the above-mentioned + field is defined, **False** else. + """ # noqa RST304 + if self.uri or self.content or self.digest: + return True + return False + + @property + def name(self) -> str | None: + """Machine-readable identifier for distinguishing between descriptors. + + The semantics are up to the producer and consumer. The |name| name + **SHOULD** be stable, such as a filename, to allow consumers to reliably + use the |name| as part of their policy. + """ # noqa RST304 + return self.__name + + @name.setter + def name(self, value: str | None) -> None: + if isinstance(value, str) or value is None: + self.__name = value + else: + raise TypeError(f"Invalid resource descriptor name: {value}") + + @property + def media_type(self) -> str | None: + """This resource descriptor media type. + + The `MIME Type + `_ + (i.e., media type) of the described resource or artifact. + + For resources or artifacts that do not have a standardized MIME type, + producers **SHOULD** follow `RFC 6838 (Sections 3.2-3.4) + `_ conventions + of prefixing types with ``x.``, ``prs.``, or ``vnd.`` to avoid + collisions with other producers. + """ + return self.__media_type + + @media_type.setter + def media_type(self, value: str | None) -> None: + if isinstance(value, str) or value is None: + self.__media_type = value + else: + raise TypeError(f"Invalid resource descriptor media type: {value}") + + @property + def uri(self) -> ResourceURI | None: + """A URI used to identify the resource or artifact globally. + + This field is **REQUIRED** unless either digest or content is set. + """ + return self.__uri + + @uri.setter + def uri(self, value: ResourceURI | str | None) -> None: + if isinstance(value, ResourceURI) or value is None: + self.__uri = value + elif isinstance(value, str): + self.__uri = ResourceURI(value) + else: + raise TypeError(f"Invalid resource descriptor uri type: {value}") + + def add_digest(self, algorithm: str, digest: str) -> None: + """Add a new digest to the digest set. + + :param algorithm: The algorithm the new digest has been computed with. + :param digest: The new digest to add to the diest set. + + :raise KeyError: if *algorithm* already defines a digest in the current + digest set. + """ + if algorithm not in self.__digest: + self.__digest[algorithm] = digest + else: + raise KeyError( + f"Digest algorithm {algorithm} is already set to " + "{self.__digest[algorithm]}" + ) + + def as_dict(self) -> dict: + """Get the dictionary representation of this resource descriptor. + + :return: The dictionary representation of this resource descriptor. + This should be a valid JSON object. + + :raise ValueError: If this resource descriptor is not valid (see + |is_valid|). + + .. seealso:: |rd.as_json|, |is_valid|, |rd.load_dict| + """ # noqa RST304 + if not self.is_valid: + raise ValueError( + "Invalid resource descriptor. Either uri, content or digest " + "should be defined." + ) + + return { + self.ATTR_URI: str(self.uri) if self.uri is not None else None, + self.ATTR_DIGEST: self.digest, + self.ATTR_NAME: self.name, + self.ATTR_DOWNLOAD_LOCATION: str(self.download_location) + if self.download_location is not None + else None, + self.ATTR_MEDIA_TYPE: self.media_type, + self.ATTR_CONTENT: None + if self.content is None + else base64.b64encode(self.content).decode("utf-8"), + self.ATTR_ANNOTATIONS: self.annotations, + } + + def as_json(self) -> str: + """Get the representation of this resource descriptor as a JSON string. + + The dictionary representing this resource descriptor (as returned by + |rd.as_dict|) is turned into a JSON string using |json.dumps| with + *sort_keys* set to **True**. + + .. seealso:: |rd.as_dict|, |rd.load_json| + """ # noqa RST304 + return json.dumps(self.as_dict(), sort_keys=True) + + @staticmethod + def dir_hash(path: Path, algorithm: str) -> str: + r"""Directory hash. + + The `directory Hash1 + `_ + function, omitting the ``h1:`` prefix and output in lowercase + hexadecimal instead of base64. + + This algorithm was designed for go modules but can be used to digest the + contents of an arbitrary archive or file tree. + + Equivalent to extracting the archive to an empty directory and running + the following command in that directory:: + + find . -type f | cut -c3- | LC_ALL=C sort | xargs -r sha256sum \\ + | sha256sum | cut -f1 -d' ' + + For example, the module dirhash + ``h1:Khu2En+0gcYPZ2kuIihfswbzxv/mIHXgzPZ018Oty48=`` would be encoded as + ``{"dirHash1": + "2a1bb6127fb481c60f67692e22285fb306f3c6ffe62075e0ccf674d7c3adcb8f"}``. + """ + # First check that there is a valid algorithm for dir_hash. + if algorithm not in hashlib.algorithms_guaranteed: + raise ValueError( + f"Unsupported digest algorithm {algorithm} for dir_hash().\n" + f"Available algorithms are: {hashlib.algorithms_guaranteed}" + ) + + need_length: bool = algorithm.startswith("shake_") + folder_hash = getattr(hashlib, algorithm)() + + # List files in path, and read them 4096 bytes per 4096 bytes to + # compute a global has. + for filepath in sorted(path.rglob("*")): + if filepath.is_file(): + file_hash = getattr(hashlib, algorithm)() + with filepath.open("rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + file_hash.update(chunk) + + rel_path: str = f"{filepath.relative_to(path).as_posix()}" + hash_str: str + if need_length: + hash_str = f"{file_hash.hexdigest(64)} {rel_path}\n" + else: + hash_str = f"{file_hash.hexdigest()} {rel_path}\n" + + folder_hash.update(hash_str.encode("utf-8")) + + # Compute the final hash. + final_hash: str + if need_length: + final_hash = f"{folder_hash.hexdigest(64)}" + else: + final_hash = f"{folder_hash.hexdigest()}" + + return final_hash + + @classmethod + def load_dict(cls, initializer: dict[str, Any]) -> ResourceDescriptor: + """Initialize a resource descriptor from a dictionary. + + :param initializer: The dictionary to initialize this resource + descriptor with. + + :return: A resource descriptor object created from the input + *initializer*. + + :raise ValueError: If this resource descriptor is not valid once + initialized with the dictionary content (see |is_valid|). + + .. seealso:: |rd.as_dict|, |is_valid| + """ # noqa RST304 + content: str = initializer.get(cls.ATTR_CONTENT, "") + resource_descriptor: ResourceDescriptor = cls( + uri=initializer.get(cls.ATTR_URI), + digest=initializer.get(cls.ATTR_DIGEST), + name=initializer.get(cls.ATTR_NAME), + download_location=initializer.get(cls.ATTR_DOWNLOAD_LOCATION), + media_type=initializer.get(cls.ATTR_MEDIA_TYPE), + content=base64.b64decode(content.encode("utf-8")) if content else None, + resource_annotations=initializer.get(cls.ATTR_ANNOTATIONS), + ) + + if not resource_descriptor.is_valid: + raise ValueError( + "Invalid resource descriptor. Either uri, content or digest " + "should be defined." + ) + + return resource_descriptor + + @classmethod + def load_json(cls, initializer: str) -> ResourceDescriptor: + """Initialize a resource descriptor from a JSON string. + + :param initializer: The JSON string to initialize this resource + descriptor with. + + :return: A resource descriptor object created from the input + *initializer*. + + :raise ValueError: If this resource descriptor is not valid once + initialized with the dictionary content (see |is_valid|). + + .. seealso:: |rd.load_dict|, |is_valid| + """ # noqa RST304 + return cls.load_dict(json.loads(initializer)) + + +class Predicate(object): + """Predicate object. + + .. |p.as_dict| replace:: :meth:`as_dict` + .. |p.as_json| replace:: :meth:`as_json` + .. |p.load_dict| replace:: :meth:`load_dict` + .. |p.load_json| replace:: :meth:`load_json` + """ # noqa RST304 + + ATTR_BUILD_DEFINITION: str = "buildDefinition" + ATTR_RUN_DETAILS: str = "runDetails" + + class BuildDefinition(object): + """The BuildDefinition describes all the inputs to the build. + + It **SHOULD** contain all the information necessary and sufficient to + initialize the build and begin execution. + + The |externalParameters| and |internalParameters| are the top-level + inputs to the template, meaning inputs not derived from another input. + Each is an arbitrary JSON object, though it is **RECOMMENDED** to keep + the structure simple with string values to aid verification. + + The same field name **SHOULD NOT** be used for both |externalParameters| + and |internalParameters|. + + The parameters **SHOULD** only contain the actual values passed in + through the interface to the build platform. + + Metadata about those parameter values, particularly digests of + artifacts referenced by those parameters, **SHOULD** instead go in + |resolvedDependencies|. + + The documentation for |buildType| **SHOULD** explain how to convert + from a parameter to the dependency uri. For example:: + + "externalParameters": { + "repository": "https://github.com/octocat/hello-world", + "ref": "refs/heads/main" + }, + "resolvedDependencies": [{ + "uri": "git+https://github.com/octocat/hello-world@refs/heads/main", + "digest": {"gitCommit": "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d"} + }] + + + Guidelines + ---------- + + - Maximize the amount of information that is implicit from the meaning + of |buildType|. In particular, any value that is boilerplate and the + same for every build **SHOULD** be implicit. + - Reduce parameters by moving configuration to input artifacts whenever + possible. For example, instead of passing in compiler flags via an + external parameter that has to be verified separately, require the + flags to live next to the source code or build configuration so that + verifying the latter automatically verifies the compiler flags. + - In some cases, additional external parameters might exist that do not + impact the behavior of the build, such as a deadline or priority. + These extra parameters **SHOULD** be excluded from the provenance + after careful analysis that they indeed pose no security impact. + - If possible, architect the build platform to use this definition as + its sole top-level input, in order to guarantee that the information + is sufficient to run the build. + - When build configuration is evaluated client-side before being sent + to the server, such as transforming version-controlled YAML into + ephemeral JSON, some solution is needed to make verification + practical. Consumers need a way to know what configuration is + expected and the usual way to do that is to map it back to version + control, but that is not possible if the server cannot verify the + configuration’s origins. Possible solutions: + + - (**RECOMMENDED**) Rearchitect the build platform to read + configuration directly from version control, recording the + server-verified URI in |externalParameters| and the digest in + |resolvedDependencies|. + - Record the digest in the provenance and use a separate provenance + attestation to link that digest back to version control. In this + solution, the client-side evaluation is considered a separate + *build* that **SHOULD** be independently secured using |SLSA|, + though securing it can be difficult since it usually runs on an + untrusted workstation. + + - The purpose of |resolvedDependencies| is to facilitate recursive + analysis of the software supply chain. Where practical, it is + valuable to record the URI and digest of artifacts that, if + compromised, could impact the build. At |SLSA| Build L3, completeness + is considered *best effort*. + + .. |buildType| replace:: :attr:`build_type` + .. |bd.as_dict| replace:: :meth:`as_dict` + .. |bd.as_json| replace:: :meth:`as_json` + .. |externalParameters| replace:: :attr:`external_parameters` + .. |internalParameters| replace:: :attr:`internal_parameters` + .. |bd.load_dict| replace:: :meth:`load_dict` + .. |bd.load_json| replace:: :meth:`load_json` + .. |resolvedDependencies| replace:: :attr:`resolved_dependencies` + """ # noqa RST304 + + ATTR_BUILD_TYPE: str = "buildType" + ATTR_EXTERNAL_PARAMETERS: str = "externalParameters" + ATTR_INTERNAL_PARAMETERS: str = "internalParameters" + ATTR_RESOLVED_DEPENDENCIES: str = "resolvedDependencies" + + def __init__( + self, + build_type: TypeURI | str, + external_parameters: object, + internal_parameters: object, + resolved_dependencies: list[ResourceDescriptor], + ) -> None: + self.__build_type: TypeURI = ( + TypeURI(build_type) if isinstance(build_type, str) else build_type + ) + self.__external_parameters: object = external_parameters + self.__internal_parameters: object = internal_parameters + self.__resolved_dependencies: list[ + ResourceDescriptor + ] = resolved_dependencies + + def __eq__(self, other: object) -> bool: + """Check if this build definition is equal to *other*. + + :param other: The build definition object to compare this with. + + :return: A |bool| set to **True** if both build definitions are + equal, **False** else. + """ # noqa RST304 + if isinstance(other, self.__class__): + return self.as_json() == other.as_json() + return False + + @property + def build_type(self) -> TypeURI | None: + """Predicate build type. + + Identifies the template for how to perform the build and interpret + the parameters and dependencies. + + The URI **SHOULD** resolve to a human-readable specification that + includes: + + - overall description of the build type + - schema for externalParameters and internalParameters + - unambiguous instructions for how to initiate the build given this + BuildDefinition, and a complete example. + + Example + ------- + :: + + https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1 + """ + return self.__build_type + + @property + def external_parameters(self) -> object | None: + """The parameters that are under external control. + + Such as those set by a user or tenant of the build platform. + They **MUST** be complete at SLSA Build L3, meaning that there is no + additional mechanism for an external party to influence the build. + (At lower SLSA Build levels, the completeness **MAY** be best + effort.) + + The build platform **SHOULD** be designed to minimize the size and + complexity of ``externalParameters``, in order to reduce fragility + and ease verification. + + Consumers **SHOULD** have an expectation of what **good** looks + like; the more information that they need to check, the harder that + task becomes. + + Verifiers **SHOULD** reject unrecognized or unexpected fields within + ``externalParameters``. + """ + return self.__external_parameters + + @property + def internal_parameters(self) -> object | None: + """Internal parameters. + + The parameters that are under the control of the entity represented + by ``builder.id``. + + The primary intention of this field is for debugging, incident + response, and vulnerability management. + + The values here **MAY** be necessary for reproducing the build. + + There is no need to verify these parameters because the build + platform is already trusted, and in many cases it is not practical + to do so. + """ + return self.__internal_parameters + + @property + def resolved_dependencies(self) -> list[ResourceDescriptor]: + """Unordered collection of artifacts needed at build time. + + Completeness is best effort, at least through SLSA Build L3. + + For example, if the build script fetches and executes + ``example.com/foo.sh``, which in turn fetches + ``example.com/bar.tar.gz``, then both ``foo.sh`` and ``bar.tar.gz`` + **SHOULD** be listed here. + """ + return self.__resolved_dependencies + + def as_dict(self) -> dict: + """Get the dictionary representation of this build definition. + + :return: The dictionary representation of this build definition. + This should be a valid JSON object (call to |json.load| + succeeds). + + .. seealso:: |bd.as_json|, |json.load|, |bd.load_dict| + """ # noqa RST304 + return { + self.ATTR_BUILD_TYPE: str(self.build_type) if self.build_type else None, + self.ATTR_EXTERNAL_PARAMETERS: self.external_parameters, + self.ATTR_INTERNAL_PARAMETERS: self.internal_parameters, + self.ATTR_RESOLVED_DEPENDENCIES: [ + rd.as_dict() for rd in self.resolved_dependencies + ], + } + + def as_json(self) -> str: + """Get the representation of this build definition as a JSON string. + + The dictionary representing this build defintion (as returned by + |bd.as_dict|) is turned into a JSON string using |json.dumps| with + *sort_keys* set to **True**. + + .. seealso:: |bd.as_dict|, |bd.load_json| + """ # noqa RST304 + return json.dumps(self.as_dict(), sort_keys=True) + + @classmethod + def load_dict(cls, initializer: dict[str, Any]) -> Predicate.BuildDefinition: + """Initialize a build definition from a dictionary. + + :param initializer: The dictionary to initialize this build + definition with. + + :return: A build definition object created from the input + *initializer*. + + .. seealso:: |bd.as_dict|, |bd.load_json| + """ # noqa RST304 + build_definition: Predicate.BuildDefinition = cls( + build_type=initializer.get(cls.ATTR_BUILD_TYPE, ""), + external_parameters=initializer.get(cls.ATTR_EXTERNAL_PARAMETERS), + internal_parameters=initializer.get(cls.ATTR_INTERNAL_PARAMETERS), + resolved_dependencies=[ + ResourceDescriptor.load_dict(rd) + for rd in initializer.get(cls.ATTR_RESOLVED_DEPENDENCIES, []) + ], + ) + + return build_definition + + @classmethod + def load_json(cls, initializer: str) -> Predicate.BuildDefinition: + """Initialize a build definition from a JSON string. + + :param initializer: The JSON string to initialize this build + definition with. + + :return: A build definition object created from the input + *initializer*. + + .. seealso:: |bd.as_json|, |bd.load_dict| + """ # noqa RST304 + return cls.load_dict(json.loads(initializer)) + + class RunDetails(object): + """Details specific to this particular execution of the build. + + :param builder: Run details builder description. + :param metadata: The metadata for this run details object. + :param by_products: Run details additional artifacts. + + .. |rund.as_dict| replace:: :meth:`as_dict` + .. |rund.as_json| replace:: :meth:`as_json` + .. |rund.load_dict| replace:: :meth:`load_dict` + .. |rund.load_json| replace:: :meth:`load_json` + """ # noqa RST304 + + ATTR_BUILDER: str = "builder" + ATTR_METADATA: str = "metadata" + ATTR_BY_PRODUCTS: str = "byproducts" + + def __init__( + self, + builder: Builder, + metadata: BuildMetadata, + by_products: list[ResourceDescriptor], + ) -> None: + self.__builder: Builder = builder + self.__metadata: BuildMetadata = metadata + self.__by_products: list[ResourceDescriptor] = by_products + + def __eq__(self, other: object) -> bool: + """Check if this run details object is equal to *other*. + + :param other: The run details object to compare this with. + + :return: A |bool| set to **True** if both run details are equal, + **False** else. + """ # noqa RST304 + if isinstance(other, self.__class__): + return self.as_json() == other.as_json() + return False + + @property + def builder(self) -> Builder: + """Run details builder. + + Identifies the build platform that executed the invocation, which + is trusted to have correctly performed the operation and populated + this provenance. + """ + return self.__builder + + @property + def by_products(self) -> list[ResourceDescriptor]: + """Run details additional artifacts. + + Additional artifacts generated during the build that are not + considered the **output** of the build but that might be needed + during debugging or incident response. + + For example, this might reference logs generated during the build + and/or a digest of the fully evaluated build configuration. + + In most cases, this **SHOULD NOT** contain all intermediate files + generated during the build. + + Instead, this **SHOULD** only contain files that are likely to be + useful later and that cannot be easily reproduced. + """ + return self.__by_products + + @property + def metatdata(self) -> BuildMetadata: + """Run details build metadata. + + Metadata about this particular execution of the build. + """ + return self.__metadata + + def as_dict(self) -> dict: + """Get the dictionary representation of this run details. + + :return: The dictionary representation of this run details. This + should be a valid JSON object (call to |json.load| succeeds). + + .. seealso:: |rund.as_json|, |json.load|, |rund.load_dict| + """ # noqa RST304 + return { + self.ATTR_BUILDER: self.builder.as_dict(), + self.ATTR_METADATA: self.metatdata.as_dict(), + self.ATTR_BY_PRODUCTS: [rd.as_dict() for rd in self.by_products], + } + + def as_json(self) -> str: + """Get the representation of this run details as a JSON string. + + The dictionary representing this run details (as returned by + |rund.as_dict|) is turned into a JSON string using |json.dumps| with + *sort_keys* set to **True**. + + .. seealso:: |rund.as_dict|, |rund.load_json| + """ # noqa RST304 + return json.dumps(self.as_dict(), sort_keys=True) + + @classmethod + def load_dict(cls, initializer: dict[str, Any]) -> Predicate.RunDetails: + """Initialize a run details from a dictionary. + + :param initializer: The dictionary to initialize this run details + with. + + :return: A run details object created from the input *initializer*. + + .. seealso:: |rund.as_dict|, |rund.load_json| + """ # noqa RST304 + builder: dict | None = initializer.get(cls.ATTR_BUILDER) + metadata: dict | None = initializer.get(cls.ATTR_METADATA) + by_products: list[dict] = initializer.get(cls.ATTR_BY_PRODUCTS, []) + + # builder and metadata should be required. + + if builder is None: + raise ValueError("Missing builder definition.") + + if metadata is None: + raise ValueError("Missing metadata definition.") + + run_details: Predicate.RunDetails = cls( + builder=Builder.load_dict(builder), + metadata=BuildMetadata.load_dict(metadata), + by_products=[ResourceDescriptor.load_dict(rd) for rd in by_products], + ) + + return run_details + + @classmethod + def load_json(cls, initializer: str) -> Predicate.RunDetails: + """Initialize a run details from a JSON string. + + :param initializer: The JSON string to initialize this run details + with. + + :return: A run details object created from the input *initializer*. + + .. seealso:: |rund.as_json|, |rund.load_dict| + """ # noqa RST304 + return cls.load_dict(json.loads(initializer)) + + def __init__( + self, + build_definition: Predicate.BuildDefinition, + run_details: Predicate.RunDetails, + ) -> None: + self.__build_definition: Predicate.BuildDefinition = build_definition + self.__run_details: Predicate.RunDetails = run_details + + def __eq__(self, other: object) -> bool: + """Check if this predicate is equal to *other*. + + :param other: The predicate object to compare this with. + + :return: A |bool| set to **True** if both predicates are equal, + **False** else. + """ # noqa RST304 + if isinstance(other, self.__class__): + return self.as_json() == other.as_json() + return False + + @property + def build_definition(self) -> Predicate.BuildDefinition: + """The input to the build. + + The accuracy and completeness are implied by runDetails.builder.id. + """ + return self.__build_definition + + @property + def run_details(self) -> Predicate.RunDetails: + """Details specific to this particular execution of the build.""" + return self.__run_details + + def as_dict(self) -> dict: + """Get the dictionary representation of this predicate. + + :return: The dictionary representation of this statement. This should + be a valid JSON object (call to |json.load| succeeds). + + .. seealso:: |p.as_json|, |json.load|, |p.load_dict| + """ # noqa RST304 + return { + self.ATTR_BUILD_DEFINITION: self.build_definition.as_dict(), + self.ATTR_RUN_DETAILS: self.run_details.as_dict(), + } + + def as_json(self) -> str: + """Get the representation of this predicate as a JSON string. + + The dictionary representing this predicate (as returned by + |p.as_dict|) is turned into a JSON string using |json.dumps| with + *sort_keys* set to **True**. + + .. seealso:: |p.as_dict|, |p.load_json| + """ # noqa RST304 + return json.dumps(self.as_dict(), sort_keys=True) + + @classmethod + def load_dict(cls, initializer: dict[str, Any]) -> Predicate: + """Initialize a predicate from a dictionary. + + :param initializer: The dictionary to initialize this predicat with. + + :return: A predicate object created from the input *initializer*. + + .. seealso:: |p.as_dict|, |p.load_json| + """ # noqa RST304 + build_definition: dict | None = initializer.get(cls.ATTR_BUILD_DEFINITION) + run_details: dict | None = initializer.get(cls.ATTR_RUN_DETAILS) + + # All fields should be mandatory. + + if build_definition is None: + raise ValueError("Missing build definition.") + + if run_details is None: + raise ValueError("Missing run details definition.") + + predicate: Predicate = cls( + build_definition=Predicate.BuildDefinition.load_dict(build_definition), + run_details=Predicate.RunDetails.load_dict(run_details), + ) + + return predicate + + @classmethod + def load_json(cls, initializer: str) -> Predicate: + """Initialize a predicate from a JSON string. + + :param initializer: The JSON string to initialize this predicate with. + + :return: A predictae object created from the input *initializer*. + + .. seealso:: |p.as_json|, |p.load_dict| + """ # noqa RST304 + return cls.load_dict(json.loads(initializer)) + + +class TypeURI(object): + """Uniform Resource Identifier as specified in RFC 3986. + + Used as a collision-resistant type identifier. + + Format + ------ + A TypeURI is represented as a case-sensitive string and **MUST** be case + normalized as per section 6.2.2.1 of RFC 3986, meaning that the scheme and + authority **MUST** be in lowercase. + + **SHOULD** resolve to a human-readable description, but **MAY** be + unresolvable. **SHOULD** include a version number to allow for revisions. + + TypeURIs are not registered. The natural namespacing of URIs is sufficient + to prevent collisions. + + Example + ------- + :: + + https://in-toto.io/Statement/v1 + """ + + def __init__(self, uri: str): + # Validate this uri. + from urllib.parse import ParseResult, urlparse + + try: + parsed: ParseResult = urlparse(uri) + if not all([parsed.scheme, parsed.netloc]): + raise ValueError(f"Invalid URI {uri}.") + except ValueError: + raise + except AttributeError as ae: + raise ValueError(f"Invalid URI {uri} : {ae}.") from ae + self.__uri = uri + + def __eq__(self, other: object) -> bool: + """Check if this type uri is equal to *other*.""" + if isinstance(other, TypeURI): + return self.uri == other.uri + elif isinstance(other, str): + return self.uri == other + return False + + def __str__(self) -> str: + """Return the string representation of this TypeURI.""" + return self.uri + + @property + def uri(self) -> str: + """Actual URI of this TypeURI. + + :return: The actual URI of this TypeURI. + """ + return self.__uri + + +class ResourceURI(TypeURI): + """Uniform Resource Identifier as specified in RFC 3986. + + Used to identify and locate any resource, service, or software artifact. + + Format + ------ + A ResourceURI is represented as a case-sensitive string and **MUST** be case + normalized as per section 6.2.2.1 of RFC 3986, meaning that the scheme and + authority **MUST** be in lowercase. + + **SHOULD** resolve to the artifact, but **MAY** be unresolvable. + + It is **RECOMMENDED** to use + `Package URL `_ (``pkg:``) or + `SPDX Download Location + `_ + (e.g. ``git+https:``). + + Example + ------- + :: + + pkg:deb/debian/stunnel@5.50-3?arch=amd64 + """ + + def __init__(self, uri: str): + super(ResourceURI, self).__init__(uri) diff --git a/tests/tests_e3/slsa/__init__.py b/tests/tests_e3/slsa/__init__.py new file mode 100644 index 00000000..679d5253 --- /dev/null +++ b/tests/tests_e3/slsa/__init__.py @@ -0,0 +1 @@ +"""SLSA (Supply-chain Levels for Software Artifacts) tests package.""" diff --git a/tests/tests_e3/slsa/provenance-example.json b/tests/tests_e3/slsa/provenance-example.json new file mode 100644 index 00000000..f131a1c8 --- /dev/null +++ b/tests/tests_e3/slsa/provenance-example.json @@ -0,0 +1,105 @@ +{ + "comment": "Taken out of the Schema on https://slsa.dev/spec/v1.0/provenance", + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "file1.txt", + "digest": {"sha256": "123456789abcdef"} + }, + { + "name": "file2.o", + "digest": {"sha512": "123456789abcdeffedcba987654321"} + }, + { + "name": "out.exe", + "digest": {"md5": "123456789"} + } + ], + + "predicateType": "https://slsa.dev/provenance/v1", + "predicate": { + "buildDefinition": { + "buildType": "https://www.myproduct.org/build", + "externalParameters": [ + { + "option": "-xxx" + }, + { + "out_format": "exe" + } + ], + "internalParameters": [ + { + "env": {"MY_VAR": "my_value"} + } + ], + "resolvedDependencies": [ + { + "uri": "https://github.com/AdaCore/e3-core", + "digest": { + "gitCommit": "f9c158d" + }, + "name": "e3-core", + "downloadLocation": null, + "mediaType": "git", + "content": null, + "annotations": { + "branch": "master" + } + }, + { + "uri": null, + "digest": null, + "name": "config", + "downloadLocation": null, + "mediaType": null, + "content": "eydjb25maWcnOiAnaGVsbG8nfQ==", + "annotations": null + } + ] + }, + "runDetails": { + "builder": { + "id": "https://www.myproduct.org/build/647eda74f5cd7dc1cf55d12b", + "builderDependencies": [ + { + "uri": "https://www.python.org/ftp/python/3.12.0/Python-3.12.0.tgz", + "digest": { + "md5": "d6eda3e1399cef5dfde7c4f319b0596c" + }, + "name": "Python", + "downloadLocation": "https://www.python.org/ftp/python/3.12.0/Python-3.12.0.tgz", + "mediaType": "application/gzip", + "content": null, + "annotations": { + "version": "3.12.0" + } + } + ], + "version": { + "3.12.0": "2023/10/02" + } + }, + "metadata": { + "invocationId": "c47eda74f5cd7dc1cf55d12b", + "startedOn": "2023-10-02T13:39:53Z", + "finishedOn": "2023-10-02T14:59:22Z" + }, + "byproducts": [ + { + "uri": "https://www.myproduct.org", + "digest": { + "md5": "d6eda3e1399caf5dfde7c4f319b0596c" + }, + "name": "My Product", + "downloadLocation": "https://www.myproduct.org/download/my-product.tgz", + "mediaType": "application/gzip", + "content": null, + "annotations": { + "version": "1.7.1" + } + } + ] + } + } +} \ No newline at end of file diff --git a/tests/tests_e3/slsa/provenance_test.py b/tests/tests_e3/slsa/provenance_test.py new file mode 100644 index 00000000..ecd8d263 --- /dev/null +++ b/tests/tests_e3/slsa/provenance_test.py @@ -0,0 +1,1124 @@ +"""SLSA provenance packages tests.""" + +from __future__ import annotations + +import hashlib +import json +import pytest + +from datetime import datetime, timezone +from dateutil import parser as date_parser +from pathlib import Path +from typing import Any + +from e3.slsa.provenance import ( + Builder, + BuildMetadata, + Predicate, + ResourceDescriptor, + ResourceURI, + Statement, + TypeURI, +) + +# Taken out of https://slsa.dev/spec/v1.0/provenance#builddefinition +EXTERNAL_PARAMETERS: dict = { + "repository": "https://github.com/octocat/hello-world", + "ref": "refs/heads/main", +} +INTERNAL_PARAMETERS: dict = {"internal": "parameters"} + +# The following algorithms have been tested with: +# +# find . -type f | cut -c3- | LC_ALL=C sort | xargs -r sha256sum \\ +# | sha256sum | cut -f1 -d' ' + +VALID_DIGESTS: dict[str, str] = { + "blake2b": ( + "0a2293c1133aa5b2bdc84a0c8793db9cc60e8af7bb41acb661dc9c7264d35c8a0" + "4071840a253c4834470e8e87e46655f22a4c7e923f263c44d75734945d509eb" + ), + "md5": "9a2477e53e7a865232aa8c266effdcc6", + "sha1": "2719edd7f69c5769d1a5169d2edfbbe95bca1daa", + "sha224": "5813c0c8f5772b9ff36e17b12d10847f3c8f78e892c6dbaf5a7052e8", + "sha256": "98e967576c9f7401ddf9659fe7fcd8a23bd172ac4206fadec7506fcd1daa3f75", + "sha384": ( + "7be552e5b91d8815e4238cd59ad5d713b34d3150fa03c61d180504c440d4c6cd5" + "29245edee34ec40c5406d447f5553b1" + ), + "sha512": ( + "96afe1503defb47772f70cf830cc77b4836e9bad16c8cb2576149a5ffb5a9ec73" + "a5842951889b2c81405291f931a5f639dbd4e8326c3cba87fc07bfe6f2711f5" + ), +} + +VALID_URIS: list[str] = [ + "scheme://netloc/path;parameters?query#fragment", + "http://docs.python.org:80/3/library/urllib.parse.html?highlight=params#url-parsing", + "https://www.adacore.com/company", +] + + +def create_valid_build_definition() -> tuple: + """Create a valid build definition object.""" + build_type: TypeURI = TypeURI(VALID_URIS[0]) + rd: ResourceDescriptor = create_valid_resource_descriptor()[-1] + resolved_dependencies: list[ResourceDescriptor] = [rd] + + bd = Predicate.BuildDefinition( + build_type=build_type, + external_parameters=EXTERNAL_PARAMETERS, + internal_parameters=INTERNAL_PARAMETERS, + resolved_dependencies=resolved_dependencies, + ) + return build_type, rd, resolved_dependencies, bd + + +def create_valid_build_metadata() -> tuple[datetime, str, datetime, BuildMetadata]: + """Create a valid BuildMetadata object.""" + start_time = datetime.now(timezone.utc) + invocation_id = "invocation id" + finish_time = datetime.now(timezone.utc) + bm = BuildMetadata( + invocation_id=invocation_id, started_on=start_time, finished_on=finish_time + ) + return start_time, invocation_id, finish_time, bm + + +def create_valid_builder() -> ( + tuple[TypeURI, list[ResourceDescriptor], dict[str, str], Builder] +): + """Create a valid builder object. + + :return: A tuple made of: + - The build ID (TypeURI) + - The Builder dependencies (list of ResourceDescriptor) + - The version (dict of str -> str) + - The created valid Builder (Builder) + """ + desc = create_valid_resource_descriptor()[-1] + build_id: TypeURI = TypeURI(VALID_URIS[1]) + builder_dependencies: list[ResourceDescriptor] = [desc] + version: dict[str, str] = {"version1": "value1"} + builder: Builder = Builder( + build_id=build_id, + builder_dependencies=builder_dependencies, + version=version, + ) + return build_id, builder_dependencies, version, builder + + +def create_valid_resource_descriptor() -> tuple: + """Create a valid resource descriptor.""" + # Create a new resource descriptor with all input parameters set. + uri: str = VALID_URIS[0] + digest: dict[str, str] = {"sha256": VALID_DIGESTS["sha256"]} + rc_annotations: dict[str, Any] = {"one": 1, "two": "two"} + name: str = "Resource descriptor" + dl_loc: str = VALID_URIS[1] + media_type: str = "Media Type" + content = "12.34".encode("utf-8") + + desc: ResourceDescriptor = ResourceDescriptor( + uri=uri, + digest=digest, + name=name, + download_location=dl_loc, + media_type=media_type, + content=content, + resource_annotations=rc_annotations, + ) + + return uri, digest, rc_annotations, name, dl_loc, media_type, content, desc + + +def create_valid_run_details() -> tuple: + """Create a valid run details object.""" + builder: Builder = create_valid_builder()[-1] + metadata: BuildMetadata = create_valid_build_metadata()[-1] + desc: ResourceDescriptor = create_valid_resource_descriptor()[-1] + by_products: list[ResourceDescriptor] = [desc] + rd: Predicate.RunDetails = Predicate.RunDetails( + builder=builder, metadata=metadata, by_products=by_products + ) + return builder, metadata, by_products, rd + + +def test_build_definition_as_dict() -> None: + """Test dict representation of build definition.""" + build_type, rd, resolved_dependencies, bd = create_valid_build_definition() + # Check class attributes. + dict_repr = bd.as_dict() + assert dict_repr.get(Predicate.BuildDefinition.ATTR_BUILD_TYPE) == build_type + assert ( + dict_repr.get(Predicate.BuildDefinition.ATTR_EXTERNAL_PARAMETERS) + == EXTERNAL_PARAMETERS + ) + assert ( + dict_repr.get(Predicate.BuildDefinition.ATTR_INTERNAL_PARAMETERS) + == INTERNAL_PARAMETERS + ) + assert ( + dict_repr.get(Predicate.BuildDefinition.ATTR_RESOLVED_DEPENDENCIES)[0] + == rd.as_dict() + ) + + +def test_build_definition_as_json() -> None: + """Test json representation of build definition.""" + bd: Predicate.BuildDefinition = create_valid_build_definition()[-1] + json_repr: str = bd.as_json() + assert json.loads(json_repr) + + +def test_build_definition_init() -> None: + """Test initialization of build definition.""" + # Check class attributes. + build_type, rd, resolved_dependencies, bd = create_valid_build_definition() + assert bd.build_type == build_type + assert bd.external_parameters == EXTERNAL_PARAMETERS + assert bd.internal_parameters == INTERNAL_PARAMETERS + assert bd.resolved_dependencies[0] == rd + # Test the __eq__ method with a wrong type. + assert bd != {} + + +def test_build_definition_load_dict() -> None: + """Test loading the dict representation of a build definition.""" + bd: Predicate.BuildDefinition = create_valid_build_definition()[-1] + dict_repr: dict = bd.as_dict() + # Initialise a second build definition with that dict. + bd2: Predicate.BuildDefinition = Predicate.BuildDefinition.load_dict(dict_repr) + # Now check that all fields match. + assert bd.build_type == bd2.build_type + assert bd.external_parameters == bd2.external_parameters + assert bd.internal_parameters == bd2.internal_parameters + for resdep in bd.resolved_dependencies: + assert resdep in bd2.resolved_dependencies + + +def test_build_definition_load_json() -> None: + """Test loading the json representation of a build definition.""" + bd: Predicate.BuildDefinition = create_valid_build_definition()[-1] + json_repr: str = bd.as_json() + # Initialise a second build definition with that dict. + bd2: Predicate.BuildDefinition = Predicate.BuildDefinition.load_json(json_repr) + # Now check that all fields match. + assert bd.build_type == bd2.build_type + assert bd.external_parameters == bd2.external_parameters + assert bd.internal_parameters == bd2.internal_parameters + for resdep in bd.resolved_dependencies: + assert resdep in bd2.resolved_dependencies + + +def test_builder_as_dict() -> None: + ( + uri, + digest, + rc_annotations, + name, + dl_loc, + media_type, + content, + desc, + ) = create_valid_resource_descriptor() + build_id: TypeURI = TypeURI(VALID_URIS[1]) + builder_dependencies: list[ResourceDescriptor] = [desc] + version: dict[str, str] = {"version1": "value1"} + builder: Builder = Builder( + build_id=build_id, + builder_dependencies=builder_dependencies, + version=version, + ) + # Make sure it is a valid JSON object. + dict_repr: dict = builder.as_dict() + dict_dep: dict = desc.as_dict() + assert json.dumps(dict_repr, indent=" ") != "" + assert dict_repr.get(Builder.ATTR_BUILD_ID) == build_id + assert dict_repr.get(Builder.ATTR_BUILDER_DEPENDENCIES)[0] == dict_dep + assert dict_repr.get(Builder.ATTR_VERSION) == version + + +def test_builder_as_json() -> None: + builder: Builder = create_valid_builder()[-1] + json_repr: str = builder.as_json() + # Check that the JSON string is valid. + assert json.loads(json_repr) + + +def test_builder_init() -> None: + bid, deps, version, builder = create_valid_builder() + assert builder.id == bid + assert builder.builder_dependencies[0] == deps[0] + assert builder.version == version + # Try with a string for the build ID. + builder = Builder( + build_id=VALID_URIS[0], + builder_dependencies=deps, + version=version, + ) + assert builder.id == TypeURI(VALID_URIS[0]) + assert builder.builder_dependencies[0] == deps[0] + assert builder.version == version + # Test the __eq__ method with a wrong type. + assert builder != {} + + +def test_builder_load_dict() -> None: + """Test loading the dict representation of a builder.""" + builder: Builder = create_valid_builder()[-1] + dict_repr: dict = builder.as_dict() + # Initialise a second build definition with that dict. + builder2: Builder = Builder.load_dict(dict_repr) + # Now check that all fields match. + for builder_dep in builder.builder_dependencies: + assert builder_dep in builder2.builder_dependencies + assert builder.id == builder2.id + assert builder.version == builder2.version + # Check with an invalid builder (by setting the build ID to None). + dict_repr.pop(Builder.ATTR_BUILD_ID) + with pytest.raises(ValueError) as invalid_builder_id: + Builder.load_dict(dict_repr) + assert "Invalid build ID (None)" in invalid_builder_id.value.args[0] + + +def test_builder_load_json() -> None: + """Test loading the JSON representation of a builder.""" + builder: Builder = create_valid_builder()[-1] + json_repr: str = builder.as_json() + # Initialise a second build definition with that JSON string. + builder2: Builder = Builder.load_json(json_repr) + # Now check that all fields match. + for builder_dep in builder.builder_dependencies: + assert builder_dep in builder2.builder_dependencies + assert builder.id == builder2.id + assert builder.version == builder2.version + + +def test_buildmetadata_as_dict() -> None: + """Test the dict representation of a BuildMetadata.""" + start_time, invocation_id, finish_time, bm = create_valid_build_metadata() + dict_repr = bm.as_dict() + assert dict_repr.get(BuildMetadata.ATTR_INVOCATION_ID) == invocation_id + assert dict_repr.get(BuildMetadata.ATTR_STARTED_ON) == start_time.strftime( + BuildMetadata.TIMESTAMP_FORMAT + ) + assert dict_repr.get(BuildMetadata.ATTR_FINISHED_ON) == finish_time.strftime( + BuildMetadata.TIMESTAMP_FORMAT + ) + + +def test_buildmetadata_as_json() -> None: + """Test the JSON representation of a BuildMetadata.""" + start_time, invocation_id, finish_time, bm = create_valid_build_metadata() + json_repr: str = bm.as_json() + # Check that the JSON string is valid. + assert json.loads(json_repr) + + +def test_buildmetadata_init() -> None: + """Test the BuildMetadata class initialization.""" + start_time, invocation_id, finish_time, bm = create_valid_build_metadata() + assert bm.invocation_id == invocation_id + assert bm.started_on == start_time.replace(microsecond=0) + assert bm.finished_on == finish_time.replace(microsecond=0) + # Test the __eq__ method with a wrong type. + assert bm != {} + # Initialize with an invalid timestamp type + with pytest.raises(TypeError) as invalid_timestamp_type: + # noinspection PyTypeChecker + BuildMetadata( + invocation_id=invocation_id, started_on=None, finished_on=finish_time + ) + assert "Invalid timestamp type" in invalid_timestamp_type.value.args[0] + + +def test_buildmetadata_load_dict() -> None: + """Test loading the dict representation of a build metadata.""" + bm: BuildMetadata = create_valid_build_metadata()[-1] + dict_repr: dict = bm.as_dict() + # Initialise a second build metadata with that dict. + bm2: BuildMetadata = BuildMetadata.load_dict(dict_repr) + # Now check that all fields match. + assert bm.invocation_id == bm2.invocation_id + assert bm.started_on == bm2.started_on + assert bm.finished_on == bm2.finished_on + # Check initialisation with an invalid invocation ID. + dict_repr.pop(BuildMetadata.ATTR_INVOCATION_ID) + with pytest.raises(ValueError) as invalid_init_id: + BuildMetadata.load_dict(dict_repr) + assert "Invalid invocation ID (None)" in invalid_init_id.value.args[0] + + +def test_buildmetadata_load_json() -> None: + """Test loading the json representation of a build metadata.""" + bm: BuildMetadata = create_valid_build_metadata()[-1] + json_repr: str = bm.as_json() + # Initialise a second build metadata with that dict. + bm2: BuildMetadata = BuildMetadata.load_json(json_repr) + # Now check that all fields match. + assert bm.invocation_id == bm2.invocation_id + assert bm.started_on == bm2.started_on + assert bm.finished_on == bm2.finished_on + + +def test_predicate_as_dict() -> None: + """Test a predicate object dict format.""" + bd: Predicate.BuildDefinition = create_valid_build_definition()[-1] + rd: Predicate.RunDetails = create_valid_run_details()[-1] + predicate: Predicate = Predicate(build_definition=bd, run_details=rd) + dict_repr: dict = predicate.as_dict() + assert dict_repr.get(Predicate.ATTR_BUILD_DEFINITION) == bd.as_dict() + assert dict_repr.get(Predicate.ATTR_RUN_DETAILS) == rd.as_dict() + + +def test_predicate_as_json() -> None: + """Test a predicate object JSON format.""" + bd: Predicate.BuildDefinition = create_valid_build_definition()[-1] + rd: Predicate.RunDetails = create_valid_run_details()[-1] + predicate: Predicate = Predicate(build_definition=bd, run_details=rd) + json_repr: str = predicate.as_json() + # Check that the JSON string is valid. + assert json.loads(json_repr) + + +def test_predicate_init() -> None: + """Test a predicate object creation.""" + bd: Predicate.BuildDefinition = create_valid_build_definition()[-1] + rd: Predicate.RunDetails = create_valid_run_details()[-1] + predicate: Predicate = Predicate(build_definition=bd, run_details=rd) + assert predicate.build_definition == bd + assert predicate.run_details == rd + # Test the __eq__ method with a wrong type. + assert predicate != {} + + +def test_predicate_load_dict() -> None: + """Test an initialization of a predicate object with dict data.""" + bd: Predicate.BuildDefinition = create_valid_build_definition()[-1] + rd: Predicate.RunDetails = create_valid_run_details()[-1] + predicate: Predicate = Predicate(build_definition=bd, run_details=rd) + dict_repr: dict = predicate.as_dict() + # Create a second predicate with that dict representation. + predicate2: Predicate = Predicate.load_dict(dict_repr) + # Check that all fields match. + assert predicate.build_definition == predicate2.build_definition + assert predicate.run_details == predicate2.run_details + + # Set an invalid build definition for the statement. + build_def = dict_repr.pop(Predicate.ATTR_BUILD_DEFINITION) + with pytest.raises(ValueError) as missing_build_def: + Predicate.load_dict(dict_repr) + assert "Missing build definition" in missing_build_def.value.args[0] + + # Set an invalid run details for the statement. Re-add the build definition + # first. + dict_repr[Predicate.ATTR_BUILD_DEFINITION] = build_def + dict_repr.pop(Predicate.ATTR_RUN_DETAILS) + with pytest.raises(ValueError) as missing_run_details: + Predicate.load_dict(dict_repr) + assert "Missing run details definition" in missing_run_details.value.args[0] + + +def test_predicate_load_json() -> None: + """Test an initialization of a predicate object with JSON data.""" + bd: Predicate.BuildDefinition = create_valid_build_definition()[-1] + rd: Predicate.RunDetails = create_valid_run_details()[-1] + predicate: Predicate = Predicate(build_definition=bd, run_details=rd) + json_repr: str = predicate.as_json() + # Create a second predicate with that dict representation. + predicate2: Predicate = Predicate.load_json(json_repr) + # Check that all fields match. + assert predicate.build_definition == predicate2.build_definition + assert predicate.run_details == predicate2.run_details + + +def test_resource_desciptor_add_digest() -> None: + ( + uri, + digest, + rc_annotations, + name, + dl_loc, + media_type, + content, + desc, + ) = create_valid_resource_descriptor() + assert desc.digest == digest + # Add a new digest + desc.add_digest("blake2b", VALID_DIGESTS["blake2b"]) + assert desc.digest.get("blake2b", "") == VALID_DIGESTS["blake2b"] + # Try to add that digest again. + with pytest.raises(KeyError) as already_added_error: + desc.add_digest("blake2b", VALID_DIGESTS["blake2b"]) + assert ( + "Digest algorithm blake2b is already set to" + in already_added_error.value.args[0] + ) + + +def test_resource_descriptor_annotations() -> None: + """Test setting a resource descriptor annotations.""" + desc = ResourceDescriptor() + # Set a valid annotation. + desc.annotations = {"one": 1, "two": "two"} + desc.annotations = {} + # Try an invalid annotations. + with pytest.raises(TypeError) as invalid_annotations: + desc.annotations = "test" + assert ( + "Invalid resource descriptor annotations" in invalid_annotations.value.args[0] + ) + + +def test_resource_descriptor_as_dict() -> None: + """Test the content of the resource descriptor dictionary.""" + desc: ResourceDescriptor = create_valid_resource_descriptor()[-1] + # Check that it is a valid json object. + assert json.dumps(desc.as_dict()) + # Set one of the values to None, and retry. + desc.media_type = None + assert json.dumps(desc.as_dict()) + + +def test_resource_descriptor_as_json() -> None: + """Test the content of the resource descriptor JSON representation.""" + desc: ResourceDescriptor = create_valid_resource_descriptor()[-1] + # Check that it is a valid json object. + assert json.loads(desc.as_json()) + # Set one of the values to None, and retry. + desc.media_type = None + assert json.loads(desc.as_json()) + + +def test_resource_descriptor_content() -> None: + """Test setting a resource descriptor content.""" + desc = ResourceDescriptor() + # Set a valid content. + desc.content = "12.34".encode("utf-8") + desc.content = None + # Try an invalid content. + with pytest.raises(TypeError) as invalid_content: + desc.content = "test" + assert "Invalid resource descriptor content" in invalid_content.value.args[0] + + +def test_resource_descriptor_digest() -> None: + """Test setting a resource descriptor digest.""" + desc = ResourceDescriptor() + # Set a valid digest. + algo = "sha512" + desc.digest = {algo: VALID_DIGESTS[algo]} + desc.digest = {} + # Try an invalid digest. + with pytest.raises(TypeError) as invalid_digest: + desc.digest = "test" + assert "Invalid resource descriptor digest" in invalid_digest.value.args[0] + + +def test_resource_desciptor_dir_hash() -> None: + # Create a simple tree and try all algorithms on that tree. + # The awaited checksum is the same as:: + # + # find . -type f | cut -c3- | LC_ALL=C sort | xargs -r sha256sum \\ + # | sha256sum | cut -f1 -d' ' + tree_dir: Path = Path(Path().cwd(), "tree") + depth_dir: Path = Path(tree_dir, "depth") + for d in tree_dir, depth_dir: + d.mkdir(parents=True, exist_ok=True) + filenames = ["file1.txt", "file2.txt"] + for filename in filenames: + fpath: Path = Path(d, filename) + with fpath.open("wb") as f: + f.write(f"{filename} file content\n".encode("utf-8")) + + # Check all algorithms. + for algo in hashlib.algorithms_guaranteed: + hashed: str = ResourceDescriptor.dir_hash(tree_dir, algo) + if algo in VALID_DIGESTS: + assert hashed == VALID_DIGESTS.get(algo), f" ({algo})" + else: + # No way to check the result yet, just + ResourceDescriptor.dir_hash(tree_dir, algo) + + # Check with an invalid algorithm. + + with pytest.raises(ValueError) as invalid_algo: + # noinspection PyTypeChecker + ResourceDescriptor.dir_hash(tree_dir, "algo") + assert "Unsupported digest algorithm" in invalid_algo.value.args[0] + + +def test_resource_descriptor_download_location() -> None: + """Test setting a resource descriptor digest.""" + desc = ResourceDescriptor() + # Set a valid download location. + desc.download_location = VALID_URIS[0] + desc.download_location = ResourceURI(VALID_URIS[0]) + desc.download_location = None + # Try an invalid download location. + with pytest.raises(TypeError) as invalid_loc: + desc.download_location = 2 + assert "Invalid resource descriptor download location" in invalid_loc.value.args[0] + + +def test_resource_descriptor_init() -> None: + """Test a resource descriptor initialization.""" + # Create an empty ResourceDescriptor. + desc = ResourceDescriptor() + assert desc.is_valid is False + with pytest.raises(ValueError) as invalid_desc: + # Getting the dict representation should fail. + desc.as_dict() + assert "Invalid resource descriptor" in invalid_desc.value.args[0] + + ( + uri, + digest, + rc_annotations, + name, + dl_loc, + media_type, + content, + desc, + ) = create_valid_resource_descriptor() + + assert desc.is_valid is True + assert desc.uri == ResourceURI(uri) + assert desc.digest == digest + assert desc.name == name + assert desc.download_location == ResourceURI(dl_loc) + assert desc.media_type == media_type + assert desc.content == content + assert desc.annotations == rc_annotations + + # Test the __eq__ method with a wrong type. + assert desc != {} + + # Check that a resource descriptor is valid with only one of uri, content + # or digest. + + desc: ResourceDescriptor = ResourceDescriptor( + uri=uri, + ) + assert desc.is_valid is True + + desc: ResourceDescriptor = ResourceDescriptor( + digest=digest, + ) + assert desc.is_valid is True + + desc: ResourceDescriptor = ResourceDescriptor( + content=content, + ) + assert desc.is_valid is True + + +def test_resource_descriptor_load_dict() -> None: + """Test the content of the resource descriptor dictionary.""" + desc: ResourceDescriptor = create_valid_resource_descriptor()[-1] + # Create a second resource descriptor out of it. + desc2: ResourceDescriptor = ResourceDescriptor.load_dict(desc.as_dict()) + + # Check that all fields match. + assert desc2.uri == desc.uri + assert desc2.digest == desc.digest + assert desc2.annotations == desc.annotations + assert desc2.name == desc.name + assert desc2.download_location == desc.download_location + assert desc2.media_type == desc.media_type + assert desc2.content == desc.content + + # Set one of the values to None, and retry. + desc.media_type = None + ResourceDescriptor.load_dict(desc.as_dict()) + # Check with an empty dict. + with pytest.raises(ValueError) as invalid_dict: + ResourceDescriptor.load_dict({}) + assert "Invalid resource descriptor" in invalid_dict.value.args[0] + + +def test_resource_descriptor_load_json() -> None: + """Test the content of the resource descriptor JSON representation.""" + desc: ResourceDescriptor = create_valid_resource_descriptor()[-1] + # Create a second resource descriptor out of it. + desc2: ResourceDescriptor = ResourceDescriptor.load_json(desc.as_json()) + + # Check that all fields match. + assert desc2.uri == desc.uri + assert desc2.digest == desc.digest + assert desc2.annotations == desc.annotations + assert desc2.name == desc.name + assert desc2.download_location == desc.download_location + assert desc2.media_type == desc.media_type + assert desc2.content == desc.content + + # Set one of the values to None, and retry. + desc.media_type = None + ResourceDescriptor.load_json(desc.as_json()) + # Check with an empty dict. + with pytest.raises(ValueError) as invalid_json: + ResourceDescriptor.load_json(json.dumps({})) + assert "Invalid resource descriptor" in invalid_json.value.args[0] + + +def test_resource_descriptor_media_type() -> None: + """Test setting a resource descriptor madiaType.""" + desc = ResourceDescriptor() + # Set a valid mediaType. + desc.media_type = "media type" + desc.media_type = None + # Try an invalid mediaType. + with pytest.raises(TypeError) as invalid_media_type: + desc.media_type = 2 + assert "Invalid resource descriptor media type" in invalid_media_type.value.args[0] + + +def test_resource_descriptor_name() -> None: + """Test setting a resource descriptor name.""" + desc = ResourceDescriptor() + # Set a valid name. + desc.name = "name" + desc.name = None + # Try an invalid name. + with pytest.raises(TypeError) as invalid_name: + desc.name = 2 + assert "Invalid resource descriptor name" in invalid_name.value.args[0] + + +def test_resource_descriptor_uri() -> None: + """Test setting a resource descriptor uri.""" + desc = ResourceDescriptor() + # Set a valid uri. + desc.uri = VALID_URIS[0] + desc.uri = ResourceURI(VALID_URIS[0]) + # Try an invalid uri. + with pytest.raises(ValueError) as invalid_uri: + desc.uri = "test" + assert "Invalid URI" in invalid_uri.value.args[0] + + with pytest.raises(TypeError) as invalid_uri: + desc.uri = 2 + assert "Invalid resource descriptor uri" in invalid_uri.value.args[0] + + +def test_resourceuri_init() -> None: + """Tests for the ResourceURI constructor.""" + # First test on some valid URIs. + for valid_uri in VALID_URIS: + ResourceURI(valid_uri) + + # Make sure that invalid URIs raise a value error. + invalid_uris: list = [ + "//www.cwi.nl:80/%7Eguido/Python.html", + "www.cwi.nl/%7Eguido/Python.html", + 42, + "anydata", + ] + for invalid_uri in invalid_uris: + with pytest.raises(ValueError): + ResourceURI(invalid_uri) + + +def test_resourceuri_str() -> None: + """Tests for the ResourceURI string conversion.""" + # First test on some valid URIs. + for valid_uri in VALID_URIS: + resource_uri: ResourceURI = ResourceURI(valid_uri) + assert str(resource_uri) == valid_uri + + +def test_resourceuri_uri() -> None: + """Tests for the ResourceURI string conversion.""" + # First test on some valid URIs. + for valid_uri in VALID_URIS: + type_uri: ResourceURI = ResourceURI(valid_uri) + assert type_uri.uri == valid_uri + + +def test_run_details_as_dict() -> None: + """Test the dictionary representation of a predicate run details object.""" + builder, metadata, by_products, rd = create_valid_run_details() + dict_repr: dict = rd.as_dict() + assert dict_repr.get(Predicate.RunDetails.ATTR_BUILDER) == builder.as_dict() + assert dict_repr.get(Predicate.RunDetails.ATTR_METADATA) == metadata.as_dict() + assert dict_repr.get(Predicate.RunDetails.ATTR_BY_PRODUCTS) == [ + product.as_dict() for product in by_products + ] + + +def test_run_details_as_json() -> None: + """Test the JSON representation of a predicate run details object.""" + rd: Predicate.RunDetails = create_valid_run_details()[-1] + # Make sure the JSOn representation is readable. + assert json.loads(rd.as_json()) + + +def test_run_details_init() -> None: + """Test the initialization of a predicate run details object.""" + builder, metadata, by_products, rd = create_valid_run_details() + assert rd.builder == builder + assert rd.metatdata == metadata + assert rd.by_products == by_products + # Test the __eq__ method with a wrong type. + assert rd != {} + + +def test_run_details_load_dict() -> None: + """Test the initialization of a predicate run details with dictionary.""" + rd: Predicate.RunDetails = create_valid_run_details()[-1] + dict_repr: dict = rd.as_dict() + rd2: Predicate.RunDetails = Predicate.RunDetails.load_dict(dict_repr) + # Check that all fields match. + assert rd.builder == rd2.builder + assert rd.metatdata == rd2.metatdata + assert rd.by_products == rd2.by_products + + # Set an invalid metadata for the run details. + dict_repr.pop(Predicate.RunDetails.ATTR_METADATA) + with pytest.raises(ValueError) as missing_metadata: + Predicate.RunDetails.load_dict(dict_repr) + assert "Missing metadata definition" in missing_metadata.value.args[0] + + # Set an invalid builder for the run details. + dict_repr.pop(Predicate.RunDetails.ATTR_BUILDER) + with pytest.raises(ValueError) as missing_builder: + Predicate.RunDetails.load_dict(dict_repr) + assert "Missing builder definition" in missing_builder.value.args[0] + + +def test_run_details_load_json() -> None: + """Test the initialization of a predicate run details with dictionary.""" + rd: Predicate.RunDetails = create_valid_run_details()[-1] + rd2: Predicate.RunDetails = Predicate.RunDetails.load_json(rd.as_json()) + # Check that all fields match. + assert rd.builder == rd2.builder + assert rd.metatdata == rd2.metatdata + assert rd.by_products == rd2.by_products + + +def test_statement_as_dict() -> None: + """Test a SLSA statement object as_dict() method.""" + statement_type: TypeURI = TypeURI(VALID_URIS[0]) + subject: list[ResourceDescriptor] = [create_valid_resource_descriptor()[-1]] + predicate_type: TypeURI = TypeURI(Statement.PREDICATE_TYPE_VALUE) + bd: Predicate.BuildDefinition = create_valid_build_definition()[-1] + rd: Predicate.RunDetails = create_valid_run_details()[-1] + predicate: Predicate = Predicate(build_definition=bd, run_details=rd) + statement: Statement = Statement( + statement_type=statement_type, + subject=subject, + predicate_type=predicate_type, + predicate=predicate, + ) + dict_repr: dict = statement.as_dict() + assert dict_repr.get(Statement.ATTR_TYPE) == str(statement_type) + assert dict_repr.get(Statement.ATTR_SUBJECT) == [s.as_dict() for s in subject] + assert dict_repr.get(Statement.ATTR_PREDICATE_TYPE) == str(predicate_type) + assert dict_repr.get(Statement.ATTR_PREDICATE) == predicate.as_dict() + + # Try with an empty predicate type. + statement = Statement( + statement_type=statement_type, subject=subject, predicate=predicate + ) + dict_repr: dict = statement.as_dict() + assert dict_repr.get(Statement.ATTR_TYPE) == str(statement_type) + assert dict_repr.get(Statement.ATTR_SUBJECT) == [s.as_dict() for s in subject] + assert ( + dict_repr.get(Statement.ATTR_PREDICATE_TYPE) == Statement.PREDICATE_TYPE_VALUE + ) + assert dict_repr.get(Statement.ATTR_PREDICATE) == predicate.as_dict() + + # Try with an empty predicate. + statement = Statement( + statement_type=statement_type, + subject=subject, + predicate_type=predicate_type, + ) + dict_repr: dict = statement.as_dict() + assert dict_repr.get(Statement.ATTR_TYPE) == str(statement_type) + assert dict_repr.get(Statement.ATTR_SUBJECT) == [s.as_dict() for s in subject] + assert dict_repr.get(Statement.ATTR_PREDICATE_TYPE) == str(predicate_type) + assert dict_repr.get(Statement.ATTR_PREDICATE) is None + + # Try with an empty predicate type and an empty predicate. + statement = Statement( + statement_type=statement_type, + subject=subject, + ) + dict_repr: dict = statement.as_dict() + assert dict_repr.get(Statement.ATTR_TYPE) == str(statement_type) + assert dict_repr.get(Statement.ATTR_SUBJECT) == [s.as_dict() for s in subject] + assert ( + dict_repr.get(Statement.ATTR_PREDICATE_TYPE) == Statement.PREDICATE_TYPE_VALUE + ) + assert dict_repr.get(Statement.ATTR_PREDICATE) is None + + +def test_statement_as_json() -> None: + """Test a SLSA statement object as_json() method.""" + statement_type: TypeURI = TypeURI(VALID_URIS[0]) + subject: list[ResourceDescriptor] = [create_valid_resource_descriptor()[-1]] + predicate_type: TypeURI = TypeURI(Statement.PREDICATE_TYPE_VALUE) + bd: Predicate.BuildDefinition = create_valid_build_definition()[-1] + rd: Predicate.RunDetails = create_valid_run_details()[-1] + predicate: Predicate = Predicate(build_definition=bd, run_details=rd) + statement: Statement = Statement( + statement_type=statement_type, + subject=subject, + predicate_type=predicate_type, + predicate=predicate, + ) + # simply check that it is a valid JSON string. + assert json.loads(statement.as_json()) + + +def test_statement_init() -> None: + """Test a SLSA statement object initialization.""" + statement_type: TypeURI = TypeURI(VALID_URIS[0]) + subject: list[ResourceDescriptor] = [create_valid_resource_descriptor()[-1]] + predicate_type: TypeURI = TypeURI(Statement.PREDICATE_TYPE_VALUE) + bd: Predicate.BuildDefinition = create_valid_build_definition()[-1] + rd: Predicate.RunDetails = create_valid_run_details()[-1] + predicate: Predicate = Predicate(build_definition=bd, run_details=rd) + statement: Statement = Statement( + statement_type=statement_type, + subject=subject, + predicate_type=predicate_type, + predicate=predicate, + ) + assert statement.type == statement_type + assert statement.subject == subject + assert statement.predicate_type == predicate_type + assert statement.predicate == predicate + + # Test the __eq__ method with a wrong type. + assert statement != {} + + # Try with an empty predicate type. + statement = Statement( + statement_type=statement_type, subject=subject, predicate=predicate + ) + assert statement.type == statement_type + assert statement.subject == subject + assert statement.predicate_type == TypeURI(Statement.PREDICATE_TYPE_VALUE) + assert statement.predicate == predicate + + # Try with an empty predicate. + statement = Statement( + statement_type=statement_type, + subject=subject, + predicate_type=predicate_type, + ) + assert statement.type == statement_type + assert statement.subject == subject + assert statement.predicate_type == predicate_type + assert statement.predicate is None + + # Try with an empty predicate type and an empty predicate. + statement = Statement( + statement_type=statement_type, + subject=subject, + ) + assert statement.type == statement_type + assert statement.subject == subject + assert statement.predicate_type == TypeURI(Statement.PREDICATE_TYPE_VALUE) + assert statement.predicate is None + + +def test_statement_load_dict() -> None: + """Test a SLSA statement object load_dict() method.""" + statement_type: TypeURI = TypeURI(VALID_URIS[0]) + subject: list[ResourceDescriptor] = [create_valid_resource_descriptor()[-1]] + predicate_type: TypeURI = TypeURI(Statement.PREDICATE_TYPE_VALUE) + bd: Predicate.BuildDefinition = create_valid_build_definition()[-1] + rd: Predicate.RunDetails = create_valid_run_details()[-1] + predicate: Predicate = Predicate(build_definition=bd, run_details=rd) + statement: Statement = Statement( + statement_type=statement_type, + subject=subject, + predicate_type=predicate_type, + predicate=predicate, + ) + dict_repr: dict = statement.as_dict() + statement2: Statement = Statement.load_dict(dict_repr) + # Now check that all fields match. + assert statement.type == statement2.type + for subject in statement.subject: + assert subject in statement2.subject + assert statement.predicate_type == statement2.predicate_type + assert statement.predicate == statement2.predicate + # Set an invalid type for a statement. + dict_repr.pop(Statement.ATTR_TYPE) + with pytest.raises(ValueError) as invalid_statement_type: + Statement.load_dict(dict_repr) + assert "Invalid statement type (None)" in invalid_statement_type.value.args[0] + + +def test_statement_load_file() -> None: + """Check the statement can read the default file.""" + template_file: Path = Path(Path(__file__).parent, "provenance-example.json") + with template_file.open() as f: + Statement.load_dict(json.load(f)) + + +def test_statement_recreate_file() -> None: + template_file: Path = Path(Path(__file__).parent, "provenance-example.json") + # Resource descriptors for the statement's subject. + rd1: ResourceDescriptor = ResourceDescriptor( + name="file1.txt", + digest={"sha256": "123456789abcdef"}, + ) + rd2: ResourceDescriptor = ResourceDescriptor( + name="file2.o", + digest={"sha512": "123456789abcdeffedcba987654321"}, + ) + rd3: ResourceDescriptor = ResourceDescriptor( + name="out.exe", + digest={"md5": "123456789"}, + ) + # ---------------- + # Build definition + # ResourceDescriptors for build definition's resolved dependencies + bd_rd1: ResourceDescriptor = ResourceDescriptor( + name="e3-core", + uri="https://github.com/AdaCore/e3-core", + media_type="git", + resource_annotations={"branch": "master"}, + digest={"gitCommit": "f9c158d"}, + ) + bd_rd2: ResourceDescriptor = ResourceDescriptor( + name="config", + content=b"{'config': 'hello'}", + ) + bd: Predicate.BuildDefinition = Predicate.BuildDefinition( + build_type="https://www.myproduct.org/build", + external_parameters=[{"option": "-xxx"}, {"out_format": "exe"}], + internal_parameters=[{"env": {"MY_VAR": "my_value"}}], + resolved_dependencies=[bd_rd1, bd_rd2], + ) + # ---------------- + # ---------------- + # Run details builder + version: str = "3.12.0" + build_dep: ResourceDescriptor = ResourceDescriptor( + name="Python", + uri=f"https://www.python.org/ftp/python/{version}/Python-{version}.tgz", + download_location=f"https://www.python.org/ftp/python/{version}/Python-{version}.tgz", + media_type="application/gzip", + digest={"md5": "d6eda3e1399cef5dfde7c4f319b0596c"}, + resource_annotations={"version": version}, + ) + builder: Builder = Builder( + build_id="https://www.myproduct.org/build/647eda74f5cd7dc1cf55d12b", + builder_dependencies=[ + build_dep, + ], + version={version: "2023/10/02"}, + ) + # Run details metadata. + metadata: BuildMetadata = BuildMetadata( + invocation_id="c47eda74f5cd7dc1cf55d12b", + started_on=date_parser.parse("2023-10-02T13:39:53Z"), + finished_on=date_parser.parse("2023-10-02T14:59:22Z"), + ) + # Run details by product + by_p1: ResourceDescriptor = ResourceDescriptor( + name="My Product", + uri="https://www.myproduct.org", + digest={"md5": "d6eda3e1399caf5dfde7c4f319b0596c"}, + download_location="https://www.myproduct.org/download/my-product.tgz", + media_type="application/gzip", + resource_annotations={"version": "1.7.1"}, + ) + # Run details + run_details: Predicate.RunDetails = Predicate.RunDetails( + builder=builder, + metadata=metadata, + by_products=[ + by_p1, + ], + ) + # ---------------- + # ---------------- + # Predicate + predicate: Predicate = Predicate( + build_definition=bd, + run_details=run_details, + ) + # ---------------- + statement: Statement = Statement( + statement_type="https://in-toto.io/Statement/v1", + subject=[rd1, rd2, rd3], + predicate_type="https://slsa.dev/provenance/v1", + predicate=predicate, + ) + file_content: str + with template_file.open() as f: + file_content = json.dumps(json.load(f), sort_keys=True) + + assert Statement.load_json(file_content) == statement + + +def test_statement_load_json() -> None: + """Test a SLSA statement object load_json() method.""" + statement_type: TypeURI = TypeURI(VALID_URIS[0]) + subject: list[ResourceDescriptor] = [create_valid_resource_descriptor()[-1]] + predicate_type: TypeURI = TypeURI(Statement.PREDICATE_TYPE_VALUE) + bd: Predicate.BuildDefinition = create_valid_build_definition()[-1] + rd: Predicate.RunDetails = create_valid_run_details()[-1] + predicate: Predicate = Predicate(build_definition=bd, run_details=rd) + statement: Statement = Statement( + statement_type=statement_type, + subject=subject, + predicate_type=predicate_type, + predicate=predicate, + ) + statement2: Statement = Statement.load_json(statement.as_json()) + # Now check that all fields match. + assert statement.type == statement2.type + for subject in statement.subject: + assert subject in statement2.subject + assert statement.predicate_type == statement2.predicate_type + assert statement.predicate == statement2.predicate + + +def test_typeuri_eq() -> None: + """Tests for the TypeURI equal method.""" + # First test on some valid URIs. + uri: ResourceURI = ResourceURI(VALID_URIS[0]) + assert ResourceURI(VALID_URIS[0]) == uri + assert uri != 2 + + +def test_typeuri_init() -> None: + """Tests for the TypeURI constructor.""" + # First test on some valid URIs. + for valid_uri in VALID_URIS: + TypeURI(valid_uri) + + # Make sure that invalid URIs raise a value error. + invalid_uris: list = [ + "//www.cwi.nl:80/%7Eguido/Python.html", + "www.cwi.nl/%7Eguido/Python.html", + 42, + "anydata", + ] + for invalid_uri in invalid_uris: + with pytest.raises(ValueError): + TypeURI(invalid_uri) + + +def test_typeuri_str() -> None: + """Tests for the TypeURI string conversion.""" + for valid_uri in VALID_URIS: + type_uri: TypeURI = TypeURI(valid_uri) + assert str(type_uri) == valid_uri + + +def test_typeuri_uri() -> None: + """Tests for the TypeURI string conversion.""" + # First test on some valid URIs. + for valid_uri in VALID_URIS: + type_uri: TypeURI = TypeURI(valid_uri) + assert type_uri.uri == valid_uri