Skip to content

Commit 86cc7de

Browse files
authored
cli: Support friendlier syntax for verify pypi command (#87)
* cli: Support friendlier syntax for `verify pypi` command Signed-off-by: Facundo Tuesca <facundo.tuesca@trailofbits.com> * fixup! cli: Support friendlier syntax for `verify pypi` command --------- Signed-off-by: Facundo Tuesca <facundo.tuesca@trailofbits.com>
1 parent 2b53c10 commit 86cc7de

File tree

4 files changed

+170
-42
lines changed

4 files changed

+170
-42
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- The CLI subcommand `verify attestation` now supports `.slsa.attestation`
1313
files. When verifying an artifact, both `.publish.attestation` and
1414
`.slsa.attestation` files are used (if present).
15+
- The CLI subcommand `verify pypi` now supports a friendlier
16+
syntax to specify the artifact to verify. The artifact can now be
17+
specified with a `pypi:` prefix followed by the filename, e.g:
18+
`pypi:sampleproject-1.0.0.tar.gz`. The old way (passing
19+
the direct URL) is still supported.
1520

1621
## [0.0.21]
1722

README.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -142,19 +142,21 @@ pypi-attestations verify attestation \
142142
143143
### Verifying a PyPI package
144144
> [!NOTE]
145-
> The URL must be a direct link to the distribution artifact hosted by PyPI.
146-
> These can be found in the "Download files" section of the project's page,
147-
> e.g: https://pypi.org/project/sigstore/#files
148-
145+
> The package to verify can be passed either as a `pypi:` prefixed filename (e.g:
146+
> 'pypi:sampleproject-1.0.0-py3-none-any.whl'), or as a direct URL
147+
> to the artifact hosted by PyPI.
149148
```bash
149+
pypi-attestations verify pypi --repository https://github.com/sigstore/sigstore-python \
150+
pypi:sigstore-3.6.1-py3-none-any.whl
151+
152+
# or alternatively:
150153
pypi-attestations verify pypi --repository https://github.com/sigstore/sigstore-python \
151154
https://files.pythonhosted.org/packages/70/f5/324edb6a802438e97e289992a41f81bb7a58a1cda2e49439e7e48896649e/sigstore-3.6.1-py3-none-any.whl
152155
```
153156
154-
This command downloads the artifact from the given URL and gets its provenance
155-
from PyPI. The artifact is then verified against the provenance, while also
156-
checking that the provenance's signing identity matches the repository specified
157-
by the user.
157+
This command downloads the artifact and its provenance from PyPI. The artifact
158+
is then verified against the provenance, while also checking that the provenance's
159+
signing identity matches the repository specified by the user.
158160

159161

160162
[PEP 740]: https://peps.python.org/pep-0740/

src/pypi_attestations/_cli.py

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
from collections.abc import Iterable
3838
from typing import NoReturn
3939

40+
from rfc3986 import URIReference
41+
4042
logging.basicConfig(format="%(message)s", datefmt="[%X]", handlers=[logging.StreamHandler()])
4143
_logger = logging.getLogger(__name__)
4244
_logger.setLevel(logging.INFO)
@@ -134,10 +136,11 @@ def _parser() -> argparse.ArgumentParser:
134136
verify_pypi_command = verify_subcommands.add_parser(name="pypi", help="Verify a PyPI release")
135137

136138
verify_pypi_command.add_argument(
137-
"distribution_url",
138-
metavar="URL_PYPI_FILE",
139+
"distribution_file",
140+
metavar="PYPI_FILE",
139141
type=str,
140-
help='URL of the PyPI file to verify, i.e: "https://files.pythonhosted.org/..."',
142+
help="PyPI file to verify, can be either: (1) pypi:$FILE_NAME (e.g. "
143+
"pypi:sampleproject-1.0.0.tar.gz) or (2) A direct URL to files.pythonhosted.org",
141144
)
142145

143146
verify_pypi_command.add_argument(
@@ -230,6 +233,61 @@ def _download_file(url: str, dest: Path) -> None:
230233
_die(f"Error downloading file: {e}")
231234

232235

236+
def _get_direct_url_from_arg(arg: str) -> URIReference:
237+
"""Parse the artifact argument for the `verify pypi` subcommand.
238+
239+
The argument can be:
240+
- A pypi: prefixed filename (e.g. pypi:sampleproject-1.0.0.tar.gz)
241+
- A direct URL to a PyPI-hosted artifact
242+
"""
243+
direct_url = None
244+
245+
if arg.startswith("pypi:"):
246+
file_name = arg[5:]
247+
try:
248+
if file_name.endswith(".tar.gz") or file_name.endswith(".zip"):
249+
pkg_name, _ = parse_sdist_filename(file_name)
250+
elif file_name.endswith(".whl"):
251+
pkg_name, _, _, _ = parse_wheel_filename(file_name)
252+
else:
253+
_die("File should be a wheel (*.whl) or a source distribution (*.zip or *.tar.gz)")
254+
except (InvalidSdistFilename, InvalidWheelFilename) as e:
255+
_die(f"Invalid distribution filename: {e}")
256+
257+
provenance_url = f"https://pypi.org/simple/{pkg_name}"
258+
response = requests.get(
259+
provenance_url, headers={"Accept": "application/vnd.pypi.simple.v1+json"}
260+
)
261+
try:
262+
response.raise_for_status()
263+
except requests.exceptions.HTTPError as e:
264+
_die(f"Error trying to get information for '{pkg_name}' from PyPI: {e}")
265+
266+
response_json = response.json()
267+
for file_json in response_json.get("files", []):
268+
if file_json.get("filename", "") == file_name:
269+
direct_url = file_json.get("url", "")
270+
break
271+
if not direct_url:
272+
_die(f"Could not find the artifact '{file_name}' on PyPI")
273+
else:
274+
direct_url = arg
275+
276+
validator = (
277+
validators.Validator()
278+
.allow_schemes("https")
279+
.allow_hosts("files.pythonhosted.org")
280+
.require_presence_of("scheme", "host")
281+
)
282+
try:
283+
pypi_url = uri_reference(direct_url)
284+
validator.validate(pypi_url)
285+
except exceptions.RFC3986Exception as e:
286+
_die(f"Unsupported/invalid URL: {e}")
287+
288+
return pypi_url
289+
290+
233291
def _get_provenance_from_pypi(filename: str) -> Provenance:
234292
"""Use PyPI's integrity API to get a distribution's provenance."""
235293
try:
@@ -425,17 +483,7 @@ def _verify_pypi(args: argparse.Namespace) -> None:
425483
the provenance file hosted on PyPI (if any), and against the repository URL
426484
passed by the user as a CLI argument.
427485
"""
428-
validator = (
429-
validators.Validator()
430-
.allow_schemes("https")
431-
.allow_hosts("files.pythonhosted.org")
432-
.require_presence_of("scheme", "host")
433-
)
434-
try:
435-
pypi_url = uri_reference(args.distribution_url)
436-
validator.validate(pypi_url)
437-
except exceptions.RFC3986Exception as e:
438-
_die(f"Unsupported/invalid URL: {e}")
486+
pypi_url = _get_direct_url_from_arg(args.distribution_file)
439487

440488
with TemporaryDirectory() as temp_dir:
441489
dist_filename = pypi_url.path.split("/")[-1]

test/test_cli.py

Lines changed: 93 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
pypi_sdist_url = "https://files.pythonhosted.org/packages/db/89/b982115aabe1068fd581d83d2a0b26b78e1e7ce6184e75003d173e15c0b3/sigstore-3.6.1.tar.gz"
4040
pypi_wheel_filename = pypi_wheel_url.split("/")[-1]
4141
pypi_sdist_filename = pypi_sdist_url.split("/")[-1]
42+
pypi_wheel_abbrev = f"sigstore/{pypi_wheel_filename}"
43+
pypi_sdist_abbrev = f"sigstore/{pypi_sdist_filename}"
4244

4345

4446
def run_main_with_command(cmd: list[str]) -> None:
@@ -366,35 +368,33 @@ def test_validate_files(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> Non
366368

367369

368370
@online
369-
def test_verify_pypi_command(caplog: pytest.LogCaptureFixture) -> None:
370-
# Happy path wheel
371-
run_main_with_command(
372-
[
373-
"verify",
374-
"pypi",
375-
"--repository",
376-
"https://github.com/sigstore/sigstore-python",
377-
pypi_wheel_url,
378-
]
379-
)
380-
assert f"OK: {pypi_wheel_filename}" in caplog.text
381-
382-
caplog.clear()
383-
384-
# Happy path sdist
371+
@pytest.mark.parametrize(
372+
"url_argument, filename",
373+
[
374+
(pypi_wheel_url, pypi_wheel_filename),
375+
(pypi_sdist_url, pypi_sdist_filename),
376+
(f"pypi:{pypi_wheel_filename}", pypi_wheel_filename),
377+
(f"pypi:{pypi_sdist_filename}", pypi_sdist_filename),
378+
],
379+
)
380+
def test_verify_pypi_command(
381+
caplog: pytest.LogCaptureFixture, url_argument: str, filename: str
382+
) -> None:
383+
# Happy path
385384
run_main_with_command(
386385
[
387386
"verify",
388387
"pypi",
389388
"--repository",
390389
"https://github.com/sigstore/sigstore-python",
391-
pypi_sdist_url,
390+
url_argument,
392391
]
393392
)
394-
assert f"OK: {pypi_sdist_filename}" in caplog.text
393+
assert f"OK: {filename}" in caplog.text
395394

396-
caplog.clear()
397395

396+
@online
397+
def test_verify_pypi_command_env_fail(caplog: pytest.LogCaptureFixture) -> None:
398398
with pytest.raises(SystemExit):
399399
# Failure from the Sigstore environment
400400
run_main_with_command(
@@ -468,7 +468,7 @@ def test_verify_pypi_invalid_url(
468468
assert "Unsupported/invalid URL" in caplog.text
469469

470470

471-
def test_verify_pypi_invalid_file_name(
471+
def test_verify_pypi_invalid_file_name_url(
472472
caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
473473
) -> None:
474474
# Failure because file is neither a wheer nor a sdist
@@ -503,6 +503,41 @@ def test_verify_pypi_invalid_file_name(
503503
assert "Invalid wheel filename" in caplog.text
504504

505505

506+
def test_verify_pypi_invalid_sdist_filename_pypi(
507+
caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
508+
) -> None:
509+
# Failure because file is neither a wheer nor a sdist
510+
monkeypatch.setattr(pypi_attestations._cli, "_download_file", lambda url, dest: None)
511+
with pytest.raises(SystemExit):
512+
run_main_with_command(
513+
[
514+
"verify",
515+
"pypi",
516+
"--repository",
517+
"https://github.com/sigstore/sigstore-python",
518+
f"pypi:{pypi_wheel_filename}.invalid_ext",
519+
]
520+
)
521+
assert (
522+
"File should be a wheel (*.whl) or a source distribution (*.zip or *.tar.gz)" in caplog.text
523+
)
524+
525+
caplog.clear()
526+
527+
"""Test that invalid sdist filenames are properly handled."""
528+
with pytest.raises(SystemExit):
529+
run_main_with_command(
530+
[
531+
"verify",
532+
"pypi",
533+
"--repository",
534+
"https://github.com/sigstore/sigstore-python",
535+
"pypi:invalid-sdist-name.tar.gz", # Invalid sdist filename format
536+
]
537+
)
538+
assert "Invalid distribution filename:" in caplog.text
539+
540+
506541
@online
507542
def test_verify_pypi_validation_fails(
508543
caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
@@ -562,6 +597,44 @@ def test_verify_pypi_error_getting_provenance(
562597
assert expected_error in caplog.text
563598

564599

600+
def test_verify_pypi_error_finding_package_info(
601+
caplog: pytest.LogCaptureFixture,
602+
monkeypatch: pytest.MonkeyPatch,
603+
) -> None:
604+
response = stub(raise_for_status=raiser(requests.HTTPError("myerror")))
605+
monkeypatch.setattr(requests, "get", lambda url, headers: response)
606+
with pytest.raises(SystemExit):
607+
run_main_with_command(
608+
[
609+
"verify",
610+
"pypi",
611+
"--repository",
612+
"https://github.com/sigstore/sigstore-python",
613+
"pypi:somefile-1.0.0.tar.gz",
614+
]
615+
)
616+
assert "Error trying to get information for 'somefile' from PyPI: myerror" in caplog.text
617+
618+
619+
def test_verify_pypi_error_finding_artifact_url(
620+
caplog: pytest.LogCaptureFixture,
621+
monkeypatch: pytest.MonkeyPatch,
622+
) -> None:
623+
response = stub(raise_for_status=lambda: None, json=lambda: {"files": []})
624+
monkeypatch.setattr(requests, "get", lambda url, headers: response)
625+
with pytest.raises(SystemExit):
626+
run_main_with_command(
627+
[
628+
"verify",
629+
"pypi",
630+
"--repository",
631+
"https://github.com/sigstore/sigstore-python",
632+
"pypi:somefile-1.0.0.tar.gz",
633+
]
634+
)
635+
assert "Could not find the artifact 'somefile-1.0.0.tar.gz' on PyPI" in caplog.text
636+
637+
565638
def test_verify_pypi_error_validating_provenance(
566639
caplog: pytest.LogCaptureFixture,
567640
monkeypatch: pytest.MonkeyPatch,

0 commit comments

Comments
 (0)