mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 22:11:13 +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:
@@ -0,0 +1,748 @@
|
||||
"""C10 ManifestVerifier — takeoff content-hash gate (AZ-324).
|
||||
|
||||
Read-only validator for the AZ-323-produced cache Manifest. Fail-
|
||||
closed: any deviation in signature, schema, key trust, hashes, or
|
||||
the optional ADR-010 takeoff-origin yields ``outcome=FAIL`` with the
|
||||
union of all ``VerifyFailReason`` values that fired. Never raises on
|
||||
a verify failure — callers branch on ``outcome`` (per the contract at
|
||||
``_docs/02_document/contracts/c10_provisioning/manifest_verifier.md``).
|
||||
|
||||
The Protocol + DTOs live alongside the implementation here; the
|
||||
public re-export surface lives in ``c10_provisioning/__init__.py``.
|
||||
Cross-component consumers (C5 takeoff arming, C12 operator tooling)
|
||||
will import via a future ``_types/manifest_verify.py`` shim if and
|
||||
when they wire up — the AZ-270 lint forbids direct
|
||||
``components.c10_provisioning`` imports from other components.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
from uuid import UUID
|
||||
|
||||
import orjson
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
from gps_denied_onboard.clock import Clock
|
||||
from gps_denied_onboard.components.c10_provisioning.manifest_builder import (
|
||||
TilesByBboxQuery,
|
||||
_aggregate_tile_hash,
|
||||
)
|
||||
from gps_denied_onboard.helpers.sha256_sidecar import Sha256Sidecar
|
||||
|
||||
__all__ = [
|
||||
"ArtifactCheck",
|
||||
"ManifestVerifier",
|
||||
"ManifestVerifierImpl",
|
||||
"VerificationResult",
|
||||
"VerifyFailReason",
|
||||
"VerifyOutcome",
|
||||
]
|
||||
|
||||
_VERIFY_LOG_KIND_PREFIX = "c10.manifest.verify"
|
||||
_ED25519_SIG_BYTES = 64
|
||||
_HASH_CHUNK_BYTES = 64 * 1024
|
||||
_MANIFEST_FILENAME = "Manifest.json"
|
||||
_SIDECAR_FILENAME = "Manifest.json.sha256"
|
||||
_SIGNATURE_FILENAME = "Manifest.json.sig"
|
||||
|
||||
|
||||
class VerifyOutcome(str, Enum):
|
||||
"""Top-level pass/fail outcome of :meth:`ManifestVerifier.verify_manifest`."""
|
||||
|
||||
PASS = "pass"
|
||||
FAIL = "fail"
|
||||
|
||||
|
||||
class VerifyFailReason(str, Enum):
|
||||
"""Enumerated reasons a verify failed; multiple may fire per call."""
|
||||
|
||||
MANIFEST_NOT_FOUND = "manifest_not_found"
|
||||
SIGNATURE_NOT_FOUND = "signature_not_found"
|
||||
SIGNATURE_INVALID = "signature_invalid"
|
||||
UNTRUSTED_PUBLIC_KEY = "untrusted_public_key"
|
||||
SCHEMA_VIOLATION = "schema_violation"
|
||||
ARTIFACT_MISSING = "artifact_missing"
|
||||
ARTIFACT_HASH_MISMATCH = "artifact_hash_mismatch"
|
||||
TILES_COVERAGE_MISMATCH = "tiles_coverage_mismatch"
|
||||
MANIFEST_SELF_HASH_MISMATCH = "manifest_self_hash_mismatch"
|
||||
TAKEOFF_ORIGIN_INVALID = "takeoff_origin_invalid"
|
||||
TAKEOFF_ORIGIN_OUT_OF_BBOX = "takeoff_origin_out_of_bbox"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArtifactCheck:
|
||||
"""One Manifest artifact entry's verify outcome."""
|
||||
|
||||
relative_path: str
|
||||
expected_sha256: str
|
||||
actual_sha256: str | None # None when the file is missing on disk
|
||||
matched: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerificationResult:
|
||||
"""Return value of :meth:`ManifestVerifier.verify_manifest`.
|
||||
|
||||
``fail_reasons`` is the deterministic union of every reason that
|
||||
fired during the call; ``fail_details`` is the parallel human-
|
||||
readable diagnostic list. ``takeoff_origin`` is populated for
|
||||
diagnostics even on FAIL whenever the ``flight`` block parsed
|
||||
(MV-INV-9); the callers consume it only on PASS.
|
||||
"""
|
||||
|
||||
outcome: VerifyOutcome
|
||||
fail_reasons: tuple[VerifyFailReason, ...]
|
||||
fail_details: tuple[str, ...]
|
||||
signing_public_key_fingerprint: str | None
|
||||
per_artifact_checks: tuple[ArtifactCheck, ...]
|
||||
takeoff_origin: LatLonAlt | None
|
||||
flight_id: UUID | None
|
||||
elapsed_ms: int
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ManifestVerifier(Protocol):
|
||||
"""Read-only verifier for a C10-produced ``Manifest.json``.
|
||||
|
||||
Fail-closed: any deviation yields ``outcome=FAIL``; never raises
|
||||
on a verify failure. Caller passes the trusted operator public-
|
||||
key tuple — this contract does NOT define a key registry.
|
||||
"""
|
||||
|
||||
def verify_manifest(
|
||||
self,
|
||||
*,
|
||||
manifest_path: Path,
|
||||
trusted_public_keys: tuple[Ed25519PublicKey, ...],
|
||||
) -> VerificationResult: ...
|
||||
|
||||
|
||||
class ManifestVerifierImpl:
|
||||
"""Production :class:`ManifestVerifier` implementation (AZ-324).
|
||||
|
||||
Operator mode (``tile_metadata_store`` supplied) re-derives the
|
||||
aggregate ``tiles_coverage_sha256`` from C6 and flags drift;
|
||||
airborne mode (``None``) trusts the recorded value once the
|
||||
Ed25519 signature passes (per MV-INV-5).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sidecar: Sha256Sidecar,
|
||||
logger: logging.Logger,
|
||||
clock: Clock,
|
||||
tile_metadata_store: TilesByBboxQuery | None = None,
|
||||
) -> None:
|
||||
self._sidecar = sidecar
|
||||
self._log = logger
|
||||
self._clock = clock
|
||||
self._tiles = tile_metadata_store
|
||||
|
||||
def verify_manifest(
|
||||
self,
|
||||
*,
|
||||
manifest_path: Path,
|
||||
trusted_public_keys: tuple[Ed25519PublicKey, ...],
|
||||
) -> VerificationResult:
|
||||
start_ns = self._clock.monotonic_ns()
|
||||
fail_reasons: list[VerifyFailReason] = []
|
||||
fail_details: list[str] = []
|
||||
per_artifact_checks: list[ArtifactCheck] = []
|
||||
signing_fingerprint: str | None = None
|
||||
takeoff_origin: LatLonAlt | None = None
|
||||
flight_id: UUID | None = None
|
||||
|
||||
# --- Step A: Manifest exists & sidecar matches -----------------
|
||||
if not manifest_path.exists():
|
||||
fail_reasons.append(VerifyFailReason.MANIFEST_NOT_FOUND)
|
||||
fail_details.append(f"Manifest.json not found at {manifest_path}")
|
||||
return self._fail(
|
||||
fail_reasons,
|
||||
fail_details,
|
||||
per_artifact_checks,
|
||||
signing_fingerprint,
|
||||
takeoff_origin,
|
||||
flight_id,
|
||||
start_ns,
|
||||
)
|
||||
|
||||
manifest_bytes = manifest_path.read_bytes()
|
||||
sidecar_path = manifest_path.parent / _SIDECAR_FILENAME
|
||||
if not sidecar_path.exists():
|
||||
fail_reasons.append(VerifyFailReason.SCHEMA_VIOLATION)
|
||||
fail_details.append("missing manifest sidecar at " f"{sidecar_path}")
|
||||
return self._fail(
|
||||
fail_reasons,
|
||||
fail_details,
|
||||
per_artifact_checks,
|
||||
signing_fingerprint,
|
||||
takeoff_origin,
|
||||
flight_id,
|
||||
start_ns,
|
||||
)
|
||||
sidecar_value = sidecar_path.read_text(encoding="ascii").strip()
|
||||
actual_self_hash = hashlib.sha256(manifest_bytes).hexdigest()
|
||||
if actual_self_hash != sidecar_value:
|
||||
fail_reasons.append(VerifyFailReason.MANIFEST_SELF_HASH_MISMATCH)
|
||||
fail_details.append(
|
||||
f"Manifest.json sha256={actual_self_hash} != sidecar={sidecar_value}"
|
||||
)
|
||||
return self._fail(
|
||||
fail_reasons,
|
||||
fail_details,
|
||||
per_artifact_checks,
|
||||
signing_fingerprint,
|
||||
takeoff_origin,
|
||||
flight_id,
|
||||
start_ns,
|
||||
)
|
||||
|
||||
# --- Step B: Signature verifies against a trusted key ----------
|
||||
signature_path = manifest_path.parent / _SIGNATURE_FILENAME
|
||||
if not signature_path.exists():
|
||||
fail_reasons.append(VerifyFailReason.SIGNATURE_NOT_FOUND)
|
||||
fail_details.append(f"signature not found at {signature_path}")
|
||||
return self._fail(
|
||||
fail_reasons,
|
||||
fail_details,
|
||||
per_artifact_checks,
|
||||
signing_fingerprint,
|
||||
takeoff_origin,
|
||||
flight_id,
|
||||
start_ns,
|
||||
)
|
||||
signature_bytes = signature_path.read_bytes()
|
||||
if len(signature_bytes) != _ED25519_SIG_BYTES:
|
||||
fail_reasons.append(VerifyFailReason.SIGNATURE_INVALID)
|
||||
fail_details.append(
|
||||
f"signature is {len(signature_bytes)} bytes; expected "
|
||||
f"{_ED25519_SIG_BYTES}"
|
||||
)
|
||||
signing_fingerprint = self._fingerprint_from_body(manifest_bytes)
|
||||
return self._fail(
|
||||
fail_reasons,
|
||||
fail_details,
|
||||
per_artifact_checks,
|
||||
signing_fingerprint,
|
||||
takeoff_origin,
|
||||
flight_id,
|
||||
start_ns,
|
||||
)
|
||||
if not trusted_public_keys:
|
||||
fail_reasons.append(VerifyFailReason.UNTRUSTED_PUBLIC_KEY)
|
||||
fail_details.append("trusted_public_keys tuple is empty")
|
||||
signing_fingerprint = self._fingerprint_from_body(manifest_bytes)
|
||||
self._log.error(
|
||||
f"{_VERIFY_LOG_KIND_PREFIX}.untrusted",
|
||||
extra={
|
||||
"kind": f"{_VERIFY_LOG_KIND_PREFIX}.untrusted",
|
||||
"kv": {"trusted_keys_len": 0},
|
||||
},
|
||||
)
|
||||
return self._fail(
|
||||
fail_reasons,
|
||||
fail_details,
|
||||
per_artifact_checks,
|
||||
signing_fingerprint,
|
||||
takeoff_origin,
|
||||
flight_id,
|
||||
start_ns,
|
||||
)
|
||||
|
||||
signature_ok = False
|
||||
for key in trusted_public_keys:
|
||||
fingerprint = _fingerprint_of(key)
|
||||
try:
|
||||
key.verify(signature_bytes, manifest_bytes)
|
||||
except InvalidSignature:
|
||||
continue
|
||||
signing_fingerprint = fingerprint
|
||||
signature_ok = True
|
||||
break
|
||||
|
||||
if not signature_ok:
|
||||
body_fingerprint = self._fingerprint_from_body(manifest_bytes)
|
||||
signing_fingerprint = body_fingerprint
|
||||
trusted_fps = {_fingerprint_of(k) for k in trusted_public_keys}
|
||||
if body_fingerprint is not None and body_fingerprint not in trusted_fps:
|
||||
fail_reasons.append(VerifyFailReason.UNTRUSTED_PUBLIC_KEY)
|
||||
fail_details.append(
|
||||
f"signing_public_key_fingerprint={body_fingerprint} not in "
|
||||
f"trusted_public_keys (size={len(trusted_public_keys)})"
|
||||
)
|
||||
else:
|
||||
fail_reasons.append(VerifyFailReason.SIGNATURE_INVALID)
|
||||
fail_details.append(
|
||||
"Ed25519 signature did not verify against any trusted key"
|
||||
)
|
||||
return self._fail(
|
||||
fail_reasons,
|
||||
fail_details,
|
||||
per_artifact_checks,
|
||||
signing_fingerprint,
|
||||
takeoff_origin,
|
||||
flight_id,
|
||||
start_ns,
|
||||
)
|
||||
|
||||
# --- Step C: Schema parse -------------------------------------
|
||||
try:
|
||||
manifest_obj: Any = orjson.loads(manifest_bytes)
|
||||
except orjson.JSONDecodeError as exc:
|
||||
fail_reasons.append(VerifyFailReason.SCHEMA_VIOLATION)
|
||||
fail_details.append(f"Manifest.json is not valid JSON: {exc}")
|
||||
return self._fail(
|
||||
fail_reasons,
|
||||
fail_details,
|
||||
per_artifact_checks,
|
||||
signing_fingerprint,
|
||||
takeoff_origin,
|
||||
flight_id,
|
||||
start_ns,
|
||||
)
|
||||
|
||||
schema_violations = _validate_manifest_schema(manifest_obj)
|
||||
if schema_violations:
|
||||
fail_reasons.append(VerifyFailReason.SCHEMA_VIOLATION)
|
||||
fail_details.extend(schema_violations)
|
||||
return self._fail(
|
||||
fail_reasons,
|
||||
fail_details,
|
||||
per_artifact_checks,
|
||||
signing_fingerprint,
|
||||
takeoff_origin,
|
||||
flight_id,
|
||||
start_ns,
|
||||
)
|
||||
|
||||
flight_block = manifest_obj.get("flight", {}) or {}
|
||||
flight_id_raw = flight_block.get("flight_id")
|
||||
if flight_id_raw is not None:
|
||||
try:
|
||||
flight_id = UUID(str(flight_id_raw))
|
||||
except ValueError:
|
||||
fail_reasons.append(VerifyFailReason.SCHEMA_VIOLATION)
|
||||
fail_details.append(
|
||||
f"flight.flight_id is not a valid UUID: {flight_id_raw!r}"
|
||||
)
|
||||
|
||||
bbox = _bbox_from_dict(manifest_obj["build"]["bbox"])
|
||||
origin_block = flight_block.get("takeoff_origin")
|
||||
if origin_block is not None:
|
||||
origin_parsed, origin_errors = _parse_takeoff_origin(origin_block)
|
||||
if origin_parsed is not None:
|
||||
takeoff_origin = origin_parsed
|
||||
if origin_errors:
|
||||
fail_reasons.append(VerifyFailReason.TAKEOFF_ORIGIN_INVALID)
|
||||
fail_details.extend(origin_errors)
|
||||
elif takeoff_origin is not None and not _origin_in_bbox(
|
||||
takeoff_origin, bbox
|
||||
):
|
||||
fail_reasons.append(VerifyFailReason.TAKEOFF_ORIGIN_OUT_OF_BBOX)
|
||||
fail_details.append(
|
||||
f"takeoff_origin=({takeoff_origin.lat_deg},"
|
||||
f"{takeoff_origin.lon_deg}) outside bbox "
|
||||
f"(min={bbox.min_lat_deg},{bbox.min_lon_deg}; "
|
||||
f"max={bbox.max_lat_deg},{bbox.max_lon_deg})"
|
||||
)
|
||||
|
||||
if fail_reasons:
|
||||
return self._fail(
|
||||
fail_reasons,
|
||||
fail_details,
|
||||
per_artifact_checks,
|
||||
signing_fingerprint,
|
||||
takeoff_origin,
|
||||
flight_id,
|
||||
start_ns,
|
||||
)
|
||||
|
||||
# --- Step D: Per-artifact hash walk ---------------------------
|
||||
artifacts = manifest_obj["artifacts"]
|
||||
cache_root = manifest_path.parent
|
||||
|
||||
seen_missing = False
|
||||
seen_mismatch = False
|
||||
for entry in artifacts["engines"]:
|
||||
check = _hash_relative_artifact(
|
||||
cache_root=cache_root,
|
||||
relative=entry["path"],
|
||||
expected=entry["sha256"],
|
||||
)
|
||||
per_artifact_checks.append(check)
|
||||
if check.actual_sha256 is None:
|
||||
if not seen_missing:
|
||||
fail_reasons.append(VerifyFailReason.ARTIFACT_MISSING)
|
||||
seen_missing = True
|
||||
fail_details.append(f"missing engine artifact: {entry['path']}")
|
||||
elif not check.matched:
|
||||
if not seen_mismatch:
|
||||
fail_reasons.append(VerifyFailReason.ARTIFACT_HASH_MISMATCH)
|
||||
seen_mismatch = True
|
||||
fail_details.append(
|
||||
f"engine hash mismatch: {entry['path']} "
|
||||
f"expected={check.expected_sha256} actual={check.actual_sha256}"
|
||||
)
|
||||
|
||||
for label, entry in (
|
||||
("descriptor_index", artifacts["descriptor_index"]),
|
||||
("calibration", artifacts["calibration"]),
|
||||
):
|
||||
check = _hash_relative_artifact(
|
||||
cache_root=cache_root,
|
||||
relative=entry["path"],
|
||||
expected=entry["sha256"],
|
||||
)
|
||||
per_artifact_checks.append(check)
|
||||
if check.actual_sha256 is None:
|
||||
if not seen_missing:
|
||||
fail_reasons.append(VerifyFailReason.ARTIFACT_MISSING)
|
||||
seen_missing = True
|
||||
fail_details.append(f"missing {label} artifact: {entry['path']}")
|
||||
elif not check.matched:
|
||||
if not seen_mismatch:
|
||||
fail_reasons.append(VerifyFailReason.ARTIFACT_HASH_MISMATCH)
|
||||
seen_mismatch = True
|
||||
fail_details.append(
|
||||
f"{label} hash mismatch: {entry['path']} "
|
||||
f"expected={check.expected_sha256} actual={check.actual_sha256}"
|
||||
)
|
||||
|
||||
tiles_recorded_sha = artifacts["tiles_coverage"]["sha256"]
|
||||
if self._tiles is None:
|
||||
per_artifact_checks.append(
|
||||
ArtifactCheck(
|
||||
relative_path="tiles_coverage",
|
||||
expected_sha256=tiles_recorded_sha,
|
||||
actual_sha256=tiles_recorded_sha,
|
||||
matched=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
try:
|
||||
zoom_levels = tuple(
|
||||
int(z) for z in manifest_obj["build"]["zoom_levels"]
|
||||
)
|
||||
sector_class = str(manifest_obj["build"]["sector_class"])
|
||||
records = tuple(
|
||||
self._tiles.query_by_bbox(
|
||||
bbox=bbox,
|
||||
zoom_levels=zoom_levels,
|
||||
sector_class=sector_class,
|
||||
)
|
||||
)
|
||||
records = tuple(
|
||||
sorted(records, key=lambda r: (r.zoom, r.lat, r.lon, r.source))
|
||||
)
|
||||
computed = _aggregate_tile_hash(records)
|
||||
except Exception as exc:
|
||||
per_artifact_checks.append(
|
||||
ArtifactCheck(
|
||||
relative_path="tiles_coverage",
|
||||
expected_sha256=tiles_recorded_sha,
|
||||
actual_sha256=None,
|
||||
matched=False,
|
||||
)
|
||||
)
|
||||
fail_reasons.append(VerifyFailReason.TILES_COVERAGE_MISMATCH)
|
||||
fail_details.append(
|
||||
f"tiles_coverage re-derivation failed: {exc}"
|
||||
)
|
||||
else:
|
||||
matched = computed == tiles_recorded_sha
|
||||
per_artifact_checks.append(
|
||||
ArtifactCheck(
|
||||
relative_path="tiles_coverage",
|
||||
expected_sha256=tiles_recorded_sha,
|
||||
actual_sha256=computed,
|
||||
matched=matched,
|
||||
)
|
||||
)
|
||||
if not matched:
|
||||
fail_reasons.append(VerifyFailReason.TILES_COVERAGE_MISMATCH)
|
||||
fail_details.append(
|
||||
f"tiles_coverage drift: recorded={tiles_recorded_sha} "
|
||||
f"computed={computed}"
|
||||
)
|
||||
|
||||
elapsed_ms = max(
|
||||
0, int((self._clock.monotonic_ns() - start_ns) / 1_000_000)
|
||||
)
|
||||
outcome = VerifyOutcome.PASS if not fail_reasons else VerifyOutcome.FAIL
|
||||
|
||||
if outcome is VerifyOutcome.PASS:
|
||||
self._log.info(
|
||||
f"{_VERIFY_LOG_KIND_PREFIX}.pass",
|
||||
extra={
|
||||
"kind": f"{_VERIFY_LOG_KIND_PREFIX}.pass",
|
||||
"kv": {
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"signing_public_key_fingerprint": signing_fingerprint,
|
||||
"n_artifacts": len(per_artifact_checks),
|
||||
"mode": "operator" if self._tiles is not None else "airborne",
|
||||
},
|
||||
},
|
||||
)
|
||||
else:
|
||||
self._log.warning(
|
||||
f"{_VERIFY_LOG_KIND_PREFIX}.fail",
|
||||
extra={
|
||||
"kind": f"{_VERIFY_LOG_KIND_PREFIX}.fail",
|
||||
"kv": {
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"fail_reasons": [r.value for r in fail_reasons],
|
||||
"n_mismatched": sum(
|
||||
1 for c in per_artifact_checks if not c.matched
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return VerificationResult(
|
||||
outcome=outcome,
|
||||
fail_reasons=tuple(fail_reasons),
|
||||
fail_details=tuple(fail_details),
|
||||
signing_public_key_fingerprint=signing_fingerprint,
|
||||
per_artifact_checks=tuple(per_artifact_checks),
|
||||
takeoff_origin=takeoff_origin,
|
||||
flight_id=flight_id,
|
||||
elapsed_ms=elapsed_ms,
|
||||
)
|
||||
|
||||
def _fail(
|
||||
self,
|
||||
fail_reasons: list[VerifyFailReason],
|
||||
fail_details: list[str],
|
||||
per_artifact_checks: list[ArtifactCheck],
|
||||
signing_fingerprint: str | None,
|
||||
takeoff_origin: LatLonAlt | None,
|
||||
flight_id: UUID | None,
|
||||
start_ns: int,
|
||||
) -> VerificationResult:
|
||||
elapsed_ms = max(
|
||||
0, int((self._clock.monotonic_ns() - start_ns) / 1_000_000)
|
||||
)
|
||||
self._log.warning(
|
||||
f"{_VERIFY_LOG_KIND_PREFIX}.fail",
|
||||
extra={
|
||||
"kind": f"{_VERIFY_LOG_KIND_PREFIX}.fail",
|
||||
"kv": {
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"fail_reasons": [r.value for r in fail_reasons],
|
||||
"n_mismatched": sum(
|
||||
1 for c in per_artifact_checks if not c.matched
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
return VerificationResult(
|
||||
outcome=VerifyOutcome.FAIL,
|
||||
fail_reasons=tuple(fail_reasons),
|
||||
fail_details=tuple(fail_details),
|
||||
signing_public_key_fingerprint=signing_fingerprint,
|
||||
per_artifact_checks=tuple(per_artifact_checks),
|
||||
takeoff_origin=takeoff_origin,
|
||||
flight_id=flight_id,
|
||||
elapsed_ms=elapsed_ms,
|
||||
)
|
||||
|
||||
def _fingerprint_from_body(self, manifest_bytes: bytes) -> str | None:
|
||||
"""Best-effort fingerprint lookup for diagnostics on FAIL paths."""
|
||||
|
||||
try:
|
||||
obj = orjson.loads(manifest_bytes)
|
||||
except orjson.JSONDecodeError:
|
||||
return None
|
||||
fp = obj.get("signing_public_key_fingerprint") if isinstance(obj, dict) else None
|
||||
if not isinstance(fp, str):
|
||||
return None
|
||||
if len(fp) != 64:
|
||||
return None
|
||||
try:
|
||||
int(fp, 16)
|
||||
except ValueError:
|
||||
return None
|
||||
return fp.lower()
|
||||
|
||||
|
||||
def _fingerprint_of(public_key: Ed25519PublicKey) -> str:
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding,
|
||||
PublicFormat,
|
||||
)
|
||||
|
||||
raw = public_key.public_bytes(
|
||||
encoding=Encoding.Raw,
|
||||
format=PublicFormat.Raw,
|
||||
)
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def _validate_manifest_schema(obj: Any) -> list[str]:
|
||||
"""Return the list of schema-violation diagnostics; empty when valid."""
|
||||
|
||||
errors: list[str] = []
|
||||
if not isinstance(obj, dict):
|
||||
return ["Manifest top-level is not an object"]
|
||||
for top_key in ("schema_version", "build", "artifacts", "signing_public_key_fingerprint"):
|
||||
if top_key not in obj:
|
||||
errors.append(f"missing required top-level key: {top_key}")
|
||||
if errors:
|
||||
return errors
|
||||
|
||||
build = obj["build"]
|
||||
if not isinstance(build, dict):
|
||||
errors.append("`build` is not an object")
|
||||
return errors
|
||||
for build_key in ("bbox", "zoom_levels", "sector_class", "built_at", "manifest_hash"):
|
||||
if build_key not in build:
|
||||
errors.append(f"missing required build.{build_key}")
|
||||
bbox = build.get("bbox")
|
||||
if isinstance(bbox, dict):
|
||||
for bbox_key in ("min_lat_deg", "min_lon_deg", "max_lat_deg", "max_lon_deg"):
|
||||
if bbox_key not in bbox:
|
||||
errors.append(f"missing required build.bbox.{bbox_key}")
|
||||
else:
|
||||
errors.append("`build.bbox` is not an object")
|
||||
|
||||
artifacts = obj["artifacts"]
|
||||
if not isinstance(artifacts, dict):
|
||||
errors.append("`artifacts` is not an object")
|
||||
return errors
|
||||
for sub_key in ("engines", "descriptor_index", "calibration", "tiles_coverage"):
|
||||
if sub_key not in artifacts:
|
||||
errors.append(f"missing required artifacts.{sub_key}")
|
||||
engines = artifacts.get("engines")
|
||||
if isinstance(engines, list):
|
||||
for i, entry in enumerate(engines):
|
||||
errors.extend(_validate_path_sha_entry(entry, f"artifacts.engines[{i}]"))
|
||||
else:
|
||||
errors.append("`artifacts.engines` is not a list")
|
||||
for sub_key in ("descriptor_index", "calibration"):
|
||||
entry = artifacts.get(sub_key)
|
||||
if isinstance(entry, dict):
|
||||
errors.extend(_validate_path_sha_entry(entry, f"artifacts.{sub_key}"))
|
||||
else:
|
||||
errors.append(f"`artifacts.{sub_key}` is not an object")
|
||||
tiles_coverage = artifacts.get("tiles_coverage")
|
||||
if isinstance(tiles_coverage, dict):
|
||||
if not isinstance(tiles_coverage.get("sha256"), str):
|
||||
errors.append("`artifacts.tiles_coverage.sha256` is not a string")
|
||||
if not isinstance(tiles_coverage.get("tile_count"), int):
|
||||
errors.append("`artifacts.tiles_coverage.tile_count` is not an int")
|
||||
else:
|
||||
errors.append("`artifacts.tiles_coverage` is not an object")
|
||||
|
||||
fp = obj.get("signing_public_key_fingerprint")
|
||||
if not isinstance(fp, str) or len(fp) != 64:
|
||||
errors.append(
|
||||
"`signing_public_key_fingerprint` must be a 64-char hex string"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_path_sha_entry(entry: Any, label: str) -> list[str]:
|
||||
if not isinstance(entry, dict):
|
||||
return [f"{label} is not an object"]
|
||||
errors: list[str] = []
|
||||
raw_path = entry.get("path")
|
||||
sha = entry.get("sha256")
|
||||
if not isinstance(raw_path, str):
|
||||
errors.append(f"{label}.path is not a string")
|
||||
else:
|
||||
if raw_path.startswith("/"):
|
||||
errors.append(f"{label}.path must be relative; got absolute {raw_path!r}")
|
||||
parts = PurePosixPath(raw_path).parts
|
||||
if ".." in parts:
|
||||
errors.append(
|
||||
f"{label}.path must not contain `..` segments; got {raw_path!r}"
|
||||
)
|
||||
if not isinstance(sha, str) or len(sha) != 64:
|
||||
errors.append(f"{label}.sha256 must be a 64-char hex string")
|
||||
return errors
|
||||
|
||||
|
||||
def _bbox_from_dict(bbox: dict[str, float]) -> BoundingBox:
|
||||
return BoundingBox(
|
||||
min_lat_deg=float(bbox["min_lat_deg"]),
|
||||
min_lon_deg=float(bbox["min_lon_deg"]),
|
||||
max_lat_deg=float(bbox["max_lat_deg"]),
|
||||
max_lon_deg=float(bbox["max_lon_deg"]),
|
||||
)
|
||||
|
||||
|
||||
def _parse_takeoff_origin(block: Any) -> tuple[LatLonAlt | None, list[str]]:
|
||||
errors: list[str] = []
|
||||
if not isinstance(block, dict):
|
||||
errors.append("`flight.takeoff_origin` is not an object")
|
||||
return None, errors
|
||||
lat = block.get("lat_deg")
|
||||
lon = block.get("lon_deg")
|
||||
alt = block.get("alt_m")
|
||||
if not isinstance(lat, (int, float)) or isinstance(lat, bool):
|
||||
errors.append("`flight.takeoff_origin.lat_deg` is not a number")
|
||||
if not isinstance(lon, (int, float)) or isinstance(lon, bool):
|
||||
errors.append("`flight.takeoff_origin.lon_deg` is not a number")
|
||||
if not isinstance(alt, (int, float)) or isinstance(alt, bool):
|
||||
errors.append("`flight.takeoff_origin.alt_m` is not a number")
|
||||
if errors:
|
||||
return None, errors
|
||||
lat_f = float(lat) # type: ignore[arg-type]
|
||||
lon_f = float(lon) # type: ignore[arg-type]
|
||||
alt_f = float(alt) # type: ignore[arg-type]
|
||||
parsed = LatLonAlt(lat_deg=lat_f, lon_deg=lon_f, alt_m=alt_f)
|
||||
if not (-90.0 <= lat_f <= 90.0):
|
||||
errors.append(f"flight.takeoff_origin.lat_deg={lat_f} out of [-90, 90]")
|
||||
if not (-180.0 <= lon_f <= 180.0):
|
||||
errors.append(
|
||||
f"flight.takeoff_origin.lon_deg={lon_f} out of [-180, 180]"
|
||||
)
|
||||
if not math.isfinite(alt_f):
|
||||
errors.append("flight.takeoff_origin.alt_m must be finite")
|
||||
return parsed, errors
|
||||
|
||||
|
||||
def _origin_in_bbox(origin: LatLonAlt, bbox: BoundingBox) -> bool:
|
||||
return bbox.contains(origin.lat_deg, origin.lon_deg)
|
||||
|
||||
|
||||
def _hash_relative_artifact(
|
||||
*,
|
||||
cache_root: Path,
|
||||
relative: str,
|
||||
expected: str,
|
||||
) -> ArtifactCheck:
|
||||
target = cache_root / relative
|
||||
if not target.exists():
|
||||
return ArtifactCheck(
|
||||
relative_path=relative,
|
||||
expected_sha256=expected,
|
||||
actual_sha256=None,
|
||||
matched=False,
|
||||
)
|
||||
hasher = hashlib.sha256()
|
||||
with target.open("rb") as fh:
|
||||
while True:
|
||||
chunk = fh.read(_HASH_CHUNK_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
hasher.update(chunk)
|
||||
actual = hasher.hexdigest()
|
||||
return ArtifactCheck(
|
||||
relative_path=relative,
|
||||
expected_sha256=expected,
|
||||
actual_sha256=actual,
|
||||
matched=actual == expected,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user