Skip to content

Commit 126eb9a

Browse files
committed
cli: Support friendlier syntax for verify pypi command
Signed-off-by: Facundo Tuesca <facundo.tuesca@trailofbits.com>
1 parent 2b53c10 commit 126eb9a

File tree

4 files changed

+126
-41
lines changed

4 files changed

+126
-41
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 attestation` now supports a friendlier
16+
syntax to specify the artifact to verify. The artifact can now be
17+
specified as `$PKG_NAME/$FILE_NAME`, e.g:
18+
`sampleproject/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 $PKG_NAME/$FILE_NAME (e.g:
146+
> 'sampleproject/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+
sigstore/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: 54 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,12 @@ 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 formatted as $PKG_NAME/$FILE_NAME, e.g: "
143+
"sampleproject/sampleproject-1.0.0.tar.gz. Direct URLs to the hosted file are also "
144+
"supported.",
141145
)
142146

143147
verify_pypi_command.add_argument(
@@ -230,6 +234,52 @@ def _download_file(url: str, dest: Path) -> None:
230234
_die(f"Error downloading file: {e}")
231235

232236

237+
def _get_direct_url_from_arg(arg: str) -> URIReference:
238+
"""Parse the artifact argument for the `verify pypi` subcommand.
239+
240+
The argument can be either a direct URL to a PyPI-hosted artifact,
241+
or a friendly-formatted alternative: '$PKG_NAME/$FILE_NAME'.
242+
"""
243+
direct_url = None
244+
components = arg.split("/")
245+
# We support passing the file argument as $PKG_NAME/$FILE_NAME
246+
if len(components) == 2:
247+
pkg_name, file_name = components
248+
249+
provenance_url = f"https://pypi.org/simple/{pkg_name}"
250+
response = requests.get(
251+
provenance_url, headers={"Accept": "application/vnd.pypi.simple.v1+json"}
252+
)
253+
try:
254+
response.raise_for_status()
255+
except requests.exceptions.HTTPError as e:
256+
_die(f"Error trying to get information for '{pkg_name}' from PyPI: {e}")
257+
258+
response_json = response.json()
259+
for file_json in response_json.get("files", []):
260+
if file_json.get("filename", "") == file_name:
261+
direct_url = file_json.get("url", "")
262+
break
263+
if not direct_url:
264+
_die(f"Could not find the artifact '{file_name}' for '{pkg_name}'")
265+
else:
266+
direct_url = arg
267+
268+
validator = (
269+
validators.Validator()
270+
.allow_schemes("https")
271+
.allow_hosts("files.pythonhosted.org")
272+
.require_presence_of("scheme", "host")
273+
)
274+
try:
275+
pypi_url = uri_reference(direct_url)
276+
validator.validate(pypi_url)
277+
except exceptions.RFC3986Exception as e:
278+
_die(f"Unsupported/invalid URL: {e}")
279+
280+
return pypi_url
281+
282+
233283
def _get_provenance_from_pypi(filename: str) -> Provenance:
234284
"""Use PyPI's integrity API to get a distribution's provenance."""
235285
try:
@@ -425,17 +475,7 @@ def _verify_pypi(args: argparse.Namespace) -> None:
425475
the provenance file hosted on PyPI (if any), and against the repository URL
426476
passed by the user as a CLI argument.
427477
"""
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}")
478+
pypi_url = _get_direct_url_from_arg(args.distribution_file)
439479

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

test/test_cli.py

Lines changed: 57 additions & 19 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+
(pypi_wheel_abbrev, pypi_wheel_filename),
377+
(pypi_sdist_abbrev, 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(
@@ -562,6 +562,44 @@ def test_verify_pypi_error_getting_provenance(
562562
assert expected_error in caplog.text
563563

564564

565+
def test_verify_pypi_error_finding_package_info(
566+
caplog: pytest.LogCaptureFixture,
567+
monkeypatch: pytest.MonkeyPatch,
568+
) -> None:
569+
response = stub(raise_for_status=raiser(requests.HTTPError("myerror")))
570+
monkeypatch.setattr(requests, "get", lambda url, headers: response)
571+
with pytest.raises(SystemExit):
572+
run_main_with_command(
573+
[
574+
"verify",
575+
"pypi",
576+
"--repository",
577+
"https://github.com/sigstore/sigstore-python",
578+
"somepkg/somefile",
579+
]
580+
)
581+
assert "Error trying to get information for 'somepkg' from PyPI: myerror" in caplog.text
582+
583+
584+
def test_verify_pypi_error_finding_artifact_url(
585+
caplog: pytest.LogCaptureFixture,
586+
monkeypatch: pytest.MonkeyPatch,
587+
) -> None:
588+
response = stub(raise_for_status=lambda: None, json=lambda: {"files": []})
589+
monkeypatch.setattr(requests, "get", lambda url, headers: response)
590+
with pytest.raises(SystemExit):
591+
run_main_with_command(
592+
[
593+
"verify",
594+
"pypi",
595+
"--repository",
596+
"https://github.com/sigstore/sigstore-python",
597+
"somepkg/somefile",
598+
]
599+
)
600+
assert "Could not find the artifact 'somefile' for 'somepkg'" in caplog.text
601+
602+
565603
def test_verify_pypi_error_validating_provenance(
566604
caplog: pytest.LogCaptureFixture,
567605
monkeypatch: pytest.MonkeyPatch,

0 commit comments

Comments
 (0)