Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/pypi_attestations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
ConversionError,
Distribution,
Envelope,
GitHubPublisher,
GitLabPublisher,
Provenance,
Publisher,
TransparencyLogEntry,
Expand All @@ -25,6 +27,8 @@
"ConversionError",
"Distribution",
"Envelope",
"GitHubPublisher",
"GitLabPublisher",
"Provenance",
"Publisher",
"TransparencyLogEntry",
Expand Down
62 changes: 46 additions & 16 deletions src/pypi_attestations/_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 Envelope as DsseEnvelope
Expand Down Expand Up @@ -333,20 +334,58 @@ def _ultranormalize_dist_filename(dist: str) -> str:
raise ValueError(f"unknown distribution format: {dist}")


class Publisher(BaseModel):
"""Publisher as defined in PEP 740."""
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.
"""
The kind of Trusted Publisher.


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`.
"""

claims: dict[str, Any] | None
environment: str | None = None
"""
Claims specified by the publisher.
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."""

Expand All @@ -364,7 +403,7 @@ class AttestationBundle(BaseModel):
class Provenance(BaseModel):
"""Provenance object as defined in PEP 740."""

version: Literal[1]
version: Literal[1] = 1
"""
The provenance object's version, which is always 1.
"""
Expand All @@ -373,12 +412,3 @@ class Provenance(BaseModel):
"""
One or more attestation "bundles".
"""

@classmethod
def construct_simple(cls, publisher: Publisher, attestations: list[Attestation]) -> Provenance:
"""Construct a simple Provenance object."""
attestation_bundle = AttestationBundle(
publisher=publisher,
attestations=attestations,
)
return cls(version=1, attestation_bundles=[attestation_bundle])
60 changes: 49 additions & 11 deletions test/test_impl.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Internal implementation tests."""

import json
import os
from hashlib import sha256
from pathlib import Path
Expand All @@ -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
Expand Down Expand Up @@ -464,17 +465,54 @@ def test_ultranormalize_dist_filename_invalid(input: str) -> None:
impl._ultranormalize_dist_filename(input)


def test_construct_provenance() -> None:
attestation = impl.Attestation.model_validate_json(dist_attestation_path.read_bytes())
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

publisher = impl.Publisher(kind="simple-publisher-url", claims=None)
provenance = impl.Provenance.construct_simple(publisher=publisher, attestations=[attestation])
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

assert provenance.version == 1
assert len(provenance.attestation_bundles) == 1
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")

bundle = provenance.attestation_bundles[0]
assert bundle.publisher.claims is None
assert bundle.publisher.kind == "simple-publisher-url"
with pytest.raises(ValueError, match="Input should be 'GitLab'"):
impl.GitLabPublisher(kind="GitHub", repository="foo/bar")

assert bundle.attestations == [attestation]

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],
)
],
)