|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
| 3 | +import base64 |
3 | 4 | import hashlib |
4 | 5 | import importlib.util |
5 | 6 | import io |
6 | 7 | import json |
7 | 8 | import os |
8 | 9 | import subprocess |
9 | | -import sys |
10 | 10 | import tarfile |
11 | 11 | import tempfile |
12 | 12 | import unittest |
@@ -126,7 +126,7 @@ def probe( |
126 | 126 | } |
127 | 127 | return { |
128 | 128 | "python": os.path.abspath(runtime_python), |
129 | | - "prefix": str(Path(sys.prefix).resolve()), |
| 129 | + "prefix": str(runtime_dirs[0].parent.resolve()), |
130 | 130 | "imports": imports, |
131 | 131 | "versions": {name: versions[name] for name in distributions}, |
132 | 132 | } |
@@ -154,7 +154,7 @@ def _attest_args( |
154 | 154 | distribution="demo_distribution", |
155 | 155 | runtime_package_dir=runtime_dirs, |
156 | 156 | runtime_module=modules, |
157 | | - runtime_python=Path(sys.executable), |
| 157 | + runtime_python=runtime_dirs[0].parent / "python", |
158 | 158 | dependency=["demo-dependency"], |
159 | 159 | runtime_label="test-runtime", |
160 | 160 | signing_key_file=signing_key_file, |
@@ -203,6 +203,7 @@ def _verify_signature( |
203 | 203 | signature: Path, |
204 | 204 | allowed_signers: Path, |
205 | 205 | signer: str, |
| 206 | + expected_fingerprint: object, |
206 | 207 | ) -> None: |
207 | 208 | if not allowed_signers.is_file() or signer != "test-runtime": |
208 | 209 | raise release_guard.GuardError("deployment manifest signature is invalid") |
@@ -258,7 +259,7 @@ def test_multi_package_signed_attestation_rechecks_current_runtime(self) -> None |
258 | 259 | ): |
259 | 260 | attested = release_guard.command_attest(attest_args) |
260 | 261 | verified = release_guard.command_verify(verify_args) |
261 | | - self.assertEqual(attested["schema_version"], 2) |
| 262 | + self.assertEqual(attested["schema_version"], 3) |
262 | 263 | self.assertEqual( |
263 | 264 | verified["packages"], ["demo_pkg", "demo_server"] |
264 | 265 | ) |
@@ -310,6 +311,84 @@ def test_manifest_edit_is_rejected_and_remote_mode_requires_fresh_signature(self |
310 | 311 | ), self.assertRaisesRegex(release_guard.GuardError, "signature is invalid"): |
311 | 312 | release_guard.command_verify(verify_args) |
312 | 313 |
|
| 314 | + def test_remote_mode_still_compares_signed_runtime_hashes_to_wheel(self) -> None: |
| 315 | + with tempfile.TemporaryDirectory() as temporary: |
| 316 | + root = Path(temporary) |
| 317 | + repo = self._init_repo(root) |
| 318 | + wheel, sdist = self._write_artifacts(root) |
| 319 | + runtime_dirs, modules = self._runtime(root) |
| 320 | + key_file = root / "signing.key" |
| 321 | + key_file.write_text("private test key", encoding="ascii") |
| 322 | + manifest = root / "deployment.json" |
| 323 | + with mock.patch.object( |
| 324 | + release_guard, |
| 325 | + "_runtime_probe", |
| 326 | + side_effect=self._probe(runtime_dirs, modules), |
| 327 | + ), mock.patch.object( |
| 328 | + release_guard, "_sign_manifest_file", side_effect=self._sign |
| 329 | + ): |
| 330 | + release_guard.command_attest( |
| 331 | + self._attest_args( |
| 332 | + repo, wheel, sdist, runtime_dirs, modules, key_file, manifest |
| 333 | + ) |
| 334 | + ) |
| 335 | + |
| 336 | + payload = json.loads(manifest.read_text(encoding="utf-8")) |
| 337 | + package_files = payload["runtime"]["package_files"]["demo_server"] |
| 338 | + package_files[next(iter(package_files))] = "0" * 64 |
| 339 | + manifest.write_text(json.dumps(payload), encoding="utf-8") |
| 340 | + self._sign(manifest, key_file) |
| 341 | + verify_args = self._verify_args( |
| 342 | + repo, |
| 343 | + wheel, |
| 344 | + sdist, |
| 345 | + key_file, |
| 346 | + manifest, |
| 347 | + allow_remote_attestation=True, |
| 348 | + ) |
| 349 | + with mock.patch.object( |
| 350 | + release_guard, |
| 351 | + "_verify_manifest_signature", |
| 352 | + side_effect=self._verify_signature, |
| 353 | + ), self.assertRaisesRegex( |
| 354 | + release_guard.GuardError, "deployed runtime package demo_server and wheel" |
| 355 | + ): |
| 356 | + release_guard.command_verify(verify_args) |
| 357 | + |
| 358 | + def test_component_profile_and_source_containment_are_mandatory(self) -> None: |
| 359 | + profile = release_guard.COMPONENT_PROFILES["RealtimeSTT"] |
| 360 | + self.assertEqual( |
| 361 | + tuple(item[0] for item in profile["packages"]), |
| 362 | + ("RealtimeSTT", "RealtimeSTT_server", "example_fastapi_server"), |
| 363 | + ) |
| 364 | + with tempfile.TemporaryDirectory() as temporary: |
| 365 | + repo = Path(temporary).resolve() |
| 366 | + with self.assertRaisesRegex(release_guard.GuardError, "stay inside"): |
| 367 | + release_guard._repo_member(repo, "../outside", "source package") |
| 368 | + |
| 369 | + def test_component_signer_key_fingerprint_is_pinned(self) -> None: |
| 370 | + with tempfile.TemporaryDirectory() as temporary: |
| 371 | + root = Path(temporary) |
| 372 | + manifest = root / "deployment.json" |
| 373 | + signature = root / "deployment.json.sig" |
| 374 | + allowed = root / "allowed_signers" |
| 375 | + manifest.write_text("{}", encoding="utf-8") |
| 376 | + signature.write_text("invalid", encoding="ascii") |
| 377 | + encoded = base64.b64encode(b"different-key").decode("ascii") |
| 378 | + allowed.write_text( |
| 379 | + f"linux-services ssh-ed25519 {encoded} test\n", encoding="utf-8" |
| 380 | + ) |
| 381 | + with self.assertRaisesRegex(release_guard.GuardError, "trust profile"): |
| 382 | + release_guard._verify_manifest_signature( |
| 383 | + manifest, |
| 384 | + signature, |
| 385 | + allowed, |
| 386 | + "linux-services", |
| 387 | + release_guard.COMPONENT_PROFILES["RealtimeTTS"][ |
| 388 | + "signer_fingerprint" |
| 389 | + ], |
| 390 | + ) |
| 391 | + |
313 | 392 | def test_remote_branch_and_tag_must_both_equal_release_head(self) -> None: |
314 | 393 | with tempfile.TemporaryDirectory() as temporary: |
315 | 394 | root = Path(temporary) |
@@ -337,13 +416,26 @@ def test_remote_branch_and_tag_must_both_equal_release_head(self) -> None: |
337 | 416 | text=True, |
338 | 417 | check=True, |
339 | 418 | ).stdout.strip() |
340 | | - result = release_guard._assert_remote_release_refs( |
341 | | - repo, "origin", "main", "v1.0", head, "1.0" |
342 | | - ) |
| 419 | + profile = { |
| 420 | + "remote_repository": "example.invalid/demo", |
| 421 | + "remote_branch": "main", |
| 422 | + } |
| 423 | + with mock.patch.object( |
| 424 | + release_guard, |
| 425 | + "_canonical_remote_url", |
| 426 | + return_value="example.invalid/demo", |
| 427 | + ): |
| 428 | + result = release_guard._assert_remote_release_refs( |
| 429 | + repo, "origin", "main", "v1.0", head, "1.0", profile |
| 430 | + ) |
343 | 431 | self.assertEqual(result["branch"], "refs/heads/main") |
344 | | - with self.assertRaisesRegex(release_guard.GuardError, "does not match"): |
| 432 | + with mock.patch.object( |
| 433 | + release_guard, |
| 434 | + "_canonical_remote_url", |
| 435 | + return_value="example.invalid/demo", |
| 436 | + ), self.assertRaisesRegex(release_guard.GuardError, "does not match"): |
345 | 437 | release_guard._assert_remote_release_refs( |
346 | | - repo, "origin", "main", "v2.0", head, "1.0" |
| 438 | + repo, "origin", "main", "v2.0", head, "1.0", profile |
347 | 439 | ) |
348 | 440 |
|
349 | 441 | def test_package_index_confirmation_compares_exact_hashes(self) -> None: |
@@ -376,6 +468,33 @@ def test_package_index_confirmation_compares_exact_hashes(self) -> None: |
376 | 468 | ) |
377 | 469 | self.assertEqual(confirmed, expected) |
378 | 470 |
|
| 471 | + response["urls"].append( |
| 472 | + { |
| 473 | + "filename": "unexpected.zip", |
| 474 | + "digests": {"sha256": "123"}, |
| 475 | + "size": 9, |
| 476 | + } |
| 477 | + ) |
| 478 | + encoded_with_extra = json.dumps(response).encode() |
| 479 | + with mock.patch.object( |
| 480 | + release_guard.urllib.request, |
| 481 | + "urlopen", |
| 482 | + return_value=io.BytesIO(encoded_with_extra), |
| 483 | + ), mock.patch.object( |
| 484 | + release_guard.time, "monotonic", side_effect=[0.0, 2.0] |
| 485 | + ), mock.patch.object( |
| 486 | + release_guard.time, "sleep" |
| 487 | + ), self.assertRaisesRegex( |
| 488 | + release_guard.GuardError, "artifact set is not exact" |
| 489 | + ): |
| 490 | + release_guard._confirm_published( |
| 491 | + "pypi", |
| 492 | + {"name": "demo", "version": "1.0"}, |
| 493 | + expected, |
| 494 | + "", |
| 495 | + 1, |
| 496 | + ) |
| 497 | + |
379 | 498 |
|
380 | 499 | if __name__ == "__main__": |
381 | 500 | unittest.main() |
0 commit comments