Skip to content

Commit 2058510

Browse files
authored
Add API and CLI option for offline (no TUF refresh) verification (#104)
* Add `offline` option to CLI and API Signed-off-by: Facundo Tuesca <[email protected]> * ci: run offline tests in offline environment Signed-off-by: Facundo Tuesca <[email protected]> * Fix offline test not working offline Signed-off-by: Facundo Tuesca <[email protected]> --------- Signed-off-by: Facundo Tuesca <[email protected]>
1 parent cb83f6e commit 2058510

File tree

8 files changed

+140
-35
lines changed

8 files changed

+140
-35
lines changed

.github/workflows/tests.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,25 @@ jobs:
3838

3939
- name: test
4040
run: make test INSTALL_EXTRA=test
41+
42+
test-offline:
43+
runs-on: ubuntu-latest
44+
steps:
45+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
46+
with:
47+
persist-credentials: false
48+
49+
- uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5
50+
with:
51+
python-version: 3.13
52+
cache: "pip"
53+
cache-dependency-path: pyproject.toml
54+
allow-prereleases: true
55+
56+
- name: install firejail
57+
run: sudo apt-get install -y firejail
58+
59+
- name: run tests offline
60+
run: |
61+
make dev INSTALL_EXTRA=test
62+
firejail --noprofile --net=none --env=TEST_OFFLINE=1 make test-nocoverage

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- The `Attestation.verify(...)` API has been changed to accept an `offline`
13+
parameter that, when True, disables TUF refreshes.
14+
- The CLI `verify` commands now also accept an `--offline` flag that disables
15+
TUF refreshes. Additionally, when used with the `verify pypi` subcommand, the
16+
`--offline` flag enforces that the distribution and provenance file arguments
17+
must be local file paths.
18+
1019
## [0.0.22]
1120

1221
### Changed

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ test tests: $(VENV)/pyvenv.cfg
7070
pytest --cov=$(PY_IMPORT) $(T) $(TEST_ARGS) && \
7171
python -m coverage report -m $(COV_ARGS)
7272

73+
.PHONY: test-nocoverage
74+
test-nocoverage: $(VENV)/pyvenv.cfg
75+
. $(VENV_BIN)/activate && \
76+
pytest $(T) $(TEST_ARGS)
77+
7378
.PHONY: doc
7479
doc: $(VENV)/pyvenv.cfg
7580
. $(VENV_BIN)/activate && \

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ print(attestation.model_dump_json())
5151
# Verify an attestation against a Python artifact
5252
attestation_path = Path("test_package-0.0.1-py3-none-any.whl.attestation")
5353
attestation = Attestation.model_validate_json(attestation_path.read_bytes())
54-
verifier = Verifier.production()
5554
identity = policy.Identity(identity="[email protected]", issuer="https://accounts.google.com")
5655
attestation.verify(identity=identity, dist=dist)
5756
```

src/pypi_attestations/_cli.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,13 @@ def _parser() -> argparse.ArgumentParser:
125125
help="Use the staging environment",
126126
)
127127

128+
verify_attestation_command.add_argument(
129+
"--offline",
130+
action="store_true",
131+
default=False,
132+
help="Disable TUF refresh",
133+
)
134+
128135
verify_attestation_command.add_argument(
129136
"files",
130137
metavar="FILE",
@@ -157,6 +164,13 @@ def _parser() -> argparse.ArgumentParser:
157164
help="Use the staging environment",
158165
)
159166

167+
verify_pypi_command.add_argument(
168+
"--offline",
169+
action="store_true",
170+
default=False,
171+
help="Force use of local files and disable TUF refresh",
172+
)
173+
160174
verify_pypi_command.add_argument(
161175
"--provenance-file",
162176
type=Path,
@@ -239,7 +253,7 @@ def _download_file(url: str, dest: Path) -> None:
239253
_die(f"Error downloading file: {e}")
240254

241255

242-
def _get_distribution_from_arg(arg: str) -> Distribution:
256+
def _get_distribution_from_arg(arg: str, offline: bool) -> Distribution:
243257
"""Parse the artifact argument for the `verify pypi` subcommand.
244258
245259
The argument can be:
@@ -248,6 +262,8 @@ def _get_distribution_from_arg(arg: str) -> Distribution:
248262
- A path to a local file
249263
"""
250264
if arg.startswith("pypi:") or arg.startswith("https://"):
265+
if offline:
266+
_die("The '--offline' option can only be used with local files")
251267
pypi_url = _get_direct_url_from_arg(arg)
252268
dist_filename = pypi_url.path.split("/")[-1]
253269
with TemporaryDirectory() as temp_dir:
@@ -497,7 +513,7 @@ def _verify_attestation(args: argparse.Namespace) -> None:
497513
_die(f"Invalid Python package distribution: {e}")
498514

499515
try:
500-
attestation.verify(pol, dist, staging=args.staging)
516+
attestation.verify(pol, dist, staging=args.staging, offline=args.offline)
501517
except VerificationError as verification_error:
502518
_die(f"Verification failed for {file_path}: {verification_error}")
503519

@@ -512,9 +528,11 @@ def _verify_pypi(args: argparse.Namespace) -> None:
512528
from PyPI if not provided), and against the repository URL passed by the user
513529
as a CLI argument.
514530
"""
515-
dist = _get_distribution_from_arg(args.distribution_file)
531+
dist = _get_distribution_from_arg(args.distribution_file, offline=args.offline)
516532

517533
if args.provenance_file is None:
534+
if args.offline:
535+
_die("The '--offline' option can only be used with local files")
518536
provenance = _get_provenance_from_pypi(dist)
519537
else:
520538
if not args.provenance_file.exists():
@@ -530,7 +548,7 @@ def _verify_pypi(args: argparse.Namespace) -> None:
530548
_check_repository_identity(expected_repository_url=args.repository, publisher=publisher)
531549
policy = publisher._as_policy() # noqa: SLF001.
532550
for attestation in attestation_bundle.attestations:
533-
attestation.verify(policy, dist, staging=args.staging)
551+
attestation.verify(policy, dist, staging=args.staging, offline=args.offline)
534552
except VerificationError as verification_error:
535553
_die(f"Verification failed for {dist.name}: {verification_error}")
536554

src/pypi_attestations/_impl.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ def verify(
230230
dist: Distribution,
231231
*,
232232
staging: bool = False,
233+
offline: bool = False,
233234
) -> tuple[str, Optional[dict[str, Any]]]:
234235
"""Verify against an existing Python distribution.
235236
@@ -241,6 +242,9 @@ def verify(
241242
`staging` parameter can be toggled to enable the staging verifier
242243
instead.
243244
245+
If `offline` is `True`, the verifier will not attempt to refresh the
246+
TUF repository.
247+
244248
On failure, raises an appropriate subclass of `AttestationError`.
245249
"""
246250
# NOTE: Can't do `isinstance` with `Publisher` since it's
@@ -253,9 +257,9 @@ def verify(
253257
policy = identity
254258

255259
if staging:
256-
verifier = Verifier.staging()
260+
verifier = Verifier.staging(offline=offline)
257261
else:
258-
verifier = Verifier.production()
262+
verifier = Verifier.production(offline=offline)
259263

260264
bundle = self.to_bundle()
261265
try:

test/test_cli.py

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@
2323
)
2424
from pypi_attestations._impl import Attestation, AttestationError, Distribution
2525

26-
ONLINE_TESTS = "CI" in os.environ or "TEST_INTERACTIVE" in os.environ
27-
online = pytest.mark.skipif(not ONLINE_TESTS, reason="online tests not enabled")
26+
ONLINE_TESTS = (
27+
"CI" in os.environ or "TEST_INTERACTIVE" in os.environ
28+
) and "TEST_OFFLINE" not in os.environ
2829

30+
online = pytest.mark.skipif(not ONLINE_TESTS, reason="online tests not enabled")
2931

3032
_HERE = Path(__file__).parent
3133
_ASSETS = _HERE / "assets"
@@ -216,6 +218,7 @@ def test_verify_attestation_command(caplog: pytest.LogCaptureFixture) -> None:
216218
[
217219
"verify",
218220
"attestation",
221+
"--offline",
219222
"--identity",
220223
publish_attestation_identity,
221224
artifact_path.as_posix(),
@@ -233,6 +236,7 @@ def test_verify_attestation_command(caplog: pytest.LogCaptureFixture) -> None:
233236
"verify",
234237
"attestation",
235238
"--staging",
239+
"--offline",
236240
"--identity",
237241
publish_attestation_identity,
238242
artifact_path.as_posix(),
@@ -256,6 +260,7 @@ def test_verify_attestation_invalid_attestation(caplog: pytest.LogCaptureFixture
256260
[
257261
"verify",
258262
"attestation",
263+
"--offline",
259264
"--identity",
260265
publish_attestation_identity,
261266
fake_package_name.as_posix(),
@@ -271,6 +276,7 @@ def test_verify_attestation_missing_artifact(caplog: pytest.LogCaptureFixture) -
271276
[
272277
"verify",
273278
"attestation",
279+
"--offline",
274280
"--identity",
275281
publish_attestation_identity,
276282
"not_a_file.txt",
@@ -288,6 +294,7 @@ def test_verify_attestation_missing_attestation(caplog: pytest.LogCaptureFixture
288294
[
289295
"verify",
290296
"attestation",
297+
"--offline",
291298
"--identity",
292299
publish_attestation_identity,
293300
f.name,
@@ -310,6 +317,7 @@ def test_verify_attestation_invalid_artifact(
310317
[
311318
"verify",
312319
"attestation",
320+
"--offline",
313321
"--identity",
314322
publish_attestation_identity,
315323
copied_artifact.as_posix(),
@@ -393,6 +401,7 @@ def test_verify_pypi_command_with_local_files(caplog: pytest.LogCaptureFixture)
393401
[
394402
"verify",
395403
"pypi",
404+
"--offline",
396405
"--repository",
397406
"https://github.com/trailofbits/pypi-attestations",
398407
"--provenance-file",
@@ -403,6 +412,40 @@ def test_verify_pypi_command_with_local_files(caplog: pytest.LogCaptureFixture)
403412
assert f"OK: {pypi_sdist_filename}" in caplog.text
404413

405414

415+
def test_verify_pypi_command_offline_without_local_dist(caplog: pytest.LogCaptureFixture) -> None:
416+
with pytest.raises(SystemExit):
417+
run_main_with_command(
418+
[
419+
"verify",
420+
"pypi",
421+
"--offline",
422+
"--repository",
423+
"https://github.com/trailofbits/pypi-attestations",
424+
"--provenance-file",
425+
pypi_sdist_provenance_path.as_posix(),
426+
pypi_sdist_url,
427+
]
428+
)
429+
assert "The '--offline' option can only be used with local files" in caplog.text
430+
431+
432+
def test_verify_pypi_command_offline_without_local_provenance(
433+
caplog: pytest.LogCaptureFixture,
434+
) -> None:
435+
with pytest.raises(SystemExit):
436+
run_main_with_command(
437+
[
438+
"verify",
439+
"pypi",
440+
"--offline",
441+
"--repository",
442+
"https://github.com/trailofbits/pypi-attestations",
443+
pypi_sdist_path.as_posix(),
444+
]
445+
)
446+
assert "The '--offline' option can only be used with local files" in caplog.text
447+
448+
406449
@online
407450
def test_verify_pypi_command_env_fail(caplog: pytest.LogCaptureFixture) -> None:
408451
with pytest.raises(SystemExit):
@@ -558,7 +601,7 @@ def test_verify_pypi_error_getting_provenance(
558601
monkeypatch.setattr(
559602
pypi_attestations._cli,
560603
"_get_distribution_from_arg",
561-
lambda arg: Distribution(name=pypi_wheel_filename, digest="a"),
604+
lambda arg, offline: Distribution(name=pypi_wheel_filename, digest="a"),
562605
)
563606
response = requests.Response()
564607
response.status_code = status_code
@@ -622,7 +665,7 @@ def test_verify_pypi_error_validating_provenance(
622665
monkeypatch.setattr(
623666
pypi_attestations._cli,
624667
"_get_distribution_from_arg",
625-
lambda arg: Distribution(name=pypi_wheel_filename, digest="a"),
668+
lambda arg, offline: Distribution(name=pypi_wheel_filename, digest="a"),
626669
)
627670
response = stub(status_code=200, raise_for_status=lambda: None, text="not json")
628671
response.status_code = 200
@@ -714,6 +757,7 @@ def test_verify_pypi_command_local_nonexistent_artifact(caplog: pytest.LogCaptur
714757
[
715758
"verify",
716759
"pypi",
760+
"--offline",
717761
"--repository",
718762
"https://github.com/trailofbits/pypi-attestations",
719763
"--provenance-file",
@@ -730,6 +774,7 @@ def test_verify_pypi_command_local_nonexistent_provenance(caplog: pytest.LogCapt
730774
[
731775
"verify",
732776
"pypi",
777+
"--offline",
733778
"--repository",
734779
"https://github.com/trailofbits/pypi-attestations",
735780
"--provenance-file",
@@ -746,7 +791,7 @@ def test_verify_pypi_command_local_invalid_provenance(
746791
monkeypatch.setattr(
747792
pypi_attestations._cli,
748793
"_get_distribution_from_arg",
749-
lambda arg: Distribution(name=pypi_sdist_filename, digest="a"),
794+
lambda arg, offline: Distribution(name=pypi_sdist_filename, digest="a"),
750795
)
751796

752797
with tempfile.NamedTemporaryFile(suffix=".provenance") as f:
@@ -757,6 +802,7 @@ def test_verify_pypi_command_local_invalid_provenance(
757802
[
758803
"verify",
759804
"pypi",
805+
"--offline",
760806
"--repository",
761807
"https://github.com/trailofbits/pypi-attestations",
762808
"--provenance-file",

0 commit comments

Comments
 (0)