diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e0c70d..28d909e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The minimum Python version required has been bumped to `3.11` ([#37](https://github.com/trailofbits/pypi-attestations/pull/37)) +- The `Provenance`, `Publisher`, `GitHubPublisher`, `GitLabPublisher`, and + `AttestationBundle` types have been added + ([#36](https://github.com/trailofbits/pypi-attestations/pull/36)). + ## [0.0.9] ### Added diff --git a/Makefile b/Makefile index 02f3b54..0f4cccd 100644 --- a/Makefile +++ b/Makefile @@ -46,9 +46,7 @@ dev: $(VENV)/pyvenv.cfg $(VENV)/pyvenv.cfg: pyproject.toml # Create our Python 3 virtual environment python3 -m venv env - # NOTE(ekilmer): interrogate v1.5.0 needs setuptools when using Python 3.12+. - # This should be fixed when the next release is made - $(VENV_BIN)/python -m pip install --upgrade pip setuptools + $(VENV_BIN)/python -m pip install --upgrade pip $(VENV_BIN)/python -m pip install -e .[$(INSTALL_EXTRA)] .PHONY: lint diff --git a/src/pypi_attestations/__init__.py b/src/pypi_attestations/__init__.py index 3a56175..a57fbae 100644 --- a/src/pypi_attestations/__init__.py +++ b/src/pypi_attestations/__init__.py @@ -4,11 +4,16 @@ from ._impl import ( Attestation, + AttestationBundle, AttestationError, AttestationType, ConversionError, Distribution, Envelope, + GitHubPublisher, + GitLabPublisher, + Provenance, + Publisher, TransparencyLogEntry, VerificationError, VerificationMaterial, @@ -16,11 +21,16 @@ __all__ = [ "Attestation", + "AttestationBundle", "AttestationError", "AttestationType", - "Envelope", "ConversionError", "Distribution", + "Envelope", + "GitHubPublisher", + "GitLabPublisher", + "Provenance", + "Publisher", "TransparencyLogEntry", "VerificationError", "VerificationMaterial", diff --git a/src/pypi_attestations/_impl.py b/src/pypi_attestations/_impl.py index d0b26cf..d7346d3 100644 --- a/src/pypi_attestations/_impl.py +++ b/src/pypi_attestations/_impl.py @@ -14,7 +14,8 @@ from cryptography import x509 from cryptography.hazmat.primitives import serialization from packaging.utils import parse_sdist_filename, parse_wheel_filename -from pydantic import Base64Bytes, BaseModel, field_validator +from pydantic import Base64Bytes, BaseModel, ConfigDict, Field, field_validator +from pydantic.alias_generators import to_snake from pydantic_core import ValidationError from sigstore._utils import _sha256_streaming from sigstore.dsse import DigestSet, StatementBuilder, Subject, _Statement @@ -331,3 +332,83 @@ def _ultranormalize_dist_filename(dist: str) -> str: return f"{name}-{ver}.tar.gz" else: raise ValueError(f"unknown distribution format: {dist}") + + +class _PublisherBase(BaseModel): + model_config = ConfigDict(alias_generator=to_snake) + + kind: str + claims: dict[str, Any] | None = None + + +class GitHubPublisher(_PublisherBase): + """A GitHub-based Trusted Publisher.""" + + kind: Literal["GitHub"] = "GitHub" + + repository: str + """ + The fully qualified publishing repository slug, e.g. `foo/bar` for + repository `bar` owned by `foo`. + """ + + workflow: str + """ + The filename of the GitHub Actions workflow that performed the publishing + action. + """ + + environment: str | None = None + """ + The optional name GitHub Actions environment that the publishing + action was performed from. + """ + + +class GitLabPublisher(_PublisherBase): + """A GitLab-based Trusted Publisher.""" + + kind: Literal["GitLab"] = "GitLab" + + repository: str + """ + The fully qualified publishing repository slug, e.g. `foo/bar` for + repository `bar` owned by `foo` or `foo/baz/bar` for repository + `bar` owned by group `foo` and subgroup `baz`. + """ + + environment: str | None = None + """ + The optional environment that the publishing action was performed from. + """ + + +Publisher = Annotated[GitHubPublisher | GitLabPublisher, Field(discriminator="kind")] + + +class AttestationBundle(BaseModel): + """AttestationBundle object as defined in PEP 740.""" + + publisher: Publisher + """ + The publisher associated with this set of attestations. + """ + + attestations: list[Attestation] + """ + The list of attestations included in this bundle. + """ + + +class Provenance(BaseModel): + """Provenance object as defined in PEP 740.""" + + version: Literal[1] = 1 + """ + The provenance object's version, which is always 1. + """ + + attestation_bundles: list[AttestationBundle] + """ + One or more attestation "bundles". + """ diff --git a/test/test_impl.py b/test/test_impl.py index 42d94a6..538df1c 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -1,5 +1,6 @@ """Internal implementation tests.""" +import json import os from hashlib import sha256 from pathlib import Path @@ -8,7 +9,7 @@ import pypi_attestations._impl as impl import pytest import sigstore -from pydantic import ValidationError +from pydantic import TypeAdapter, ValidationError from sigstore.dsse import DigestSet, StatementBuilder, Subject from sigstore.models import Bundle from sigstore.oidc import IdentityToken @@ -462,3 +463,73 @@ def test_ultranormalize_dist_filename(input: str, normalized: str) -> None: def test_ultranormalize_dist_filename_invalid(input: str) -> None: with pytest.raises(ValueError): impl._ultranormalize_dist_filename(input) + + +class TestPublisher: + def test_discriminator(self) -> None: + gh_raw = {"kind": "GitHub", "repository": "foo/bar", "workflow": "publish.yml"} + gh = TypeAdapter(impl.Publisher).validate_python(gh_raw) + + assert isinstance(gh, impl.GitHubPublisher) + assert gh.repository == "foo/bar" + assert gh.workflow == "publish.yml" + assert TypeAdapter(impl.Publisher).validate_json(json.dumps(gh_raw)) == gh + + gl_raw = {"kind": "GitLab", "repository": "foo/bar/baz", "environment": "publish"} + gl = TypeAdapter(impl.Publisher).validate_python(gl_raw) + assert isinstance(gl, impl.GitLabPublisher) + assert gl.repository == "foo/bar/baz" + assert gl.environment == "publish" + assert TypeAdapter(impl.Publisher).validate_json(json.dumps(gl_raw)) == gl + + def test_wrong_kind(self) -> None: + with pytest.raises(ValueError, match="Input should be 'GitHub'"): + impl.GitHubPublisher(kind="wrong", repository="foo/bar", workflow="publish.yml") + + with pytest.raises(ValueError, match="Input should be 'GitLab'"): + impl.GitLabPublisher(kind="GitHub", repository="foo/bar") + + def test_claims(self) -> None: + raw = { + "kind": "GitHub", + "repository": "foo/bar", + "workflow": "publish.yml", + "claims": { + "this": "is-preserved", + "this-too": 123, + }, + } + pub = TypeAdapter(impl.Publisher).validate_python(raw) + + assert pub.claims == { + "this": "is-preserved", + "this-too": 123, + } + + +class TestProvenance: + def test_version(self) -> None: + attestation = impl.Attestation.model_validate_json(dist_attestation_path.read_bytes()) + provenance = impl.Provenance( + attestation_bundles=[ + impl.AttestationBundle( + publisher=impl.GitHubPublisher(repository="foo/bar", workflow="publish.yml"), + attestations=[attestation], + ) + ] + ) + assert provenance.version == 1 + + # Setting any other version doesn't work. + with pytest.raises(ValueError): + provenance = impl.Provenance( + version=2, + attestation_bundles=[ + impl.AttestationBundle( + publisher=impl.GitHubPublisher( + repository="foo/bar", workflow="publish.yml" + ), + attestations=[attestation], + ) + ], + )