1313from annotated_types import MinLen # noqa: TCH002
1414from cryptography import x509
1515from cryptography .hazmat .primitives import serialization
16- from packaging .utils import (
17- InvalidSdistFilename ,
18- InvalidWheelFilename ,
19- parse_sdist_filename ,
20- parse_wheel_filename ,
21- )
22- from pydantic import Base64Bytes , BaseModel
16+ from packaging .utils import parse_sdist_filename , parse_wheel_filename
17+ from pydantic import Base64Bytes , BaseModel , field_validator
2318from pydantic_core import ValidationError
2419from sigstore ._utils import _sha256_streaming
2520from sigstore .dsse import Envelope as DsseEnvelope
3833 from sigstore .verify .policy import VerificationPolicy # pragma: no cover
3934
4035
36+ class Distribution (BaseModel ):
37+ """Represents a Python package distribution.
38+
39+ A distribution is identified by its (sdist or wheel) filename, which
40+ provides the package name and version (at a minimum) plus a SHA-256
41+ digest, which uniquely identifies its contents.
42+ """
43+
44+ name : str
45+ digest : str
46+
47+ @field_validator ("name" )
48+ @classmethod
49+ def _validate_name (cls , v : str ) -> str :
50+ return _ultranormalize_dist_filename (v )
51+
52+ @classmethod
53+ def from_file (cls , dist : Path ) -> Distribution :
54+ """Construct a `Distribution` from the given path."""
55+ name = dist .name
56+ with dist .open (mode = "rb" , buffering = 0 ) as io :
57+ # Replace this with `hashlib.file_digest()` once
58+ # our minimum supported Python is >=3.11
59+ digest = _sha256_streaming (io ).hex ()
60+
61+ return cls (name = name , digest = digest )
62+
63+
4164class AttestationType (str , Enum ):
4265 """Attestation types known to PyPI."""
4366
@@ -98,32 +121,19 @@ class Attestation(BaseModel):
98121 """
99122
100123 @classmethod
101- def sign (cls , signer : Signer , dist : Path ) -> Attestation :
102- """Create an envelope, with signature, from a distribution file .
124+ def sign (cls , signer : Signer , dist : Distribution ) -> Attestation :
125+ """Create an envelope, with signature, from the given Python distribution .
103126
104127 On failure, raises `AttestationError`.
105128 """
106- try :
107- with dist .open (mode = "rb" , buffering = 0 ) as io :
108- # Replace this with `hashlib.file_digest()` once
109- # our minimum supported Python is >=3.11
110- digest = _sha256_streaming (io ).hex ()
111- except OSError as e :
112- raise AttestationError (str (e ))
113-
114- try :
115- name = _ultranormalize_dist_filename (dist .name )
116- except (ValueError , InvalidWheelFilename , InvalidSdistFilename ) as e :
117- raise AttestationError (str (e ))
118-
119129 try :
120130 stmt = (
121131 _StatementBuilder ()
122132 .subjects (
123133 [
124134 _Subject (
125- name = name ,
126- digest = _DigestSet (root = {"sha256" : digest }),
135+ name = dist . name ,
136+ digest = _DigestSet (root = {"sha256" : dist . digest }),
127137 )
128138 ]
129139 )
@@ -144,19 +154,15 @@ def sign(cls, signer: Signer, dist: Path) -> Attestation:
144154 raise AttestationError (str (e ))
145155
146156 def verify (
147- self , verifier : Verifier , policy : VerificationPolicy , dist : Path
157+ self ,
158+ verifier : Verifier ,
159+ policy : VerificationPolicy ,
160+ dist : Distribution ,
148161 ) -> tuple [str , dict [str , Any ] | None ]:
149- """Verify against an existing Python artifact.
150-
151- Returns a tuple of the in-toto predicate type and optional deserialized JSON predicate.
162+ """Verify against an existing Python distribution.
152163
153164 On failure, raises an appropriate subclass of `AttestationError`.
154165 """
155- with dist .open (mode = "rb" , buffering = 0 ) as io :
156- # Replace this with `hashlib.file_digest()` once
157- # our minimum supported Python is >=3.11
158- expected_digest = _sha256_streaming (io ).hex ()
159-
160166 bundle = self .to_bundle ()
161167 try :
162168 type_ , payload = verifier .verify_dsse (bundle , policy )
@@ -184,18 +190,13 @@ def verify(
184190 except ValueError as e :
185191 raise VerificationError (f"invalid subject: { str (e )} " )
186192
187- try :
188- normalized = _ultranormalize_dist_filename (dist .name )
189- except ValueError as e :
190- raise VerificationError (f"invalid distribution name: { str (e )} " )
191-
192- if subject_name != normalized :
193+ if subject_name != dist .name :
193194 raise VerificationError (
194- f"subject does not match distribution name: { subject_name } != { normalized } "
195+ f"subject does not match distribution name: { subject_name } != { dist . name } "
195196 )
196197
197198 digest = subject .digest .root .get ("sha256" )
198- if digest is None or digest != expected_digest :
199+ if digest is None or digest != dist . digest :
199200 raise VerificationError ("subject does not match distribution digest" )
200201
201202 try :
0 commit comments