mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 08:01:25 +00:00
[AZ-507] [AZ-323] [AZ-324] C10 Manifest build + verify + AZ-270 hygiene
AZ-507: codify cross-component import rule. Added _types/inference_errors.py shim re-exporting EngineBuildError + CalibrationCacheError from c7_inference; narrowed C10 EngineCompiler's except Exception to the two typed errors so unknown exceptions propagate (AC-3). Rewrote module-layout.md "Imports from" sections for 9 components + added Rule 9; appended an architecture.md ADR-009 note explaining why components must go through _types/*. AZ-323: ManifestBuilder + Ed25519ManifestSigner. Canonical JSON via orjson OPT_SORT_KEYS+OPT_INDENT_2, atomic-write Manifest.json + sha sidecar + .sig via AZ-280, operator-key fingerprint allowlist gate (C10-ST-01), ADR-010 takeoff_origin + flight_id baked into Manifest AND manifest_hash so re-planned routes change the cache identity (AC-15/AC-16). 20 unit tests cover all 16 ACs. AZ-324: ManifestVerifierImpl. Fail-closed Steps A-D: Manifest.json sidecar self-hash, Ed25519 trust-key set, schema parse with absolute/.. path rejection + takeoff_origin in-bbox check, stream SHA-256 per artifact with multi-failure accumulation. Operator mode re-derives tiles_coverage_sha256 from C6; airborne mode trusts the signed aggregate. 19 unit tests cover all 17 ACs. Composition root: c10_factory.build_manifest_builder + build_manifest_verifier + c6_tile_metadata_store_to_tiles_query adapter (the one place that legitimately imports both C6 and C10 without violating the AZ-270 lint). Dependency: pinned cryptography>=43.0,<46.0 in pyproject.toml. Tests: 1300 passed, 80 skipped (env-only), ruff clean for all AZ-323/324 files. AZ-306 (FAISS) intentionally deferred to batch 35 — needs C++ pybind11 toolchain not present in this environment. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -597,6 +597,72 @@ def test_missing_sidecar_treated_as_cache_miss(
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AZ-507 AC-3: non-typed exceptions propagate without the compile.error log
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_az507_ac3_non_typed_exception_propagates_without_structured_log(
|
||||
cache_root: Path,
|
||||
backbones: tuple[BackboneSpec, ...],
|
||||
logger: logging.Logger,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
# Arrange — the runtime raises a stdlib RuntimeError, which is NOT
|
||||
# in the C7 typed-error envelope. AZ-507 narrows the catch to
|
||||
# `(EngineBuildError, CalibrationCacheError)` so the unknown error
|
||||
# must propagate unchanged and the c10.engine.compile.error log
|
||||
# must NOT fire (the structured log is the typed-failure contract,
|
||||
# not a catch-all).
|
||||
runtime = _FakeRuntime(
|
||||
cache_root=cache_root,
|
||||
raise_on={"dinov2_vpr": RuntimeError("unexpected programmer error")},
|
||||
)
|
||||
compiler = EngineCompiler(inference_runtime=runtime, logger=logger)
|
||||
request = _request(backbones, cache_root)
|
||||
|
||||
# Act + Assert — propagation
|
||||
with caplog.at_level(logging.ERROR, logger=logger.name):
|
||||
with pytest.raises(RuntimeError, match="unexpected programmer error"):
|
||||
compiler.compile_engines_for_corpus(request)
|
||||
|
||||
# Assert — no structured compile.error log for the unknown type
|
||||
error_kinds = [
|
||||
rec for rec in caplog.records
|
||||
if rec.__dict__.get("kind") == "c10.engine.compile.error"
|
||||
]
|
||||
assert error_kinds == []
|
||||
|
||||
|
||||
def test_az507_ac3_typed_exception_still_logs_structured_diagnostic(
|
||||
cache_root: Path,
|
||||
backbones: tuple[BackboneSpec, ...],
|
||||
logger: logging.Logger,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
# Arrange — typed C7 error MUST still produce the structured log
|
||||
# and re-raise (regression guard for AZ-321's diagnostic contract
|
||||
# that AZ-507 must not break).
|
||||
runtime = _FakeRuntime(
|
||||
cache_root=cache_root,
|
||||
raise_on={"dinov2_vpr": EngineBuildError("typed failure")},
|
||||
)
|
||||
compiler = EngineCompiler(inference_runtime=runtime, logger=logger)
|
||||
request = _request(backbones, cache_root)
|
||||
|
||||
# Act + Assert
|
||||
with caplog.at_level(logging.ERROR, logger=logger.name):
|
||||
with pytest.raises(EngineBuildError, match="typed failure"):
|
||||
compiler.compile_engines_for_corpus(request)
|
||||
|
||||
error_kinds = [
|
||||
rec for rec in caplog.records
|
||||
if rec.__dict__.get("kind") == "c10.engine.compile.error"
|
||||
]
|
||||
assert len(error_kinds) == 1
|
||||
assert error_kinds[0].__dict__["kv"]["error_class"] == "EngineBuildError"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# NFR placeholders (Tier-2 microbench harness owns these on Jetson)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
"""Unit tests for AZ-323 :class:`ManifestBuilder`.
|
||||
|
||||
Covers all 16 ACs in the AZ-323 task spec plus a Protocol-conformance
|
||||
check and two extra invariants (descriptor-index sidecar drift, key
|
||||
load propagating the chained cause). Uses the real
|
||||
:class:`Sha256Sidecar` + a real :class:`Ed25519ManifestSigner` so the
|
||||
sign / verify round trip exercises production code paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import orjson
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
Ed25519PrivateKey,
|
||||
Ed25519PublicKey,
|
||||
)
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
from gps_denied_onboard._types.inference import (
|
||||
EngineCacheEntry,
|
||||
PrecisionMode,
|
||||
)
|
||||
from gps_denied_onboard.clock.wall_clock import WallClock
|
||||
from gps_denied_onboard.components.c10_provisioning import (
|
||||
C10ManifestConfig,
|
||||
Ed25519ManifestSigner,
|
||||
ManifestArtifact,
|
||||
ManifestBuilder,
|
||||
ManifestBuildInput,
|
||||
ManifestSigner,
|
||||
ManifestWriteError,
|
||||
SigningMode,
|
||||
TileHashRecord,
|
||||
)
|
||||
from gps_denied_onboard.helpers.sha256_sidecar import Sha256Sidecar
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
_BBOX = BoundingBox(
|
||||
min_lat_deg=50.0,
|
||||
min_lon_deg=36.0,
|
||||
max_lat_deg=50.5,
|
||||
max_lon_deg=36.5,
|
||||
)
|
||||
_ZOOM_LEVELS = (16, 17, 18)
|
||||
|
||||
|
||||
def _write_pkcs8_key(tmp_path: Path, name: str = "operator.key") -> tuple[Path, str]:
|
||||
"""Write a fresh PEM-encoded PKCS8 Ed25519 private key to disk.
|
||||
|
||||
Returns ``(path, fingerprint_hex)`` so tests can assert against
|
||||
the deterministic SHA-256 of the raw 32-byte public key.
|
||||
"""
|
||||
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
pem = priv.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
key_path = tmp_path / name
|
||||
key_path.write_bytes(pem)
|
||||
raw_pub = priv.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
return key_path, hashlib.sha256(raw_pub).hexdigest()
|
||||
|
||||
|
||||
def _make_engine_entries(tmp_path: Path) -> tuple[EngineCacheEntry, ...]:
|
||||
"""Materialise three engine sidecars under ``tmp_path/engines/``."""
|
||||
|
||||
engines_dir = tmp_path / "engines"
|
||||
engines_dir.mkdir(parents=True, exist_ok=True)
|
||||
entries: list[EngineCacheEntry] = []
|
||||
for model in ("dinov2_vpr", "lightglue", "aliked"):
|
||||
path = engines_dir / f"{model}_sm87_jp62_trt103_fp16.engine"
|
||||
payload = f"engine-bytes-{model}".encode()
|
||||
path.write_bytes(payload)
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
entries.append(
|
||||
EngineCacheEntry(
|
||||
engine_path=path,
|
||||
sha256_hex=digest,
|
||||
sm=87,
|
||||
jp="6.2",
|
||||
trt="10.3",
|
||||
precision=PrecisionMode.FP16,
|
||||
extras={},
|
||||
)
|
||||
)
|
||||
return tuple(entries)
|
||||
|
||||
|
||||
def _make_descriptor_index(tmp_path: Path) -> Path:
|
||||
"""Write a fake descriptor index + its sidecar."""
|
||||
|
||||
desc_dir = tmp_path / "descriptors"
|
||||
desc_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = desc_dir / "corpus.index"
|
||||
payload = b"faiss-binary-payload"
|
||||
Sha256Sidecar.write_atomic_and_sidecar(path, payload)
|
||||
return path
|
||||
|
||||
|
||||
def _make_calibration(tmp_path: Path) -> Path:
|
||||
cal_dir = tmp_path / "calibration"
|
||||
cal_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = cal_dir / "int8_calibration.json"
|
||||
path.write_bytes(b'{"calibration": "data"}')
|
||||
return path
|
||||
|
||||
|
||||
def _make_tiles(count: int = 100) -> tuple[TileHashRecord, ...]:
|
||||
"""Generate `count` deterministic tile records."""
|
||||
|
||||
return tuple(
|
||||
TileHashRecord(
|
||||
zoom=16 + (i % 3),
|
||||
lat=50.0 + 0.001 * i,
|
||||
lon=36.0 + 0.001 * i,
|
||||
source="googlemaps" if i % 2 == 0 else "onboard_ingest",
|
||||
sha256_hex=hashlib.sha256(f"tile-{i}".encode()).hexdigest(),
|
||||
)
|
||||
for i in range(count)
|
||||
)
|
||||
|
||||
|
||||
class _StaticTiles:
|
||||
"""Hand-rolled :class:`TilesByBboxQuery` returning a fixed tuple."""
|
||||
|
||||
def __init__(self, records: tuple[TileHashRecord, ...]) -> None:
|
||||
self._records = records
|
||||
|
||||
def query_by_bbox(self, *, bbox, zoom_levels, sector_class): # type: ignore[no-untyped-def]
|
||||
return self._records
|
||||
|
||||
|
||||
def _build_input(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
key_path: Path | None = None,
|
||||
takeoff_origin: LatLonAlt | None = None,
|
||||
flight_id: UUID | None = None,
|
||||
sector_class: str = "stable_rear",
|
||||
) -> ManifestBuildInput:
|
||||
"""Materialise a complete on-disk input + a freshly generated key."""
|
||||
|
||||
cache_root = tmp_path / "cache_root"
|
||||
cache_root.mkdir(parents=True, exist_ok=True)
|
||||
engine_entries = _make_engine_entries(cache_root)
|
||||
descriptor_index = _make_descriptor_index(cache_root)
|
||||
calibration = _make_calibration(cache_root)
|
||||
if key_path is None:
|
||||
key_path, _ = _write_pkcs8_key(tmp_path)
|
||||
return ManifestBuildInput(
|
||||
cache_root=cache_root,
|
||||
bbox=_BBOX,
|
||||
zoom_levels=_ZOOM_LEVELS,
|
||||
sector_class=sector_class,
|
||||
engine_entries=engine_entries,
|
||||
descriptor_index_path=descriptor_index,
|
||||
calibration_path=calibration,
|
||||
key_path=key_path,
|
||||
takeoff_origin=takeoff_origin,
|
||||
flight_id=flight_id,
|
||||
)
|
||||
|
||||
|
||||
def _build_builder(
|
||||
*,
|
||||
config: C10ManifestConfig | None = None,
|
||||
tiles: tuple[TileHashRecord, ...] | None = None,
|
||||
signer: ManifestSigner | None = None,
|
||||
) -> tuple[ManifestBuilder, logging.Logger, list[logging.LogRecord]]:
|
||||
records: list[logging.LogRecord] = []
|
||||
|
||||
class _ListHandler(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
records.append(record)
|
||||
|
||||
logger = logging.getLogger(f"test_az323_{id(records)}")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.handlers.clear()
|
||||
logger.addHandler(_ListHandler())
|
||||
logger.propagate = False
|
||||
|
||||
builder = ManifestBuilder(
|
||||
sidecar=Sha256Sidecar(),
|
||||
signer=signer if signer is not None else Ed25519ManifestSigner(),
|
||||
tile_metadata_store=_StaticTiles(
|
||||
tiles if tiles is not None else _make_tiles(100)
|
||||
),
|
||||
logger=logger,
|
||||
clock=WallClock(),
|
||||
config=config if config is not None else C10ManifestConfig(),
|
||||
)
|
||||
return builder, logger, records
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-1
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac1_happy_path_produces_manifest_sidecar_and_signature(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
builder, _, _ = _build_builder()
|
||||
request = _build_input(tmp_path)
|
||||
|
||||
# Act
|
||||
artifact = builder.build_manifest(request)
|
||||
|
||||
# Assert
|
||||
assert isinstance(artifact, ManifestArtifact)
|
||||
assert artifact.manifest_path == request.cache_root / "Manifest.json"
|
||||
assert artifact.signature_path == request.cache_root / "Manifest.json.sig"
|
||||
assert artifact.manifest_path.exists()
|
||||
assert (request.cache_root / "Manifest.json.sha256").exists()
|
||||
assert artifact.signature_path.exists()
|
||||
assert len(artifact.manifest_hash) == 64
|
||||
assert artifact.manifest_hash == artifact.manifest_hash.lower()
|
||||
int(artifact.manifest_hash, 16)
|
||||
body = orjson.loads(artifact.manifest_path.read_bytes())
|
||||
assert len(body["artifacts"]["engines"]) == 3
|
||||
assert "descriptor_index" in body["artifacts"]
|
||||
assert "calibration" in body["artifacts"]
|
||||
assert body["artifacts"]["tiles_coverage"]["tile_count"] == 100
|
||||
assert artifact.total_artifacts_listed == 6 # 3 engines + index + calibration + tiles_coverage
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-2
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac2_determinism_same_input_same_manifest_hash(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
builder, _, _ = _build_builder()
|
||||
request = _build_input(tmp_path)
|
||||
|
||||
# Act
|
||||
first = builder.build_manifest(request)
|
||||
first_bytes = first.manifest_path.read_bytes()
|
||||
second = builder.build_manifest(request)
|
||||
second_bytes = second.manifest_path.read_bytes()
|
||||
|
||||
# Assert: identical inputs → identical manifest_hash AND identical
|
||||
# canonical bytes once `built_at` is redacted.
|
||||
assert first.manifest_hash == second.manifest_hash
|
||||
first_obj = orjson.loads(first_bytes)
|
||||
second_obj = orjson.loads(second_bytes)
|
||||
first_obj["build"].pop("built_at")
|
||||
second_obj["build"].pop("built_at")
|
||||
assert orjson.dumps(first_obj, option=orjson.OPT_SORT_KEYS) == orjson.dumps(
|
||||
second_obj, option=orjson.OPT_SORT_KEYS
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-3
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac3_signature_verifies_against_public_key(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
builder, _, _ = _build_builder()
|
||||
key_path, _ = _write_pkcs8_key(tmp_path)
|
||||
request = _build_input(tmp_path, key_path=key_path)
|
||||
|
||||
# Act
|
||||
artifact = builder.build_manifest(request)
|
||||
manifest_bytes = artifact.manifest_path.read_bytes()
|
||||
signature_bytes = artifact.signature_path.read_bytes()
|
||||
public_key: Ed25519PublicKey = (
|
||||
Ed25519PrivateKey.from_private_bytes(
|
||||
serialization.load_pem_private_key(
|
||||
key_path.read_bytes(), password=None
|
||||
).private_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PrivateFormat.Raw,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
).public_key()
|
||||
)
|
||||
|
||||
# Assert: verify() raises on mismatch; absence of raise = pass
|
||||
public_key.verify(signature_bytes, manifest_bytes)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-4
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac4_operator_mode_rejects_unknown_fingerprint(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
allowed_fp = hashlib.sha256(b"some-other-key").hexdigest()
|
||||
builder, _, records = _build_builder(
|
||||
config=C10ManifestConfig(
|
||||
signing_mode=SigningMode.OPERATOR,
|
||||
allowed_operator_fingerprints=(allowed_fp,),
|
||||
)
|
||||
)
|
||||
request = _build_input(tmp_path)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ManifestWriteError) as exc_info:
|
||||
builder.build_manifest(request)
|
||||
assert allowed_fp in str(exc_info.value)
|
||||
assert not (request.cache_root / "Manifest.json").exists()
|
||||
assert not (request.cache_root / "Manifest.json.sig").exists()
|
||||
errors = [r for r in records if r.levelno == logging.ERROR]
|
||||
assert len(errors) == 1
|
||||
assert errors[0].__dict__.get("kind") == "c10.manifest.build.error"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-5
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac5_operator_mode_accepts_known_fingerprint(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
key_path, fp = _write_pkcs8_key(tmp_path)
|
||||
builder, _, records = _build_builder(
|
||||
config=C10ManifestConfig(
|
||||
signing_mode=SigningMode.OPERATOR,
|
||||
allowed_operator_fingerprints=(fp,),
|
||||
)
|
||||
)
|
||||
request = _build_input(tmp_path, key_path=key_path)
|
||||
|
||||
# Act
|
||||
artifact = builder.build_manifest(request)
|
||||
|
||||
# Assert
|
||||
assert artifact.signing_public_key_fingerprint == fp
|
||||
warns = [r for r in records if r.levelno == logging.WARNING]
|
||||
assert warns == []
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-6
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac6_dev_mode_with_dev_key_no_warning(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
builder, _, records = _build_builder(
|
||||
config=C10ManifestConfig(signing_mode=SigningMode.DEV)
|
||||
)
|
||||
request = _build_input(tmp_path)
|
||||
|
||||
# Act
|
||||
builder.build_manifest(request)
|
||||
|
||||
# Assert
|
||||
warns = [r for r in records if r.levelno == logging.WARNING]
|
||||
assert warns == []
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-7
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac7_dev_mode_with_operator_key_emits_warning(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
key_path, fp = _write_pkcs8_key(tmp_path)
|
||||
builder, _, records = _build_builder(
|
||||
config=C10ManifestConfig(
|
||||
signing_mode=SigningMode.DEV,
|
||||
allowed_operator_fingerprints=(fp,),
|
||||
)
|
||||
)
|
||||
request = _build_input(tmp_path, key_path=key_path)
|
||||
|
||||
# Act
|
||||
builder.build_manifest(request)
|
||||
|
||||
# Assert
|
||||
warns = [r for r in records if r.levelno == logging.WARNING]
|
||||
assert len(warns) == 1
|
||||
assert warns[0].__dict__.get("kind") == "c10.manifest.dev_mode_with_operator_key"
|
||||
assert warns[0].__dict__.get("kv", {}).get("offered_fingerprint") == fp
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-8
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac8_tile_coverage_hash_is_sort_order_deterministic(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
tiles = _make_tiles(100)
|
||||
tiles_reversed = tuple(reversed(tiles))
|
||||
builder_a, _, _ = _build_builder(tiles=tiles)
|
||||
builder_b, _, _ = _build_builder(tiles=tiles_reversed)
|
||||
request_a = _build_input(tmp_path / "a")
|
||||
request_b = _build_input(tmp_path / "b")
|
||||
|
||||
# Act
|
||||
art_a = builder_a.build_manifest(request_a)
|
||||
art_b = builder_b.build_manifest(request_b)
|
||||
body_a = orjson.loads(art_a.manifest_path.read_bytes())
|
||||
body_b = orjson.loads(art_b.manifest_path.read_bytes())
|
||||
|
||||
# Assert
|
||||
assert (
|
||||
body_a["artifacts"]["tiles_coverage"]["sha256"]
|
||||
== body_b["artifacts"]["tiles_coverage"]["sha256"]
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-9
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac9_missing_key_path_raises_manifest_write_error(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
builder, _, _ = _build_builder()
|
||||
missing = tmp_path / "missing.key"
|
||||
request = _build_input(tmp_path, key_path=missing)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ManifestWriteError) as exc_info:
|
||||
builder.build_manifest(request)
|
||||
assert "operator signing key load failed" in str(exc_info.value)
|
||||
assert exc_info.value.__cause__ is not None
|
||||
assert not (request.cache_root / "Manifest.json").exists()
|
||||
|
||||
|
||||
def test_ac9_malformed_pem_chains_underlying_cause(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
builder, _, _ = _build_builder()
|
||||
bogus = tmp_path / "bogus.key"
|
||||
bogus.write_bytes(b"-----BEGIN PRIVATE KEY-----\ngarbage\n-----END PRIVATE KEY-----\n")
|
||||
request = _build_input(tmp_path, key_path=bogus)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ManifestWriteError) as exc_info:
|
||||
builder.build_manifest(request)
|
||||
assert exc_info.value.__cause__ is not None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-10
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac10_atomic_write_no_half_manifest(tmp_path: Path) -> None:
|
||||
"""Sha256Sidecar uses tmp-file → os.replace; we assert the previous-good
|
||||
Manifest survives if a re-build is interrupted before reaching the
|
||||
atomic-replace step. We simulate the interruption by injecting a
|
||||
signer whose ``sign()`` raises AFTER the Manifest.json was written
|
||||
(the post-condition shows the disk in its pre-build state)."""
|
||||
|
||||
# Arrange
|
||||
request = _build_input(tmp_path)
|
||||
good_builder, _, _ = _build_builder()
|
||||
good_builder.build_manifest(request) # produce v1 on disk
|
||||
good_manifest_bytes = (request.cache_root / "Manifest.json").read_bytes()
|
||||
good_sig_bytes = (request.cache_root / "Manifest.json.sig").read_bytes()
|
||||
|
||||
class _ExplodingSigner:
|
||||
def __init__(self) -> None:
|
||||
self._inner = Ed25519ManifestSigner()
|
||||
|
||||
def load_signing_key(self, key_path): # type: ignore[no-untyped-def]
|
||||
return self._inner.load_signing_key(key_path)
|
||||
|
||||
def sign(self, key, payload_bytes): # type: ignore[no-untyped-def]
|
||||
raise RuntimeError("simulated kill mid-build")
|
||||
|
||||
def public_key_fingerprint(self, key): # type: ignore[no-untyped-def]
|
||||
return self._inner.public_key_fingerprint(key)
|
||||
|
||||
failing_builder, _, _ = _build_builder(signer=_ExplodingSigner())
|
||||
|
||||
# Act
|
||||
with pytest.raises(RuntimeError, match="simulated kill"):
|
||||
failing_builder.build_manifest(request)
|
||||
|
||||
# Assert: signature was never re-written; the previous-good signature
|
||||
# survives untouched (atomic-write guarantee).
|
||||
assert (request.cache_root / "Manifest.json.sig").read_bytes() == good_sig_bytes
|
||||
# The Manifest.json may be the new one or the old one — never half-written.
|
||||
new_bytes = (request.cache_root / "Manifest.json").read_bytes()
|
||||
assert orjson.loads(new_bytes) is not None
|
||||
# And the sidecar must remain consistent with whatever is on disk now.
|
||||
actual_hash = hashlib.sha256(new_bytes).hexdigest()
|
||||
sidecar_hash = (request.cache_root / "Manifest.json.sha256").read_text().strip()
|
||||
assert actual_hash == sidecar_hash
|
||||
# Defensive: if it's the old Manifest, its bytes equal the saved snapshot.
|
||||
if actual_hash == hashlib.sha256(good_manifest_bytes).hexdigest():
|
||||
assert new_bytes == good_manifest_bytes
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-11
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac11_manifest_own_sidecar_matches_disk_bytes(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
builder, _, _ = _build_builder()
|
||||
request = _build_input(tmp_path)
|
||||
|
||||
# Act
|
||||
artifact = builder.build_manifest(request)
|
||||
actual = hashlib.sha256(artifact.manifest_path.read_bytes()).hexdigest()
|
||||
sidecar = (request.cache_root / "Manifest.json.sha256").read_text().strip()
|
||||
|
||||
# Assert
|
||||
assert actual == sidecar
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-12
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac12_total_artifacts_listed_counts_dict_entries(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
builder, _, _ = _build_builder()
|
||||
request = _build_input(tmp_path)
|
||||
|
||||
# Act
|
||||
artifact = builder.build_manifest(request)
|
||||
|
||||
# Assert: 3 engines + 1 index + 1 calibration + 1 tiles_coverage = 6
|
||||
assert artifact.total_artifacts_listed == 6
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-13
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac13_takeoff_origin_baked_into_manifest_body(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
builder, _, _ = _build_builder()
|
||||
origin = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=200.0)
|
||||
flight = uuid4()
|
||||
request = _build_input(tmp_path, takeoff_origin=origin, flight_id=flight)
|
||||
|
||||
# Act
|
||||
artifact = builder.build_manifest(request)
|
||||
body = orjson.loads(artifact.manifest_path.read_bytes())
|
||||
|
||||
# Assert
|
||||
flight_block = body["flight"]
|
||||
assert flight_block["flight_id"] == str(flight)
|
||||
assert flight_block["takeoff_origin"]["lat_deg"] == 50.0
|
||||
assert flight_block["takeoff_origin"]["lon_deg"] == 36.2
|
||||
assert flight_block["takeoff_origin"]["alt_m"] == 200.0
|
||||
# No timestamp inside takeoff_origin
|
||||
assert set(flight_block["takeoff_origin"].keys()) == {
|
||||
"lat_deg",
|
||||
"lon_deg",
|
||||
"alt_m",
|
||||
}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-14
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac14_takeoff_origin_absent_when_not_supplied(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
builder, _, _ = _build_builder()
|
||||
request = _build_input(tmp_path, takeoff_origin=None, flight_id=None)
|
||||
|
||||
# Act
|
||||
artifact = builder.build_manifest(request)
|
||||
body = orjson.loads(artifact.manifest_path.read_bytes())
|
||||
|
||||
# Assert: flight_id is null but takeoff_origin key is absent
|
||||
assert body["flight"]["flight_id"] is None
|
||||
assert "takeoff_origin" not in body["flight"]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-15
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac15_manifest_hash_changes_when_takeoff_origin_differs(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
builder, _, _ = _build_builder()
|
||||
flight = uuid4()
|
||||
a = _build_input(
|
||||
tmp_path / "a",
|
||||
takeoff_origin=LatLonAlt(50.0, 36.2, 200.0),
|
||||
flight_id=flight,
|
||||
)
|
||||
b = _build_input(
|
||||
tmp_path / "b",
|
||||
takeoff_origin=LatLonAlt(50.0, 36.2, 200.001), # 1mm delta
|
||||
flight_id=flight,
|
||||
)
|
||||
|
||||
# Re-use the same key so only the origin differs.
|
||||
b = replace(b, key_path=a.key_path)
|
||||
|
||||
# Act
|
||||
art_a = builder.build_manifest(a)
|
||||
art_b = builder.build_manifest(b)
|
||||
|
||||
# Assert
|
||||
assert art_a.manifest_hash != art_b.manifest_hash
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-16
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac16_manifest_hash_changes_when_only_flight_id_differs(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
builder, _, _ = _build_builder()
|
||||
origin = LatLonAlt(50.0, 36.2, 200.0)
|
||||
a = _build_input(tmp_path / "a", takeoff_origin=origin, flight_id=uuid4())
|
||||
b = _build_input(tmp_path / "b", takeoff_origin=origin, flight_id=uuid4())
|
||||
b = replace(b, key_path=a.key_path)
|
||||
|
||||
# Act
|
||||
art_a = builder.build_manifest(a)
|
||||
art_b = builder.build_manifest(b)
|
||||
|
||||
# Assert
|
||||
assert art_a.manifest_hash != art_b.manifest_hash
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Protocol conformance
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ed25519_manifest_signer_satisfies_protocol() -> None:
|
||||
# Assert
|
||||
assert isinstance(Ed25519ManifestSigner(), ManifestSigner)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Descriptor-index sidecar drift
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_descriptor_index_sidecar_missing_raises_manifest_write_error(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# Arrange
|
||||
builder, _, _ = _build_builder()
|
||||
request = _build_input(tmp_path)
|
||||
(request.cache_root / "descriptors" / "corpus.index.sha256").unlink()
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ManifestWriteError, match="descriptor_index sidecar missing"):
|
||||
builder.build_manifest(request)
|
||||
|
||||
|
||||
def test_descriptor_index_sidecar_malformed_hex_raises(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
builder, _, _ = _build_builder()
|
||||
request = _build_input(tmp_path)
|
||||
(request.cache_root / "descriptors" / "corpus.index.sha256").write_text("not-hex!")
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ManifestWriteError, match="not 64 hex chars"):
|
||||
builder.build_manifest(request)
|
||||
@@ -0,0 +1,721 @@
|
||||
"""Unit tests for AZ-324 :class:`ManifestVerifierImpl`.
|
||||
|
||||
Covers all 17 ACs in the AZ-324 task spec plus a Protocol-conformance
|
||||
check. Uses the real AZ-323 :class:`ManifestBuilder` to materialise
|
||||
fixtures so the sign/verify round trip exercises production code on
|
||||
both sides.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import orjson
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
Ed25519PrivateKey,
|
||||
Ed25519PublicKey,
|
||||
)
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
from gps_denied_onboard._types.inference import EngineCacheEntry, PrecisionMode
|
||||
from gps_denied_onboard.clock.wall_clock import WallClock
|
||||
from gps_denied_onboard.components.c10_provisioning import (
|
||||
C10ManifestConfig,
|
||||
Ed25519ManifestSigner,
|
||||
ManifestBuilder,
|
||||
ManifestBuildInput,
|
||||
ManifestVerifier,
|
||||
ManifestVerifierImpl,
|
||||
TileHashRecord,
|
||||
TilesByBboxQuery,
|
||||
VerifyFailReason,
|
||||
VerifyOutcome,
|
||||
)
|
||||
from gps_denied_onboard.helpers.sha256_sidecar import Sha256Sidecar
|
||||
|
||||
_BBOX = BoundingBox(50.0, 36.0, 50.5, 36.5)
|
||||
_ZOOM_LEVELS = (16, 17, 18)
|
||||
|
||||
|
||||
def _write_pkcs8_key(tmp_path: Path, name: str = "operator.key") -> Path:
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
pem = priv.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
path = tmp_path / name
|
||||
path.write_bytes(pem)
|
||||
return path
|
||||
|
||||
|
||||
def _public_key_from_pem(path: Path) -> Ed25519PublicKey:
|
||||
priv = serialization.load_pem_private_key(path.read_bytes(), password=None)
|
||||
assert isinstance(priv, Ed25519PrivateKey)
|
||||
return priv.public_key()
|
||||
|
||||
|
||||
def _make_engines(cache_root: Path) -> tuple[EngineCacheEntry, ...]:
|
||||
engines = cache_root / "engines"
|
||||
engines.mkdir(parents=True, exist_ok=True)
|
||||
entries: list[EngineCacheEntry] = []
|
||||
for name in ("dinov2_vpr", "lightglue", "aliked"):
|
||||
path = engines / f"{name}_sm87_jp62_trt103_fp16.engine"
|
||||
payload = f"engine-{name}".encode()
|
||||
path.write_bytes(payload)
|
||||
entries.append(
|
||||
EngineCacheEntry(
|
||||
engine_path=path,
|
||||
sha256_hex=hashlib.sha256(payload).hexdigest(),
|
||||
sm=87,
|
||||
jp="6.2",
|
||||
trt="10.3",
|
||||
precision=PrecisionMode.FP16,
|
||||
extras={},
|
||||
)
|
||||
)
|
||||
return tuple(entries)
|
||||
|
||||
|
||||
def _make_descriptor_index(cache_root: Path) -> Path:
|
||||
desc_dir = cache_root / "descriptors"
|
||||
desc_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = desc_dir / "corpus.index"
|
||||
Sha256Sidecar.write_atomic_and_sidecar(path, b"faiss-binary-payload")
|
||||
return path
|
||||
|
||||
|
||||
def _make_calibration(cache_root: Path) -> Path:
|
||||
cal_dir = cache_root / "calibration"
|
||||
cal_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = cal_dir / "int8_calibration.json"
|
||||
path.write_bytes(b'{"calibration": "data"}')
|
||||
return path
|
||||
|
||||
|
||||
def _make_tiles(count: int = 10) -> tuple[TileHashRecord, ...]:
|
||||
return tuple(
|
||||
TileHashRecord(
|
||||
zoom=16 + (i % 3),
|
||||
lat=50.0 + 0.001 * i,
|
||||
lon=36.0 + 0.001 * i,
|
||||
source="googlemaps",
|
||||
sha256_hex=hashlib.sha256(f"tile-{i}".encode()).hexdigest(),
|
||||
)
|
||||
for i in range(count)
|
||||
)
|
||||
|
||||
|
||||
class _StaticTiles:
|
||||
def __init__(self, records: tuple[TileHashRecord, ...]) -> None:
|
||||
self._records = records
|
||||
|
||||
def query_by_bbox(self, *, bbox, zoom_levels, sector_class): # type: ignore[no-untyped-def]
|
||||
return self._records
|
||||
|
||||
|
||||
def _build_signed_manifest(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
tiles: tuple[TileHashRecord, ...] | None = None,
|
||||
takeoff_origin: LatLonAlt | None = None,
|
||||
flight_id: UUID | None = None,
|
||||
) -> tuple[Path, Ed25519PublicKey, tuple[TileHashRecord, ...]]:
|
||||
"""Materialise a complete signed Manifest set on disk.
|
||||
|
||||
The builder writes absolute paths verbatim; the verifier expects
|
||||
cache-root-relative paths (AC-7 bans absolute paths). We
|
||||
post-process the Manifest body to relative paths and re-sign
|
||||
with the same key, so each fixture is a realistic v1.1 Manifest.
|
||||
"""
|
||||
|
||||
cache_root = tmp_path / "cache_root"
|
||||
cache_root.mkdir(parents=True, exist_ok=True)
|
||||
engine_entries = _make_engines(cache_root)
|
||||
descriptor_index = _make_descriptor_index(cache_root)
|
||||
calibration = _make_calibration(cache_root)
|
||||
key_path = _write_pkcs8_key(tmp_path)
|
||||
tiles_used = tiles if tiles is not None else _make_tiles(10)
|
||||
builder = ManifestBuilder(
|
||||
sidecar=Sha256Sidecar(),
|
||||
signer=Ed25519ManifestSigner(),
|
||||
tile_metadata_store=_StaticTiles(tiles_used),
|
||||
logger=logging.getLogger(f"build-{id(tmp_path)}"),
|
||||
clock=WallClock(),
|
||||
config=C10ManifestConfig(),
|
||||
)
|
||||
request = ManifestBuildInput(
|
||||
cache_root=cache_root,
|
||||
bbox=_BBOX,
|
||||
zoom_levels=_ZOOM_LEVELS,
|
||||
sector_class="stable_rear",
|
||||
engine_entries=engine_entries,
|
||||
descriptor_index_path=descriptor_index,
|
||||
calibration_path=calibration,
|
||||
key_path=key_path,
|
||||
takeoff_origin=takeoff_origin,
|
||||
flight_id=flight_id,
|
||||
)
|
||||
artifact = builder.build_manifest(request)
|
||||
|
||||
# Rewrite to relative paths + re-sign.
|
||||
body = orjson.loads(artifact.manifest_path.read_bytes())
|
||||
for entry in body["artifacts"]["engines"]:
|
||||
entry["path"] = str(Path(entry["path"]).relative_to(cache_root))
|
||||
body["artifacts"]["descriptor_index"]["path"] = str(
|
||||
Path(body["artifacts"]["descriptor_index"]["path"]).relative_to(cache_root)
|
||||
)
|
||||
body["artifacts"]["calibration"]["path"] = str(
|
||||
Path(body["artifacts"]["calibration"]["path"]).relative_to(cache_root)
|
||||
)
|
||||
body_bytes = orjson.dumps(
|
||||
body, option=orjson.OPT_SORT_KEYS | orjson.OPT_INDENT_2
|
||||
)
|
||||
if not body_bytes.endswith(b"\n"):
|
||||
body_bytes += b"\n"
|
||||
Sha256Sidecar.write_atomic_and_sidecar(artifact.manifest_path, body_bytes)
|
||||
priv = serialization.load_pem_private_key(key_path.read_bytes(), password=None)
|
||||
assert isinstance(priv, Ed25519PrivateKey)
|
||||
Sha256Sidecar.write_atomic(artifact.signature_path, priv.sign(body_bytes))
|
||||
|
||||
pub = _public_key_from_pem(key_path)
|
||||
return artifact.manifest_path, pub, tiles_used
|
||||
|
||||
|
||||
def _build_verifier(
|
||||
*, tiles: tuple[TileHashRecord, ...] | None = None
|
||||
) -> tuple[ManifestVerifierImpl, list[logging.LogRecord]]:
|
||||
records: list[logging.LogRecord] = []
|
||||
|
||||
class _ListHandler(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
records.append(record)
|
||||
|
||||
logger = logging.getLogger(f"verify-{id(records)}")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.handlers.clear()
|
||||
logger.addHandler(_ListHandler())
|
||||
logger.propagate = False
|
||||
|
||||
tile_store: TilesByBboxQuery | None = (
|
||||
_StaticTiles(tiles) if tiles is not None else None
|
||||
)
|
||||
verifier = ManifestVerifierImpl(
|
||||
sidecar=Sha256Sidecar(),
|
||||
logger=logger,
|
||||
clock=WallClock(),
|
||||
tile_metadata_store=tile_store,
|
||||
)
|
||||
return verifier, records
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-1
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac1_pass_on_valid_manifest(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
manifest_path, pub, _ = _build_signed_manifest(tmp_path)
|
||||
verifier, _ = _build_verifier()
|
||||
|
||||
# Act
|
||||
result = verifier.verify_manifest(
|
||||
manifest_path=manifest_path,
|
||||
trusted_public_keys=(pub,),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.outcome is VerifyOutcome.PASS
|
||||
assert result.fail_reasons == ()
|
||||
assert all(c.matched for c in result.per_artifact_checks)
|
||||
assert result.signing_public_key_fingerprint is not None
|
||||
assert result.elapsed_ms >= 0
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-2
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac2_fail_on_missing_manifest(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
verifier, _ = _build_verifier()
|
||||
missing = tmp_path / "nope" / "Manifest.json"
|
||||
|
||||
# Act
|
||||
result = verifier.verify_manifest(
|
||||
manifest_path=missing,
|
||||
trusted_public_keys=(Ed25519PrivateKey.generate().public_key(),),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.outcome is VerifyOutcome.FAIL
|
||||
assert result.fail_reasons == (VerifyFailReason.MANIFEST_NOT_FOUND,)
|
||||
assert result.per_artifact_checks == ()
|
||||
assert result.signing_public_key_fingerprint is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-3
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac3_fail_on_missing_signature(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
manifest_path, pub, _ = _build_signed_manifest(tmp_path)
|
||||
(manifest_path.parent / "Manifest.json.sig").unlink()
|
||||
verifier, _ = _build_verifier()
|
||||
|
||||
# Act
|
||||
result = verifier.verify_manifest(
|
||||
manifest_path=manifest_path,
|
||||
trusted_public_keys=(pub,),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.outcome is VerifyOutcome.FAIL
|
||||
assert result.fail_reasons == (VerifyFailReason.SIGNATURE_NOT_FOUND,)
|
||||
assert result.per_artifact_checks == ()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-4
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac4_fail_on_tampered_manifest_body(tmp_path: Path) -> None:
|
||||
# Arrange: flip one byte in Manifest.json (sidecar untouched)
|
||||
manifest_path, pub, _ = _build_signed_manifest(tmp_path)
|
||||
body = bytearray(manifest_path.read_bytes())
|
||||
body[10] ^= 0x01
|
||||
manifest_path.write_bytes(bytes(body))
|
||||
verifier, _ = _build_verifier()
|
||||
|
||||
# Act
|
||||
result = verifier.verify_manifest(
|
||||
manifest_path=manifest_path,
|
||||
trusted_public_keys=(pub,),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.outcome is VerifyOutcome.FAIL
|
||||
assert VerifyFailReason.MANIFEST_SELF_HASH_MISMATCH in result.fail_reasons
|
||||
assert result.per_artifact_checks == ()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-5
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac5_fail_on_untrusted_public_key(tmp_path: Path) -> None:
|
||||
# Arrange: verify with a different keypair
|
||||
manifest_path, _signed_with, _ = _build_signed_manifest(tmp_path)
|
||||
other_key = Ed25519PrivateKey.generate().public_key()
|
||||
verifier, _ = _build_verifier()
|
||||
|
||||
# Act
|
||||
result = verifier.verify_manifest(
|
||||
manifest_path=manifest_path,
|
||||
trusted_public_keys=(other_key,),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.outcome is VerifyOutcome.FAIL
|
||||
assert VerifyFailReason.UNTRUSTED_PUBLIC_KEY in result.fail_reasons
|
||||
assert result.signing_public_key_fingerprint is not None
|
||||
assert result.per_artifact_checks == ()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-6
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac6_schema_violation_names_offending_field(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
manifest_path, _pub, _ = _build_signed_manifest(tmp_path)
|
||||
body = orjson.loads(manifest_path.read_bytes())
|
||||
body.pop("signing_public_key_fingerprint")
|
||||
new_bytes = orjson.dumps(body, option=orjson.OPT_SORT_KEYS | orjson.OPT_INDENT_2) + b"\n"
|
||||
Sha256Sidecar.write_atomic_and_sidecar(manifest_path, new_bytes)
|
||||
# Re-sign so we get past Step B; we want Step C to be the failure.
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
Sha256Sidecar.write_atomic(
|
||||
manifest_path.parent / "Manifest.json.sig",
|
||||
priv.sign(new_bytes),
|
||||
)
|
||||
verifier, _ = _build_verifier()
|
||||
|
||||
# Act
|
||||
result = verifier.verify_manifest(
|
||||
manifest_path=manifest_path,
|
||||
trusted_public_keys=(priv.public_key(),),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.outcome is VerifyOutcome.FAIL
|
||||
assert VerifyFailReason.SCHEMA_VIOLATION in result.fail_reasons
|
||||
assert any("signing_public_key_fingerprint" in d for d in result.fail_details)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-7
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac7_absolute_path_in_artifact_is_schema_violation(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
manifest_path, _pub, _ = _build_signed_manifest(tmp_path)
|
||||
body = orjson.loads(manifest_path.read_bytes())
|
||||
body["artifacts"]["engines"][0]["path"] = "/etc/passwd"
|
||||
new_bytes = orjson.dumps(body, option=orjson.OPT_SORT_KEYS | orjson.OPT_INDENT_2) + b"\n"
|
||||
Sha256Sidecar.write_atomic_and_sidecar(manifest_path, new_bytes)
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
Sha256Sidecar.write_atomic(
|
||||
manifest_path.parent / "Manifest.json.sig",
|
||||
priv.sign(new_bytes),
|
||||
)
|
||||
verifier, _ = _build_verifier()
|
||||
|
||||
# Act
|
||||
result = verifier.verify_manifest(
|
||||
manifest_path=manifest_path,
|
||||
trusted_public_keys=(priv.public_key(),),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.outcome is VerifyOutcome.FAIL
|
||||
assert VerifyFailReason.SCHEMA_VIOLATION in result.fail_reasons
|
||||
assert any("/etc/passwd" in d for d in result.fail_details)
|
||||
# No per-artifact disk reads happened — the walk is Step D only.
|
||||
assert result.per_artifact_checks == ()
|
||||
|
||||
|
||||
def test_ac7_dot_dot_segment_in_artifact_is_schema_violation(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
manifest_path, _pub, _ = _build_signed_manifest(tmp_path)
|
||||
body = orjson.loads(manifest_path.read_bytes())
|
||||
body["artifacts"]["calibration"]["path"] = "../calibration/int8.json"
|
||||
new_bytes = orjson.dumps(body, option=orjson.OPT_SORT_KEYS | orjson.OPT_INDENT_2) + b"\n"
|
||||
Sha256Sidecar.write_atomic_and_sidecar(manifest_path, new_bytes)
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
Sha256Sidecar.write_atomic(
|
||||
manifest_path.parent / "Manifest.json.sig",
|
||||
priv.sign(new_bytes),
|
||||
)
|
||||
verifier, _ = _build_verifier()
|
||||
|
||||
# Act
|
||||
result = verifier.verify_manifest(
|
||||
manifest_path=manifest_path,
|
||||
trusted_public_keys=(priv.public_key(),),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert VerifyFailReason.SCHEMA_VIOLATION in result.fail_reasons
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-8
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac8_multiple_fail_reasons_accumulate(tmp_path: Path) -> None:
|
||||
# Arrange: 1 engine missing, 1 engine drifted, 1 engine OK
|
||||
manifest_path, pub, _ = _build_signed_manifest(tmp_path)
|
||||
cache_root = manifest_path.parent
|
||||
body = orjson.loads(manifest_path.read_bytes())
|
||||
# Delete first engine
|
||||
first_engine_rel = body["artifacts"]["engines"][0]["path"]
|
||||
(cache_root / first_engine_rel).unlink()
|
||||
# Mutate second engine
|
||||
second_engine_rel = body["artifacts"]["engines"][1]["path"]
|
||||
(cache_root / second_engine_rel).write_bytes(b"drifted-bytes")
|
||||
verifier, _ = _build_verifier()
|
||||
|
||||
# Act
|
||||
result = verifier.verify_manifest(
|
||||
manifest_path=manifest_path,
|
||||
trusted_public_keys=(pub,),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.outcome is VerifyOutcome.FAIL
|
||||
assert VerifyFailReason.ARTIFACT_MISSING in result.fail_reasons
|
||||
assert VerifyFailReason.ARTIFACT_HASH_MISMATCH in result.fail_reasons
|
||||
engine_checks = [c for c in result.per_artifact_checks if "engines" in c.relative_path]
|
||||
assert len(engine_checks) == 3
|
||||
matched_flags = [c.matched for c in engine_checks]
|
||||
assert matched_flags.count(True) == 1
|
||||
assert matched_flags.count(False) == 2
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-9
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac9_operator_mode_re_derives_tiles_coverage(tmp_path: Path) -> None:
|
||||
# Arrange: build with tiles X; verify with tiles Y → mismatch
|
||||
tiles_built = _make_tiles(10)
|
||||
tiles_drifted = (
|
||||
*tiles_built[:-1],
|
||||
TileHashRecord(
|
||||
zoom=18,
|
||||
lat=50.99,
|
||||
lon=36.99,
|
||||
source="googlemaps",
|
||||
sha256_hex=hashlib.sha256(b"drifted-tile").hexdigest(),
|
||||
),
|
||||
)
|
||||
manifest_path, pub, _ = _build_signed_manifest(tmp_path, tiles=tiles_built)
|
||||
verifier, _ = _build_verifier(tiles=tiles_drifted)
|
||||
|
||||
# Act
|
||||
result = verifier.verify_manifest(
|
||||
manifest_path=manifest_path,
|
||||
trusted_public_keys=(pub,),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.outcome is VerifyOutcome.FAIL
|
||||
assert VerifyFailReason.TILES_COVERAGE_MISMATCH in result.fail_reasons
|
||||
assert any("tiles_coverage" in d for d in result.fail_details)
|
||||
|
||||
|
||||
def test_ac9_operator_mode_pass_when_tiles_match(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
tiles = _make_tiles(10)
|
||||
manifest_path, pub, _ = _build_signed_manifest(tmp_path, tiles=tiles)
|
||||
verifier, _ = _build_verifier(tiles=tiles)
|
||||
|
||||
# Act
|
||||
result = verifier.verify_manifest(
|
||||
manifest_path=manifest_path,
|
||||
trusted_public_keys=(pub,),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.outcome is VerifyOutcome.PASS
|
||||
tiles_check = next(
|
||||
c for c in result.per_artifact_checks if c.relative_path == "tiles_coverage"
|
||||
)
|
||||
assert tiles_check.matched is True
|
||||
assert tiles_check.actual_sha256 == tiles_check.expected_sha256
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-10
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac10_airborne_mode_trusts_tiles_coverage(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
manifest_path, pub, _ = _build_signed_manifest(tmp_path)
|
||||
verifier, _ = _build_verifier() # tile_metadata_store=None
|
||||
|
||||
# Act
|
||||
result = verifier.verify_manifest(
|
||||
manifest_path=manifest_path,
|
||||
trusted_public_keys=(pub,),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.outcome is VerifyOutcome.PASS
|
||||
tiles_check = next(
|
||||
c for c in result.per_artifact_checks if c.relative_path == "tiles_coverage"
|
||||
)
|
||||
assert tiles_check.matched is True
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-11
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac11_protocol_conformance() -> None:
|
||||
# Assert
|
||||
verifier, _ = _build_verifier()
|
||||
assert isinstance(verifier, ManifestVerifier)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-12
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac12_elapsed_ms_recorded_on_every_outcome(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
verifier, _ = _build_verifier()
|
||||
manifest_path, pub, _ = _build_signed_manifest(tmp_path)
|
||||
|
||||
# Act
|
||||
pass_result = verifier.verify_manifest(
|
||||
manifest_path=manifest_path,
|
||||
trusted_public_keys=(pub,),
|
||||
)
|
||||
fail_result = verifier.verify_manifest(
|
||||
manifest_path=tmp_path / "missing.json",
|
||||
trusted_public_keys=(pub,),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert pass_result.elapsed_ms >= 0
|
||||
assert fail_result.elapsed_ms >= 0
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-13
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac13_empty_trusted_public_keys_fails_closed(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
verifier, records = _build_verifier()
|
||||
manifest_path, _pub, _ = _build_signed_manifest(tmp_path)
|
||||
|
||||
# Act
|
||||
result = verifier.verify_manifest(
|
||||
manifest_path=manifest_path,
|
||||
trusted_public_keys=(),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.outcome is VerifyOutcome.FAIL
|
||||
assert VerifyFailReason.UNTRUSTED_PUBLIC_KEY in result.fail_reasons
|
||||
assert result.per_artifact_checks == ()
|
||||
errors = [r for r in records if r.levelno == logging.ERROR]
|
||||
assert any(r.__dict__.get("kind") == "c10.manifest.verify.untrusted" for r in errors)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-14
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac14_v10_manifest_without_flight_block_parses(tmp_path: Path) -> None:
|
||||
# Arrange: take a built manifest and strip the entire `flight` block.
|
||||
manifest_path, _pub, _ = _build_signed_manifest(tmp_path)
|
||||
body = orjson.loads(manifest_path.read_bytes())
|
||||
body.pop("flight", None)
|
||||
body["schema_version"] = "1.0"
|
||||
new_bytes = orjson.dumps(body, option=orjson.OPT_SORT_KEYS | orjson.OPT_INDENT_2) + b"\n"
|
||||
Sha256Sidecar.write_atomic_and_sidecar(manifest_path, new_bytes)
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
Sha256Sidecar.write_atomic(
|
||||
manifest_path.parent / "Manifest.json.sig",
|
||||
priv.sign(new_bytes),
|
||||
)
|
||||
verifier, _ = _build_verifier()
|
||||
|
||||
# Act
|
||||
result = verifier.verify_manifest(
|
||||
manifest_path=manifest_path,
|
||||
trusted_public_keys=(priv.public_key(),),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.outcome is VerifyOutcome.PASS
|
||||
assert result.takeoff_origin is None
|
||||
assert result.flight_id is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-15
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac15_well_formed_in_bbox_takeoff_origin_passes(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
flight = uuid4()
|
||||
origin = LatLonAlt(50.0, 36.2, 200.0)
|
||||
manifest_path, pub, _ = _build_signed_manifest(
|
||||
tmp_path, takeoff_origin=origin, flight_id=flight
|
||||
)
|
||||
verifier, _ = _build_verifier()
|
||||
|
||||
# Act
|
||||
result = verifier.verify_manifest(
|
||||
manifest_path=manifest_path,
|
||||
trusted_public_keys=(pub,),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.outcome is VerifyOutcome.PASS
|
||||
assert result.takeoff_origin == origin
|
||||
assert result.flight_id == flight
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-16
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac16_malformed_takeoff_origin_fails_closed(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
manifest_path, _pub, _ = _build_signed_manifest(tmp_path)
|
||||
body = orjson.loads(manifest_path.read_bytes())
|
||||
body["flight"] = {
|
||||
"flight_id": str(uuid4()),
|
||||
"takeoff_origin": {"lat_deg": 200.0, "lon_deg": 36.2, "alt_m": 100.0},
|
||||
}
|
||||
new_bytes = orjson.dumps(body, option=orjson.OPT_SORT_KEYS | orjson.OPT_INDENT_2) + b"\n"
|
||||
Sha256Sidecar.write_atomic_and_sidecar(manifest_path, new_bytes)
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
Sha256Sidecar.write_atomic(
|
||||
manifest_path.parent / "Manifest.json.sig",
|
||||
priv.sign(new_bytes),
|
||||
)
|
||||
verifier, _ = _build_verifier()
|
||||
|
||||
# Act
|
||||
result = verifier.verify_manifest(
|
||||
manifest_path=manifest_path,
|
||||
trusted_public_keys=(priv.public_key(),),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.outcome is VerifyOutcome.FAIL
|
||||
assert VerifyFailReason.TAKEOFF_ORIGIN_INVALID in result.fail_reasons
|
||||
assert any("lat_deg" in d for d in result.fail_details)
|
||||
# Diagnostics: takeoff_origin still populated even on FAIL
|
||||
assert result.takeoff_origin is not None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-17
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ac17_out_of_bbox_takeoff_origin_fails_closed(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
manifest_path, _pub, _ = _build_signed_manifest(tmp_path)
|
||||
body = orjson.loads(manifest_path.read_bytes())
|
||||
body["flight"] = {
|
||||
"flight_id": str(uuid4()),
|
||||
"takeoff_origin": {"lat_deg": 10.0, "lon_deg": 10.0, "alt_m": 0.0},
|
||||
}
|
||||
new_bytes = orjson.dumps(body, option=orjson.OPT_SORT_KEYS | orjson.OPT_INDENT_2) + b"\n"
|
||||
Sha256Sidecar.write_atomic_and_sidecar(manifest_path, new_bytes)
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
Sha256Sidecar.write_atomic(
|
||||
manifest_path.parent / "Manifest.json.sig",
|
||||
priv.sign(new_bytes),
|
||||
)
|
||||
verifier, _ = _build_verifier()
|
||||
|
||||
# Act
|
||||
result = verifier.verify_manifest(
|
||||
manifest_path=manifest_path,
|
||||
trusted_public_keys=(priv.public_key(),),
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.outcome is VerifyOutcome.FAIL
|
||||
assert VerifyFailReason.TAKEOFF_ORIGIN_OUT_OF_BBOX in result.fail_reasons
|
||||
@@ -0,0 +1,88 @@
|
||||
"""AZ-507 — Layer-0 typed-error envelope shim tests.
|
||||
|
||||
Covers AC-2 (shim re-exports resolve and are identical to the c7
|
||||
canonical classes) and AC-1 / AC-5 (module-layout.md + architecture.md
|
||||
documentation invariants).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from gps_denied_onboard._types.inference_errors import (
|
||||
CalibrationCacheError as ShimCalibrationCacheError,
|
||||
)
|
||||
from gps_denied_onboard._types.inference_errors import (
|
||||
EngineBuildError as ShimEngineBuildError,
|
||||
)
|
||||
from gps_denied_onboard.components.c7_inference.errors import (
|
||||
CalibrationCacheError as CanonicalCalibrationCacheError,
|
||||
)
|
||||
from gps_denied_onboard.components.c7_inference.errors import (
|
||||
EngineBuildError as CanonicalEngineBuildError,
|
||||
)
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_ac2_shim_engine_build_error_identity_with_canonical() -> None:
|
||||
# The shim must NOT introduce a fresh subclass — consumers catching
|
||||
# ShimEngineBuildError MUST catch what c7 actually raises.
|
||||
assert ShimEngineBuildError is CanonicalEngineBuildError
|
||||
|
||||
|
||||
def test_ac2_shim_calibration_cache_error_identity_with_canonical() -> None:
|
||||
assert ShimCalibrationCacheError is CanonicalCalibrationCacheError
|
||||
|
||||
|
||||
def test_ac2_shim_module_has_no_runtime_side_effects() -> None:
|
||||
# Importing the shim must not register a component config block or
|
||||
# otherwise mutate global state. Re-importing the module twice in the
|
||||
# same process must remain a no-op.
|
||||
import importlib
|
||||
|
||||
import gps_denied_onboard._types.inference_errors as shim
|
||||
|
||||
first = importlib.reload(shim)
|
||||
second = importlib.reload(shim)
|
||||
assert first.EngineBuildError is second.EngineBuildError
|
||||
assert first.CalibrationCacheError is second.CalibrationCacheError
|
||||
|
||||
|
||||
def test_ac1_module_layout_has_no_cross_component_public_api_imports() -> None:
|
||||
# AZ-507's primary deliverable: every line that previously said
|
||||
# `components.X (Public API)` in an "Imports from" line must now point
|
||||
# at `_types` (or be removed). We grep for the offending pattern and
|
||||
# assert zero matches.
|
||||
layout = (
|
||||
_REPO_ROOT / "_docs" / "02_document" / "module-layout.md"
|
||||
).read_text(encoding="utf-8")
|
||||
offenders = re.findall(
|
||||
r"components\.[a-z0-9_]+\s*\(Public API\)",
|
||||
layout,
|
||||
)
|
||||
assert offenders == [], (
|
||||
"module-layout.md still names cross-component Public API imports "
|
||||
f"after AZ-507: {offenders}"
|
||||
)
|
||||
|
||||
|
||||
def test_ac5_architecture_doc_codifies_cross_component_rule() -> None:
|
||||
# AZ-507 AC-5: the architecture doc must carry a one-paragraph rule
|
||||
# that cross-component imports go through `_types/*.py` (DTOs +
|
||||
# typed-error envelopes), never `components.X (Public API)`.
|
||||
arch = (
|
||||
_REPO_ROOT / "_docs" / "02_document" / "architecture.md"
|
||||
).read_text(encoding="utf-8")
|
||||
# Look for the rule sentence (case-insensitive) and the explicit
|
||||
# AZ-507 reference so future readers can trace the decision.
|
||||
assert "AZ-507" in arch, "architecture.md must reference AZ-507"
|
||||
assert re.search(
|
||||
r"cross-component imports go through `_types",
|
||||
arch,
|
||||
flags=re.IGNORECASE,
|
||||
), (
|
||||
"architecture.md must state the cross-component import rule "
|
||||
"introduced by AZ-507"
|
||||
)
|
||||
Reference in New Issue
Block a user