diff --git a/CHANGELOG.md b/CHANGELOG.md index 830b5c3..a871c33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 specified with a `pypi:` prefix followed by the filename, e.g: `pypi:sampleproject-1.0.0.tar.gz`. The old way (passing the direct URL) is still supported. +- The CLI subcommand `verify pypi` now supports passing the local paths + to the artifact and its provenance file, allowing the user to verify + files already downloaded from PyPI. The artifact path is passed as + usual, whereas the provenance file path is passed using the + `--provenance-file` option. ## [0.0.21] diff --git a/README.md b/README.md index e7bbfc1..6be1216 100644 --- a/README.md +++ b/README.md @@ -142,16 +142,23 @@ pypi-attestations verify attestation \ ### Verifying a PyPI package > [!NOTE] -> The package to verify can be passed either as a `pypi:` prefixed filename (e.g: -> 'pypi:sampleproject-1.0.0-py3-none-any.whl'), or as a direct URL -> to the artifact hosted by PyPI. +> The package to verify can be passed either as a path to a local file, a +> `pypi:` prefixed filename (e.g: 'pypi:sampleproject-1.0.0-py3-none-any.whl'), +> or as a direct URL to the artifact hosted by PyPI. + ```bash +# Download the artifact (and its provenance) from PyPI and verify it pypi-attestations verify pypi --repository https://github.com/sigstore/sigstore-python \ pypi:sigstore-3.6.1-py3-none-any.whl -# or alternatively: +# or alternatively, using the direct URL: pypi-attestations verify pypi --repository https://github.com/sigstore/sigstore-python \ https://files.pythonhosted.org/packages/70/f5/324edb6a802438e97e289992a41f81bb7a58a1cda2e49439e7e48896649e/sigstore-3.6.1-py3-none-any.whl + +# Verify the artifact and its provenance using local files +pypi-attestations verify pypi --repository https://github.com/sigstore/sigstore-python \ + --provenance-file ~/Downloads/sigstore-3.6.1-py3-none-any.whl.provenance \ + ~/Downloads/sigstore-3.6.1-py3-none-any.whl ``` This command downloads the artifact and its provenance from PyPI. The artifact diff --git a/src/pypi_attestations/_cli.py b/src/pypi_attestations/_cli.py index aa79309..0d002e5 100644 --- a/src/pypi_attestations/_cli.py +++ b/src/pypi_attestations/_cli.py @@ -157,6 +157,12 @@ def _parser() -> argparse.ArgumentParser: help="Use the staging environment", ) + verify_pypi_command.add_argument( + "--provenance-file", + type=Path, + help="Provide the provenance file instead of downloading it from PyPI", + ) + inspect_command = subcommands.add_parser( name="inspect", help="Inspect one or more inputs", @@ -233,9 +239,33 @@ def _download_file(url: str, dest: Path) -> None: _die(f"Error downloading file: {e}") -def _get_direct_url_from_arg(arg: str) -> URIReference: +def _get_distribution_from_arg(arg: str) -> Distribution: """Parse the artifact argument for the `verify pypi` subcommand. + The argument can be: + - A pypi: prefixed filename (e.g. pypi:sampleproject-1.0.0.tar.gz) + - A direct URL to a PyPI-hosted artifact + - A path to a local file + """ + if arg.startswith("pypi:") or arg.startswith("https://"): + pypi_url = _get_direct_url_from_arg(arg) + dist_filename = pypi_url.path.split("/")[-1] + with TemporaryDirectory() as temp_dir: + dist_path = Path(temp_dir) / dist_filename + _download_file(url=pypi_url.unsplit(), dest=dist_path) + dist = Distribution.from_file(dist_path) + else: + dist_path = Path(arg) + if not dist_path.exists(): + _die(f"File does not exist: {dist_path}") + dist = Distribution.from_file(dist_path) + + return dist + + +def _get_direct_url_from_arg(arg: str) -> URIReference: + """Get the URL from the artifact argument for the `verify pypi` subcommand. + The argument can be: - A pypi: prefixed filename (e.g. pypi:sampleproject-1.0.0.tar.gz) - A direct URL to a PyPI-hosted artifact @@ -288,17 +318,14 @@ def _get_direct_url_from_arg(arg: str) -> URIReference: return pypi_url -def _get_provenance_from_pypi(filename: str) -> Provenance: +def _get_provenance_from_pypi(dist: Distribution) -> Provenance: """Use PyPI's integrity API to get a distribution's provenance.""" - try: - if filename.endswith(".tar.gz") or filename.endswith(".zip"): - name, version = parse_sdist_filename(filename) - elif filename.endswith(".whl"): - name, version, _, _ = parse_wheel_filename(filename) - else: - _die("URL should point to a wheel (*.whl) or a source distribution (*.zip or *.tar.gz)") - except (InvalidSdistFilename, InvalidWheelFilename) as e: - _die(f"Invalid distribution filename: {e}") + filename = dist.name + # Filename is already validated when creating the Distribution object + if filename.endswith(".tar.gz") or filename.endswith(".zip"): + name, version = parse_sdist_filename(filename) + else: + name, version, _, _ = parse_wheel_filename(filename) provenance_url = f"https://pypi.org/integrity/{name}/{version}/{filename}/provenance" response = requests.get(provenance_url) @@ -480,31 +507,34 @@ def _verify_attestation(args: argparse.Namespace) -> None: def _verify_pypi(args: argparse.Namespace) -> None: """Verify a distribution hosted on PyPI. - The distribution is downloaded and verified. The verification is against - the provenance file hosted on PyPI (if any), and against the repository URL - passed by the user as a CLI argument. + The distribution is downloaded (if needed) and verified. The verification is against + the provenance file (passed using the `--provenance-file` option, or downloaded + from PyPI if not provided), and against the repository URL passed by the user + as a CLI argument. """ - pypi_url = _get_direct_url_from_arg(args.distribution_file) + dist = _get_distribution_from_arg(args.distribution_file) - with TemporaryDirectory() as temp_dir: - dist_filename = pypi_url.path.split("/")[-1] - dist_path = Path(temp_dir) / dist_filename - _download_file(url=pypi_url.unsplit(), dest=dist_path) - provenance = _get_provenance_from_pypi(dist_filename) - dist = Distribution.from_file(dist_path) + if args.provenance_file is None: + provenance = _get_provenance_from_pypi(dist) + else: + if not args.provenance_file.exists(): + _die(f"Provenance file does not exist: {args.provenance_file}") try: - for attestation_bundle in provenance.attestation_bundles: - publisher = attestation_bundle.publisher - _check_repository_identity( - expected_repository_url=args.repository, publisher=publisher - ) - policy = publisher._as_policy() # noqa: SLF001. - for attestation in attestation_bundle.attestations: - attestation.verify(policy, dist, staging=args.staging) - except VerificationError as verification_error: - _die(f"Verification failed for {dist_filename}: {verification_error}") - - _logger.info(f"OK: {dist_filename}") + provenance = Provenance.model_validate_json(args.provenance_file.read_bytes()) + except ValidationError as validation_error: + _die(f"Invalid provenance: {validation_error}") + + try: + for attestation_bundle in provenance.attestation_bundles: + publisher = attestation_bundle.publisher + _check_repository_identity(expected_repository_url=args.repository, publisher=publisher) + policy = publisher._as_policy() # noqa: SLF001. + for attestation in attestation_bundle.attestations: + attestation.verify(policy, dist, staging=args.staging) + except VerificationError as verification_error: + _die(f"Verification failed for {dist.name}: {verification_error}") + + _logger.info(f"OK: {dist.name}") def main() -> None: diff --git a/test/assets/sigstore-3.6.1.tar.gz b/test/assets/sigstore-3.6.1.tar.gz new file mode 100644 index 0000000..fb36a61 Binary files /dev/null and b/test/assets/sigstore-3.6.1.tar.gz differ diff --git a/test/assets/sigstore-3.6.1.tar.gz.provenance b/test/assets/sigstore-3.6.1.tar.gz.provenance new file mode 100644 index 0000000..673b7f2 --- /dev/null +++ b/test/assets/sigstore-3.6.1.tar.gz.provenance @@ -0,0 +1 @@ +{"attestation_bundles":[{"attestations":[{"envelope":{"signature":"MEUCIQDMIOMtnfp8Sh5OmmuWUjteQueY9w0weYye1542/61bCgIgay9OlFBHW7ykJP7/Cnitk59eNDAPcvK9+unlCaceKwo=","statement":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoic2lnc3RvcmUtMy42LjEudGFyLmd6IiwiZGlnZXN0Ijp7InNoYTI1NiI6ImVlNjBmZGM5MjM2ZmQ2NzA5MjcxYWQ1M2I0NDAyNzQ2MTM2MGMzZmRlMTU1ZDJhZjE1NDgyZTRjNDUxZmY4NjUifX1dLCJwcmVkaWNhdGVUeXBlIjoiaHR0cHM6Ly9kb2NzLnB5cGkub3JnL2F0dGVzdGF0aW9ucy9wdWJsaXNoL3YxIiwicHJlZGljYXRlIjpudWxsfQ=="},"verification_material":{"certificate":"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","transparency_entries":[{"canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiMjM4OWMxOGMxMmY2YmYxOTQyNmM5YTZmNzEwNDUzZjMwNTAyNzNhNTE1MzY0MDVkYTJmOTE2NjUxYzJiZjdmNyJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6IjdiZDYxODAwNWExNDIxMjViYjk5YzBjYmIwMjgzYjc2ODg1MjI2MmFkOGRiZjYzN2ViZTM4Zjc2MWVmYzRjZWEifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVVQ0lRRE1JT010bmZwOFNoNU9tbXVXVWp0ZVF1ZVk5dzB3ZVl5ZTE1NDIvNjFiQ2dJZ2F5OU9sRkJIVzd5a0pQNy9Dbml0azU5ZU5EQVBjdks5K3VubENhY2VLd289IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VjeVZFTkRRbXdyWjBGM1NVSkJaMGxWWldsbGRVdFFUU3QzZEVOa2JFdEZkVTgyYmxJNGN6aExjR3RqZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwUmVFMXFSVFZOVkdOM1QxUlZlbGRvWTA1TmFsRjRUV3BGTlUxVVkzaFBWRlY2VjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVkJTMWR6UkhGNVkyVktjMmwyTVRodlRIcHZaVTFGWm1aMVpXaEZTbEpFU0dSWldFd0tkV2xvVVM5bWNGVTNPVXR6U1VwdWVIaHZXbnBNY3pnMVVEaFZhM0JvTm5kSlVtVnVSRkozY1VJdlpVcExNazg1UzB0UFEwSllOSGRuWjFZMlRVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdSQ1owNVdTRkUwUlVablVWVTVNSE5uQ2pKVVNDODVRMlJ1VG5sUlkyOUlRMHBuZVZCaVpqSkJkMGgzV1VSV1VqQnFRa0puZDBadlFWVXpPVkJ3ZWpGWmEwVmFZalZ4VG1wd1MwWlhhWGhwTkZrS1drUTRkMkZCV1VSV1VqQlNRVkZJTDBKR05IZFlTVnBoWVVoU01HTklUVFpNZVRsdVlWaFNiMlJYU1hWWk1qbDBURE5PY0ZvelRqQmlNMHBzVEROT2NBcGFNMDR3WWpOS2JFeFlRalZrUjJoMlltazRkVm95YkRCaFNGWnBURE5rZG1OdGRHMWlSemt6WTNrNWVWcFhlR3haV0U1c1RHNXNkR0pGUW5sYVYxcDZDa3d6VW1oYU0wMTJaR3BOZFU1cE5IaE5SR3RIUTJselIwRlJVVUpuTnpoM1FWRkZSVXN5YURCa1NFSjZUMms0ZG1SSE9YSmFWelIxV1ZkT01HRlhPWFVLWTNrMWJtRllVbTlrVjBveFl6SldlVmt5T1hWa1IxWjFaRU0xYW1JeU1IZEdVVmxMUzNkWlFrSkJSMFIyZWtGQ1FXZFJTR050Vm5OYVYwWjZXbFJCTWdwQ1oyOXlRbWRGUlVGWlR5OU5RVVZFUWtObk5FOVVXbXBhYlZWNFRYcEZkMDVVVVRWT1YxVXlXa2ROTWxwcWFHMVpWMWw1VFRKVmVFMUVRVE5hUjBWNkNrNVhWbXRhVjFacFRVSlZSME5wYzBkQlVWRkNaemM0ZDBGUlVVVkNNVXBzWWtkV2FHTXlWWGRLWjFsTFMzZFpRa0pCUjBSMmVrRkNRbEZSV1dNeWJHNEtZek5TZG1OdFZYWmpNbXh1WXpOU2RtTnRWWFJqU0d3d1lVYzVkVTFDTkVkRGFYTkhRVkZSUW1jM09IZEJVVmxGUlVoS2JGcHVUWFprUjBadVkzazVNZ3BOZVRReVRHcEZkMDkzV1V0TGQxbENRa0ZIUkhaNlFVSkRRVkYwUkVOMGIyUklVbmRqZW05MlRETlNkbUV5Vm5WTWJVWnFaRWRzZG1KdVRYVmFNbXd3Q21GSVZtbGtXRTVzWTIxT2RtSnVVbXhpYmxGMVdUSTVkRTFIYjBkRGFYTkhRVkZSUW1jM09IZEJVV3RGV0VGNFlXRklVakJqU0UwMlRIazVibUZZVW04S1pGZEpkVmt5T1hSTU0wNXdXak5PTUdJelNteE1NMDV3V2pOT01HSXpTbXhNV0VJMVpFZG9kbUpwT0hWYU1td3dZVWhXYVV3elpIWmpiWFJ0WWtjNU13cGplVGw1V2xkNGJGbFlUbXhNYm14MFlrVkNlVnBYV25wTU0xSm9Xak5OZG1ScVRYVk9hVFI0VFVSblIwTnBjMGRCVVZGQ1p6YzRkMEZSYjBWTFozZHZDazlFYXpKWk1scHNUVlJOZUUxRVZUQlBWRlpzVG0xU2FrNXRXVFJhYlVadFRXcE9iRTFVUVhkT01sSm9UWHBXYkZwSFZteFpha0ZrUW1kdmNrSm5SVVVLUVZsUEwwMUJSVXhDUVRoTlJGZGtjR1JIYURGWmFURnZZak5PTUZwWFVYZFBkMWxMUzNkWlFrSkJSMFIyZWtGQ1JFRlJkRVJEZEc5a1NGSjNZM3B2ZGdwTU1tUndaRWRvTVZscE5XcGlNakIyWXpKc2JtTXpVblpqYlZWMll6SnNibU16VW5aamJWVjBZMGhzTUdGSE9YVk5SR2RIUTJselIwRlJVVUpuTnpoM0NrRlJNRVZMWjNkdlQwUnJNbGt5V214TlZFMTRUVVJWTUU5VVZteE9iVkpxVG0xWk5GcHRSbTFOYWs1c1RWUkJkMDR5VW1oTmVsWnNXa2RXYkZscVFXY0tRbWR2Y2tKblJVVkJXVTh2VFVGRlQwSkNTVTFGU0Vwc1dtNU5kbVJIUm01amVUa3lUWGswTWt4cVJYZEhVVmxMUzNkWlFrSkJSMFIyZWtGQ1JIZFJUQXBFUVdzd1RrUmpNazlVUlhkUFJGbDNTM2RaUzB0M1dVSkNRVWRFZG5wQlFrVkJVV1JFUW5SdlpFaFNkMk42YjNaTU1tUndaRWRvTVZscE5XcGlNakIyQ21NeWJHNWpNMUoyWTIxVmQwZEJXVXRMZDFsQ1FrRkhSSFo2UVVKRlVWRkxSRUZuTTAxVVFUVk9hazB4VFhwQ2NVSm5iM0pDWjBWRlFWbFBMMDFCUlZNS1FrWjNUVmR0YURCa1NFSjZUMms0ZGxveWJEQmhTRlpwVEcxT2RtSlRPWHBoVjJSNlpFYzVlVnBUT1hwaFYyUjZaRWM1ZVZwVE1YZGxXRkp2WWpJMGRncE1iV1J3WkVkb01WbHBPVE5pTTBweVdtMTRkbVF6VFhaamJWWnpXbGRHZWxwVE5UVmlWM2hCWTIxV2JXTjVPVEJaVjJSNlRETlpla3hxV1hWTlZFRTBDa0puYjNKQ1owVkZRVmxQTDAxQlJWUkNRMjlOUzBSbk5VNXRUbTFhVkVWNlRWUkJNVTVFYXpGYVZGcHJXWHBhYlU5SFdtaGFha2w2V2xSRmQwMUVaR3NLV1ZSTk1WcFhVbXhhVjBsM1JuZFpTMHQzV1VKQ1FVZEVkbnBCUWtaQlVVcEVRV1I1V2xkNGJGbFlUbXhOUmpoSFEybHpSMEZSVVVKbk56aDNRVkpWUlFwVlVYaFFZVWhTTUdOSVRUWk1lVGx1WVZoU2IyUlhTWFZaTWpsMFRETk9jRm96VGpCaU0wcHNURE5PY0ZvelRqQmlNMHBzVEZoQ05XUkhhSFppYVRsb0Nsa3pVbkJpTWpWNlRETktNV0p1VFhaTlZFa3dUVlJqTUUxcVFUVk5SRVYyV1ZoU01GcFhNWGRrU0UxMlRWUkJWMEpuYjNKQ1owVkZRVmxQTDAxQlJWY0tRa0ZuVFVKdVFqRlpiWGh3V1hwRFFtbG5XVXRMZDFsQ1FrRklWMlZSU1VWQloxSTRRa2h2UVdWQlFqSkJUakE1VFVkeVIzaDRSWGxaZUd0bFNFcHNiZ3BPZDB0cFUydzJORE5xZVhRdk5HVkxZMjlCZGt0bE5rOUJRVUZDYXprdmJIUlNkMEZCUVZGRVFVVmpkMUpSU1doQlR6bGhUMGRpVDBaUGVtcGlXVTR6Q2xwUmIzcFFhRTFLTDNSRlVWSkJPVUZ6VERsaGFtNU9WVzFFYURoQmFVSnFabUZPVkRaNFVHODJRWEY0YkZWWWJ6ZHVkMDluVDBsTmJXUkdOVFJ0WnpVS1ZqbEtTbnBHTTBzNFJFRkxRbWRuY1docmFrOVFVVkZFUVhkT2IwRkVRbXhCYWtGemVUbDFPRW96TUdwM1NHSkNiRE5DTXpGa0syOTNNVlJ1WlhWdlJ3cEVlSE5KYUdNelF6RXpaVWxVV1RnNFdVVmlPVWQxUnl0YVRFVk1ObEJrYzNwNk5FTk5VVU00UVRWQ1JtTnZURzVZYm13MWRFRkdWRXBITW5ndllYTnNDa1JNWTJsbmJEWjNObGRaUTJ0TlZHNVVaVWg2Y0hWMFNrbGlVbEJ1ZGtWcVFtcDJhME4xYnowS0xTMHRMUzFGVGtRZ1EwVlNWRWxHU1VOQlZFVXRMUzB0TFFvPSJ9XX19","inclusionPromise":{"signedEntryTimestamp":"MEUCIQDrulw1km4at8ZmBCBTiB1EDhtGdmx8V5hck+FzPYg7jQIgLJ4O8E4Bd9cqn7G3HotsJ+I3NiVG5W+pZv7nQDjX5bY="},"inclusionProof":{"checkpoint":{"envelope":"rekor.sigstore.dev - 1193050959916656506\n34812628\n+ce/n7ywQMI84KqPfAKGKMwdivhMAlf7XwPKYBidxYM=\n\n— rekor.sigstore.dev wNI9ajBFAiAhElSyD1E0nPtdd92eidXkaRcgYtILfOA1cXk1sDbLXAIhAItjpa2bFkL5aovzA1eUjkzZ6TX/PVL4+BNl5A+BHmby\n"},"hashes":["FhoMTHbH8IjfwSy6gvgd+d/IfANzjryvXh4eZSn6AS8=","1QyyxJKYbJBZlc15TbaxmNbEfA1AHmJr70+0qyePuXg=","EN1muAli1O0UWgSvkX6qMmJK1nLTyal0aWpuaHKQ2Y4=","Nl//RhaVeQQQoM17dAQHuAIj6Dkl/vM2NFCmc2mC8/4=","DPDWnECwPD/Wh144RnYVA7yenXvJzFtdLTFt0zs1a1g=","vqBR26dbTo8QhMdNFlg3s+NZOui+7VrzrGbP0fFVOzk=","mQnBdLQrv9x7kuZzlQT93vlvWUy7sfcsVWRId4hBEtg=","naGqS2+y9kMdzxW4CDHPJAJs/s1LMscH0gAbpFEhnkc=","pUm2APk0bAEfOBQX/2qQnXBGU08yCTl7wSgiwbyA1CI=","qw2H3MqjNE1OcI8EE5kjLoaRrucguamat/hjT+fJFS0=","TtWisxkCD12d93zYhBEcavGz5i/0U8SBkxnc2qfCBvw=","vemyaMj0Na1LMjbB/9Dmkq8T+jAb3o+yCESgAayUABU="],"logIndex":"34812627","rootHash":"+ce/n7ywQMI84KqPfAKGKMwdivhMAlf7XwPKYBidxYM=","treeSize":"34812628"},"integratedTime":"1734628193","kindVersion":{"kind":"dsse","version":"0.0.1"},"logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="},"logIndex":"156716889"}]},"version":1}],"publisher":{"environment":null,"kind":"GitHub","repository":"sigstore/sigstore-python","workflow":"release.yml"}}],"version":1} diff --git a/test/test_cli.py b/test/test_cli.py index a4e7298..e025786 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -21,7 +21,7 @@ get_identity_token, main, ) -from pypi_attestations._impl import Attestation, AttestationError +from pypi_attestations._impl import Attestation, AttestationError, Distribution ONLINE_TESTS = "CI" in os.environ or "TEST_INTERACTIVE" in os.environ online = pytest.mark.skipif(not ONLINE_TESTS, reason="online tests not enabled") @@ -41,6 +41,8 @@ pypi_sdist_filename = pypi_sdist_url.split("/")[-1] pypi_wheel_abbrev = f"sigstore/{pypi_wheel_filename}" pypi_sdist_abbrev = f"sigstore/{pypi_sdist_filename}" +pypi_sdist_path = _ASSETS / pypi_sdist_filename +pypi_sdist_provenance_path = _ASSETS / f"{pypi_sdist_filename}.provenance" def run_main_with_command(cmd: list[str]) -> None: @@ -386,6 +388,21 @@ def test_verify_pypi_command( assert f"OK: {filename}" in caplog.text +def test_verify_pypi_command_with_local_files(caplog: pytest.LogCaptureFixture) -> None: + run_main_with_command( + [ + "verify", + "pypi", + "--repository", + "https://github.com/sigstore/sigstore-python", + "--provenance-file", + pypi_sdist_provenance_path.as_posix(), + pypi_sdist_path.as_posix(), + ] + ) + assert f"OK: {pypi_sdist_filename}" in caplog.text + + @online def test_verify_pypi_command_env_fail(caplog: pytest.LogCaptureFixture) -> None: with pytest.raises(SystemExit): @@ -461,41 +478,6 @@ def test_verify_pypi_invalid_url( assert "Unsupported/invalid URL" in caplog.text -def test_verify_pypi_invalid_file_name_url( - caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch -) -> None: - # Failure because file is neither a wheer nor a sdist - monkeypatch.setattr(pypi_attestations._cli, "_download_file", lambda url, dest: None) - with pytest.raises(SystemExit): - run_main_with_command( - [ - "verify", - "pypi", - "--repository", - "https://github.com/sigstore/sigstore-python", - pypi_wheel_url + ".invalid_ext", - ] - ) - assert ( - "URL should point to a wheel (*.whl) or a source distribution (*.zip or *.tar.gz)" - in caplog.text - ) - - caplog.clear() - - with pytest.raises(SystemExit): - run_main_with_command( - [ - "verify", - "pypi", - "--repository", - "https://github.com/sigstore/sigstore-python", - pypi_wheel_url + "/invalid-wheel-name-9.9.9-.whl", - ] - ) - assert "Invalid wheel filename" in caplog.text - - def test_verify_pypi_invalid_sdist_filename_pypi( caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -573,7 +555,11 @@ def test_verify_pypi_error_getting_provenance( expected_error: str, ) -> None: # Failure to get provenance from PyPI - monkeypatch.setattr(pypi_attestations._cli, "_download_file", lambda url, dest: None) + monkeypatch.setattr( + pypi_attestations._cli, + "_get_distribution_from_arg", + lambda arg: Distribution(name=pypi_wheel_filename, digest="a"), + ) response = requests.Response() response.status_code = status_code monkeypatch.setattr(requests, "get", lambda url: response) @@ -633,7 +619,11 @@ def test_verify_pypi_error_validating_provenance( monkeypatch: pytest.MonkeyPatch, ) -> None: # Failure to validate provenance JSON - monkeypatch.setattr(pypi_attestations._cli, "_download_file", lambda url, dest: None) + monkeypatch.setattr( + pypi_attestations._cli, + "_get_distribution_from_arg", + lambda arg: Distribution(name=pypi_wheel_filename, digest="a"), + ) response = stub(status_code=200, raise_for_status=lambda: None, text="not json") response.status_code = 200 monkeypatch.setattr(requests, "get", lambda url: response) @@ -716,3 +706,63 @@ def test_verify_pypi_command_invalid_repository_argument( ) assert expected_error in caplog.text + + +def test_verify_pypi_command_local_nonexistent_artifact(caplog: pytest.LogCaptureFixture) -> None: + with pytest.raises(SystemExit): + run_main_with_command( + [ + "verify", + "pypi", + "--repository", + "https://github.com/sigstore/sigstore-python", + "--provenance-file", + pypi_sdist_provenance_path.as_posix(), + "nonexistent-artifact.whl", + ] + ) + assert "File does not exist: nonexistent-artifact.whl" in caplog.text + + +def test_verify_pypi_command_local_nonexistent_provenance(caplog: pytest.LogCaptureFixture) -> None: + with pytest.raises(SystemExit): + run_main_with_command( + [ + "verify", + "pypi", + "--repository", + "https://github.com/sigstore/sigstore-python", + "--provenance-file", + "nonexistent-provenance.json", + pypi_sdist_path.as_posix(), + ] + ) + assert "Provenance file does not exist: nonexistent-provenance.json" in caplog.text + + +def test_verify_pypi_command_local_invalid_provenance( + caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + pypi_attestations._cli, + "_get_distribution_from_arg", + lambda arg: Distribution(name=pypi_sdist_filename, digest="a"), + ) + + with tempfile.NamedTemporaryFile(suffix=".provenance") as f: + f.write(b"not a valid provenance") + f.flush() + with pytest.raises(SystemExit): + run_main_with_command( + [ + "verify", + "pypi", + "--repository", + "https://github.com/sigstore/sigstore-python", + "--provenance-file", + f.name, + pypi_sdist_path.as_posix(), + ] + ) + + assert "Invalid provenance" in caplog.text