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: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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
12 changes: 11 additions & 1 deletion src/pypi_attestations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,33 @@

from ._impl import (
Attestation,
AttestationBundle,
AttestationError,
AttestationType,
ConversionError,
Distribution,
Envelope,
GitHubPublisher,
GitLabPublisher,
Provenance,
Publisher,
TransparencyLogEntry,
VerificationError,
VerificationMaterial,
)

__all__ = [
"Attestation",
"AttestationBundle",
"AttestationError",
"AttestationType",
"Envelope",
"ConversionError",
"Distribution",
"Envelope",
"GitHubPublisher",
"GitLabPublisher",
"Provenance",
"Publisher",
"TransparencyLogEntry",
"VerificationError",
"VerificationMaterial",
Expand Down
83 changes: 82 additions & 1 deletion 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 DigestSet, StatementBuilder, Subject, _Statement
Expand Down Expand Up @@ -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".
"""
73 changes: 72 additions & 1 deletion 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 @@ -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],
)
],
)