diff --git a/README.md b/README.md index 5e43bd3..84a171f 100644 --- a/README.md +++ b/README.md @@ -84,14 +84,14 @@ print(bundle.to_json()) ## Usage as a command line tool > [!IMPORTANT] -> The `python -m pypi_attestations` CLI is intended primarily for +> The `pypi-attestations` CLI is intended primarily for > experimentation, and is not considered a stable interface for > generating or verifying attestations. Users are encouraged to > generate attestations using [the official PyPA publishing action] > or via this package's [public Python APIs]. ````bash -python -m pypi_attestations --help +pypi-attestations --help usage: pypi-attestation [-h] [-v] [-V] COMMAND ... Sign, inspect or verify PEP 740 attestations @@ -119,7 +119,7 @@ options: ```bash # Generate a whl file make package -python -m pypi_attestations sign dist/pypi_attestations-*.whl +pypi-attestations sign dist/pypi_attestations-*.whl ``` ### Inspecting a PEP 740 Attestation @@ -129,7 +129,7 @@ python -m pypi_attestations sign dist/pypi_attestations-*.whl > the attestation. ```bash -python -m pypi_attestations inspect dist/pypi_attestations-*.whl.publish.attestation +pypi-attestations inspect dist/pypi_attestations-*.whl.publish.attestation ``` ### Verifying a PEP 740 Attestation @@ -140,7 +140,7 @@ python -m pypi_attestations inspect dist/pypi_attestations-*.whl.publish.attesta > workflow that generated the attestation. The format of that identity ```bash -python -m pypi_attestations verify --staging \ +pypi-attestations verify attestation --staging \ --identity william@yossarian.net \ test/assets/rfc8785-0.1.2-py3-none-any.whl ``` @@ -148,6 +148,23 @@ python -m pypi_attestations verify --staging \ The attestation present in the test has been generated using the staging environment of Sigstore and signed by the identity `william@yossarian.net`. +### Verifying a PyPI package +> [!NOTE] +> The URL must be a direct link to the distribution artifact hosted by PyPI. +> These can be found in the "Download files" section of the project's page, +> e.g: https://pypi.org/project/sigstore/#files + +```bash +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 +``` + +This command downloads the artifact from the given URL and gets its provenance +from PyPI. The artifact is then verified against the provenance, while also +checking that the provenance's signing identity matches the repository specified +by the user. + + [PEP 740]: https://peps.python.org/pep-0740/ [here]: https://trailofbits.github.io/pypi-attestations diff --git a/pyproject.toml b/pyproject.toml index aef3302..c942b57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,8 @@ dependencies = [ "packaging", "pyasn1 ~= 0.6", "pydantic >= 2.10.0", + "requests", + "rfc3986", "sigstore >= 3.5.3, < 3.7", "sigstore-protobuf-specs", ] @@ -28,7 +30,7 @@ test = ["pytest", "pytest-cov", "pretend", "coverage[toml]"] lint = [ # NOTE: ruff is under active development, so we pin conservatively here # and let Dependabot periodically perform this update. - "ruff ~= 0.2", + "ruff ~= 0.9", "mypy >= 1.0", "types-html5lib", "types-requests", @@ -39,6 +41,8 @@ lint = [ ] dev = ["pypi-attestations[doc,test,lint]", "build"] +[project.scripts] +pypi-attestations = "pypi_attestations._cli:main" [project.urls] Homepage = "https://pypi.org/project/pypi-attestations" @@ -51,7 +55,7 @@ name = "pypi_attestations" [tool.coverage.run] # don't attempt code coverage for the CLI entrypoints -omit = ["src/pypi_attestations/_cli.py", "src/pypi_attestations/__main__.py"] +omit = ["src/pypi_attestations/__main__.py"] [tool.mypy] mypy_path = "src" @@ -80,11 +84,10 @@ target-version = "py39" [tool.ruff.lint] select = ["E", "F", "I", "W", "UP", "ANN", "D", "COM", "ISC", "TCH", "SLF"] -# ANN101 and ANN102 are deprecated # D203 and D213 are incompatible with D211 and D212 respectively. # COM812 and ISC001 can cause conflicts when using ruff as a formatter. # See https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules. -ignore = ["ANN101", "ANN102", "D203", "D213", "COM812", "ISC001"] +ignore = ["D203", "D213", "COM812", "ISC001"] # Needed since Pydantic relies on runtime type annotations, and we target Python versions # < 3.10. See https://docs.astral.sh/ruff/rules/non-pep604-annotation/#why-is-this-bad pyupgrade.keep-runtime-typing = true @@ -104,7 +107,6 @@ pyupgrade.keep-runtime-typing = true exclude = [ "env", "test", - "src/pypi_attestations/_cli.py", "src/pypi_attestations/__main__.py", ] ignore-semiprivate = true diff --git a/src/pypi_attestations/_cli.py b/src/pypi_attestations/_cli.py index 56b04ed..ebc389b 100644 --- a/src/pypi_attestations/_cli.py +++ b/src/pypi_attestations/_cli.py @@ -1,3 +1,5 @@ +"""Implementation of the CLI for pypi-attestations.""" + from __future__ import annotations import argparse @@ -5,18 +7,32 @@ import logging import typing from pathlib import Path +from tempfile import TemporaryDirectory +import requests import sigstore.oidc from cryptography import x509 +from packaging.utils import ( + InvalidSdistFilename, + InvalidWheelFilename, + parse_sdist_filename, + parse_wheel_filename, +) from pydantic import ValidationError +from rfc3986 import exceptions, uri_reference, validators from sigstore.oidc import IdentityError, IdentityToken, Issuer from sigstore.sign import SigningContext from sigstore.verify import policy from pypi_attestations import Attestation, AttestationError, VerificationError, __version__ -from pypi_attestations._impl import Distribution - -if typing.TYPE_CHECKING: +from pypi_attestations._impl import ( + Distribution, + GitHubPublisher, + Provenance, + Publisher, +) + +if typing.TYPE_CHECKING: # pragma: no cover from collections.abc import Iterable from typing import NoReturn @@ -37,7 +53,7 @@ def _parser() -> argparse.ArgumentParser: ) parser = argparse.ArgumentParser( - prog="python -m pypi_attestations", + prog="pypi-attestations", description="Sign, inspect or verify PEP 740 attestations", parents=[parent_parser], formatter_class=argparse.ArgumentDefaultsHelpFormatter, @@ -82,21 +98,31 @@ def _parser() -> argparse.ArgumentParser: parents=[parent_parser], ) - verify_command.add_argument( + verify_subcommands = verify_command.add_subparsers( + required=True, + dest="verification_type", + metavar="VERIFICATION_TYPE", + help="The type of verification", + ) + verify_attestation_command = verify_subcommands.add_parser( + name="attestation", help="Verify a PEP-740 attestation" + ) + + verify_attestation_command.add_argument( "--identity", type=str, required=True, help="Signer identity", ) - verify_command.add_argument( + verify_attestation_command.add_argument( "--staging", action="store_true", default=False, help="Use the staging environment", ) - verify_command.add_argument( + verify_attestation_command.add_argument( "files", metavar="FILE", type=Path, @@ -104,6 +130,29 @@ def _parser() -> argparse.ArgumentParser: help="The file to sign", ) + verify_pypi_command = verify_subcommands.add_parser(name="pypi", help="Verify a PyPI release") + + verify_pypi_command.add_argument( + "distribution_url", + metavar="URL_PYPI_FILE", + type=str, + help='URL of the PyPI file to verify, i.e: "https://files.pythonhosted.org/..."', + ) + + verify_pypi_command.add_argument( + "--repository", + type=str, + required=True, + help="URL of the publishing GitHub or GitLab repository", + ) + + verify_pypi_command.add_argument( + "--staging", + action="store_true", + default=False, + help="Use the staging environment", + ) + inspect_command = subcommands.add_parser( name="inspect", help="Inspect one or more inputs", @@ -164,6 +213,84 @@ def get_identity_token(args: argparse.Namespace) -> IdentityToken: return issuer.identity_token() +def _download_file(url: str, dest: Path) -> None: + """Download a file into a given path.""" + response = requests.get(url, stream=True) + try: + response.raise_for_status() # Raise an exception for bad status codes + except requests.exceptions.HTTPError as e: + _die(f"Error downloading file: {e}") + + with open(dest, "wb") as f: + try: + for chunk in response.iter_content(chunk_size=1024): + f.write(chunk) + except requests.RequestException as e: + _die(f"Error downloading file: {e}") + + +def _get_provenance_from_pypi(filename: str) -> 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}") + + provenance_url = f"https://pypi.org/integrity/{name}/{version}/{filename}/provenance" + response = requests.get(provenance_url) + if response.status_code == 403: + _die("Access to provenance is temporarily disabled by PyPI administrators") + elif response.status_code == 404: + _die(f'Provenance for file "{filename}" was not found') + elif response.status_code != 200: + _die( + f"Unexpected error while downloading provenance file from PyPI, Integrity API " + f"returned status code: {response.status_code}" + ) + + try: + return Provenance.model_validate_json(response.text) + except ValidationError as validation_error: + _die(f"Invalid provenance: {validation_error}") + + +def _check_repository_identity(expected_repository_url: str, publisher: Publisher) -> None: + """Check that a repository url matches the given publisher's identity.""" + validator = ( + validators.Validator() + .allow_schemes("https") + .allow_hosts("github.com", "gitlab.com") + .require_presence_of("scheme", "host") + ) + try: + expected_uri = uri_reference(expected_repository_url) + validator.validate(expected_uri) + except exceptions.RFC3986Exception as e: + _die(f"Unsupported/invalid URL: {e}") + + actual_host = "github.com" if isinstance(publisher, GitHubPublisher) else "gitlab.com" + expected_host = expected_uri.host + if actual_host != expected_host: + _die( + f"Verification failed: provenance was signed by a {actual_host} repository, but " + f"expected a {expected_host} repository" + ) + + actual_repository = publisher.repository + # '/owner/repo' -> 'owner/repo' + expected_repository = expected_uri.path.lstrip("/") + if actual_repository != expected_repository: + _die( + f'Verification failed: provenance was signed by repository "{actual_repository}", ' + f'expected "{expected_repository}"' + ) + + def _sign(args: argparse.Namespace) -> None: """Sign the files passed as argument.""" try: @@ -254,11 +381,11 @@ def _inspect(args: argparse.Namespace) -> None: _logger.info(f"\tLog Index: {entry['logIndex']}") -def _verify(args: argparse.Namespace) -> None: +def _verify_attestation(args: argparse.Namespace) -> None: """Verify the files passed as argument.""" pol = policy.Identity(identity=args.identity) - # Validate that both the attestations and files exists + # Validate that both the attestations and files exist _validate_files(args.files, should_exist=True) _validate_files( (Path(f"{file_path}.publish.attestation") for file_path in args.files), @@ -267,16 +394,8 @@ def _verify(args: argparse.Namespace) -> None: inputs: list[Path] = [] for file_path in args.files: - # Collect only the inputs themselves, not their attestations. - # Attestation paths are inferred subsequently. - if file_path.name.endswith(".publish.attestation"): - _logger.warning(f"skipping attestation path while collecting file inputs: {file_path}") - continue inputs.append(file_path) - if not inputs: - _die("No inputs given; make sure you passed distributions and not attestations as inputs") - for input in inputs: attestation_path = Path(f"{input}.publish.attestation") try: @@ -297,7 +416,48 @@ def _verify(args: argparse.Namespace) -> None: _logger.info(f"OK: {attestation_path}") +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. + """ + validator = ( + validators.Validator() + .allow_schemes("https") + .allow_hosts("files.pythonhosted.org") + .require_presence_of("scheme", "host") + ) + try: + pypi_url = uri_reference(args.distribution_url) + validator.validate(pypi_url) + except exceptions.RFC3986Exception as e: + _die(f"Unsupported/invalid URL: {e}") + + 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) + 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}") + + def main() -> None: + """Dispatch the CLI subcommand.""" parser = _parser() args: argparse.Namespace = parser.parse_args() @@ -313,6 +473,9 @@ def main() -> None: if args.subcommand == "sign": _sign(args) elif args.subcommand == "verify": - _verify(args) + if args.verification_type == "attestation": + _verify_attestation(args) + elif args.verification_type == "pypi": + _verify_pypi(args) elif args.subcommand == "inspect": _inspect(args) diff --git a/test/test_cli.py b/test/test_cli.py index a75d345..d7a84d4 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -9,7 +9,9 @@ from pathlib import Path import pytest +import requests import sigstore.oidc +from pretend import raiser, stub from sigstore.oidc import IdentityError import pypi_attestations._cli @@ -19,7 +21,7 @@ get_identity_token, main, ) -from pypi_attestations._impl import Attestation +from pypi_attestations._impl import Attestation, AttestationError ONLINE_TESTS = "CI" in os.environ or "TEST_INTERACTIVE" in os.environ online = pytest.mark.skipif(not ONLINE_TESTS, reason="online tests not enabled") @@ -31,6 +33,11 @@ artifact_path = _ASSETS / "rfc8785-0.1.2-py3-none-any.whl" attestation_path = _ASSETS / "rfc8785-0.1.2-py3-none-any.whl.publish.attestation" +pypi_wheel_url = "https://files.pythonhosted.org/packages/70/f5/324edb6a802438e97e289992a41f81bb7a58a1cda2e49439e7e48896649e/sigstore-3.6.1-py3-none-any.whl" +pypi_sdist_url = "https://files.pythonhosted.org/packages/db/89/b982115aabe1068fd581d83d2a0b26b78e1e7ce6184e75003d173e15c0b3/sigstore-3.6.1.tar.gz" +pypi_wheel_filename = pypi_wheel_url.split("/")[-1] +pypi_sdist_filename = pypi_sdist_url.split("/")[-1] + def run_main_with_command(cmd: list[str]) -> None: """Helper method to run the main function with a given command.""" @@ -94,9 +101,7 @@ def test_sign_command(tmp_path: Path) -> None: @online -def test_sign_command_failures( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture -) -> None: +def test_sign_missing_file(caplog: pytest.LogCaptureFixture) -> None: # Missing file with pytest.raises(SystemExit): run_main_with_command( @@ -108,9 +113,10 @@ def test_sign_command_failures( ) assert "not_exist.txt is not a file" in caplog.text - caplog.clear() - # Signature already exists + +@online +def test_sign_signature_already_exists(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: artifact = tmp_path / artifact_path.with_suffix(".copy2.whl").name artifact.touch(exist_ok=False) @@ -128,7 +134,11 @@ def test_sign_command_failures( assert "already exists" in caplog.text caplog.clear() - # Invalid token + +@online +def test_sign_invalid_token( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: def return_invalid_token() -> str: return "invalid-token" @@ -139,14 +149,39 @@ def return_invalid_token() -> str: [ "sign", "--staging", - artifact.as_posix(), + artifact_path.as_posix(), ] ) assert "Failed to detect identity" in caplog.text -def test_inspect_command(caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch) -> None: +@online +def test_sign_invalid_artifact(caplog: pytest.LogCaptureFixture, tmp_path: Path) -> None: + artifact = tmp_path / "pkg-1.0.0.exe" + artifact.touch(exist_ok=False) + + with pytest.raises(SystemExit): + run_main_with_command(["sign", "--staging", artifact.as_posix()]) + + assert "Invalid Python package distribution" in caplog.text + + +@online +def test_sign_fail_to_sign( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, tmp_path: Path +) -> None: + monkeypatch.setattr(pypi_attestations._cli, "Attestation", stub(sign=raiser(AttestationError))) + copied_artifact = tmp_path / artifact_path.with_suffix(".copy.whl").name + shutil.copy(artifact_path, copied_artifact) + + with pytest.raises(SystemExit): + run_main_with_command(["sign", "--staging", copied_artifact.as_posix()]) + + assert "Failed to sign:" in caplog.text + + +def test_inspect_command(caplog: pytest.LogCaptureFixture) -> None: # Happy path run_main_with_command(["inspect", attestation_path.as_posix()]) assert attestation_path.as_posix() in caplog.text @@ -176,11 +211,12 @@ def test_inspect_command(caplog: pytest.LogCaptureFixture, monkeypatch: pytest.M assert "not_a_file.txt is not a file." in caplog.text -def test_verify_command(caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch) -> None: +def test_verify_attestation_command(caplog: pytest.LogCaptureFixture) -> None: # Happy path run_main_with_command( [ "verify", + "attestation", "--staging", "--identity", "william@yossarian.net", @@ -196,6 +232,7 @@ def test_verify_command(caplog: pytest.LogCaptureFixture, monkeypatch: pytest.Mo run_main_with_command( [ "verify", + "attestation", "--identity", "william@yossarian.net", artifact_path.as_posix(), @@ -208,7 +245,7 @@ def test_verify_command(caplog: pytest.LogCaptureFixture, monkeypatch: pytest.Mo assert "OK:" not in caplog.text -def test_verify_command_failures(caplog: pytest.LogCaptureFixture) -> None: +def test_verify_attestation_invalid_attestation(caplog: pytest.LogCaptureFixture) -> None: # Failure because not an attestation with pytest.raises(SystemExit): with tempfile.NamedTemporaryFile(suffix=".publish.attestation") as f: @@ -218,6 +255,7 @@ def test_verify_command_failures(caplog: pytest.LogCaptureFixture) -> None: run_main_with_command( [ "verify", + "attestation", "--staging", "--identity", "william@yossarian.net", @@ -226,12 +264,14 @@ def test_verify_command_failures(caplog: pytest.LogCaptureFixture) -> None: ) assert "Invalid attestation" in caplog.text + +def test_verify_attestation_missing_artifact(caplog: pytest.LogCaptureFixture) -> None: # Failure because missing package file - caplog.clear() with pytest.raises(SystemExit): run_main_with_command( [ "verify", + "attestation", "--staging", "--identity", "william@yossarian.net", @@ -241,13 +281,15 @@ def test_verify_command_failures(caplog: pytest.LogCaptureFixture) -> None: assert "not_a_file.txt is not a file." in caplog.text + +def test_verify_attestation_missing_attestation(caplog: pytest.LogCaptureFixture) -> None: # Failure because missing attestation file - caplog.clear() with pytest.raises(SystemExit): with tempfile.NamedTemporaryFile() as f: run_main_with_command( [ "verify", + "attestation", "--staging", "--identity", "william@yossarian.net", @@ -258,6 +300,43 @@ def test_verify_command_failures(caplog: pytest.LogCaptureFixture) -> None: assert "is not a file." in caplog.text +def test_verify_attestation_invalid_artifact( + caplog: pytest.LogCaptureFixture, tmp_path: Path +) -> None: + copied_artifact = tmp_path / artifact_path.with_suffix(".whl2").name + shutil.copy(artifact_path, copied_artifact) + copied_attestation = tmp_path / artifact_path.with_suffix(".whl2.publish.attestation").name + shutil.copy(attestation_path, copied_attestation) + + with pytest.raises(SystemExit): + run_main_with_command( + [ + "verify", + "attestation", + "--staging", + "--identity", + "william@yossarian.net", + copied_artifact.as_posix(), + ] + ) + assert "Invalid Python package distribution" in caplog.text + + +def test_get_identity_token_oauth_flow(monkeypatch: pytest.MonkeyPatch) -> None: + # If no ambient credential is available, default to the OAuth2 flow + monkeypatch.setattr(sigstore.oidc, "detect_credential", lambda: None) + identity_token = stub() + + class MockIssuer: + @staticmethod + def staging() -> stub: + return stub(identity_token=lambda: identity_token) + + monkeypatch.setattr(pypi_attestations._cli, "Issuer", MockIssuer) + + assert pypi_attestations._cli.get_identity_token(stub(staging=True)) == identity_token + + def test_validate_files(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: # Happy path file_1_exist = tmp_path / "file1" @@ -285,3 +364,290 @@ def test_validate_files(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> Non _validate_files([file_1_missing, file_2_exist], should_exist=False) assert f"{file_2_exist} already exists." in caplog.text + + +@online +def test_verify_pypi_command(caplog: pytest.LogCaptureFixture) -> None: + # Happy path wheel + run_main_with_command( + [ + "verify", + "pypi", + "--repository", + "https://github.com/sigstore/sigstore-python", + pypi_wheel_url, + ] + ) + assert f"OK: {pypi_wheel_filename}" in caplog.text + + caplog.clear() + + # Happy path sdist + run_main_with_command( + [ + "verify", + "pypi", + "--repository", + "https://github.com/sigstore/sigstore-python", + pypi_sdist_url, + ] + ) + assert f"OK: {pypi_sdist_filename}" in caplog.text + + caplog.clear() + + with pytest.raises(SystemExit): + # Failure from the Sigstore environment + run_main_with_command( + [ + "verify", + "pypi", + "--staging", + "--repository", + "https://github.com/sigstore/sigstore-python", + pypi_wheel_url, + ] + ) + assert ( + "Verification failed: failed to build chain: unable to get local issuer certificate" + in caplog.text + ) + assert "OK:" not in caplog.text + + +@online +def test_verify_pypi_command_failure_download( + caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + # Failure because URL does not exist + with pytest.raises(SystemExit): + run_main_with_command( + [ + "verify", + "pypi", + "--repository", + "https://github.com/sigstore/sigstore-python", + pypi_wheel_url + "invalid", + ] + ) + assert "Error downloading file: 404 Client Error" in caplog.text + + caplog.clear() + + # Download fails + response = stub( + raise_for_status=lambda: None, iter_content=raiser(requests.RequestException("myerror")) + ) + monkeypatch.setattr(requests, "get", lambda url, stream: response) + with pytest.raises(SystemExit): + run_main_with_command( + [ + "verify", + "pypi", + "--repository", + "https://github.com/sigstore/sigstore-python", + pypi_wheel_url, + ] + ) + assert "Error downloading file: myerror" in caplog.text + + +def test_verify_pypi_invalid_url( + caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + # Failure because file is not hosted on PyPI + with pytest.raises(SystemExit): + run_main_with_command( + [ + "verify", + "pypi", + "--repository", + "https://github.com/sigstore/sigstore-python", + "https://example.com/mypkg-1.2.0.tar.gz", + ] + ) + assert "Unsupported/invalid URL" in caplog.text + + +def test_verify_pypi_invalid_file_name( + 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 + + +@online +def test_verify_pypi_validation_fails( + caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + # Replace the actual wheel with another file + def _download_file(url: str, dest: Path) -> None: + with open(dest, "w") as f: + f.write("random wheel file") + + monkeypatch.setattr(pypi_attestations._cli, "_download_file", _download_file) + with pytest.raises(SystemExit): + run_main_with_command( + [ + "verify", + "pypi", + "--repository", + "https://github.com/sigstore/sigstore-python", + pypi_wheel_url, + ] + ) + assert f"Verification failed for {pypi_wheel_filename}" in caplog.text + + +@pytest.mark.parametrize( + "status_code,expected_error", + [ + (403, "Access to provenance is temporarily disabled by PyPI administrators"), + (404, f'Provenance for file "{pypi_wheel_filename}" was not found'), + ( + 500, + "Unexpected error while downloading provenance file from PyPI, Integrity API " + "returned status code: 500", + ), + ], +) +def test_verify_pypi_error_getting_provenance( + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, + status_code: int, + expected_error: str, +) -> None: + # Failure to get provenance from PyPI + monkeypatch.setattr(pypi_attestations._cli, "_download_file", lambda url, dest: None) + response = requests.Response() + response.status_code = status_code + monkeypatch.setattr(requests, "get", lambda url: response) + with pytest.raises(SystemExit): + run_main_with_command( + [ + "verify", + "pypi", + "--repository", + "https://github.com/sigstore/sigstore-python", + pypi_wheel_url, + ] + ) + assert expected_error in caplog.text + + +def test_verify_pypi_error_validating_provenance( + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Failure to validate provenance JSON + monkeypatch.setattr(pypi_attestations._cli, "_download_file", lambda url, dest: None) + response = stub(status_code=200, raise_for_status=lambda: None, text="not json") + response.status_code = 200 + monkeypatch.setattr(requests, "get", lambda url: response) + with pytest.raises(SystemExit): + run_main_with_command( + [ + "verify", + "pypi", + "--repository", + "https://github.com/sigstore/sigstore-python", + pypi_wheel_url, + ] + ) + assert "Invalid provenance: 1 validation error for Provenance" in caplog.text + + caplog.clear() + + +@online +@pytest.mark.parametrize( + "repository,expected_error", + [ + ( + "https://gitlab.com/sigstore/sigstore-python", + "Verification failed: provenance was signed by a github.com repository, but expected " + "a gitlab.com repository", + ), + ( + "https://github.com/other/repo", + 'Verification failed: provenance was signed by repository "sigstore/sigstore-python", ' + 'expected "other/repo"', + ), + ], +) +def test_verify_pypi_command_publisher_doesnt_match_user_repository( + caplog: pytest.LogCaptureFixture, + repository: str, + expected_error: str, +) -> None: + # Failure because URL does not exist + with pytest.raises(SystemExit): + run_main_with_command( + [ + "verify", + "pypi", + "--repository", + repository, + pypi_wheel_url, + ] + ) + + assert expected_error in caplog.text + + +@online +@pytest.mark.parametrize( + "repository,expected_error", + [ + # Only github.com or gitlab.com allowed + ("https://example.com/sigstore/sigstore-python", "Unsupported/invalid URL"), + # Only HTTPS allowed + ("http://github.com/other/repo", "Unsupported/invalid URL"), + ], +) +def test_verify_pypi_command_invalid_repository_argument( + caplog: pytest.LogCaptureFixture, + repository: str, + expected_error: str, +) -> None: + # Failure because URL does not exist + with pytest.raises(SystemExit): + run_main_with_command( + [ + "verify", + "pypi", + "--repository", + repository, + pypi_wheel_url, + ] + ) + + assert expected_error in caplog.text