mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 09:01:14 +00:00
ca0430a44d
Cumulative-review F1 (batches 34-36, carried into batch 37): both manifest_verifier.py (AZ-324) and provisioner.py (AZ-325) imported leading-underscore privates _aggregate_tile_hash + _compute_manifest_hash from manifest_builder.py (AZ-323). The helpers encode the trust-chain formula shared across all three components; the import shape gave readers no static signal that a refactor would silently break two modules. Move the formula into c10_provisioning/_canonical_hash.py: - TileHashRecord (moved from manifest_builder) - aggregate_tile_hash (renamed, public) - compute_manifest_hash (renamed, public) - TAKEOFF_ORIGIN_DECIMALS constant (moved) Callers updated to import directly from _canonical_hash. Bodies unchanged; manifest hashes are byte-for-byte identical. Tests: c10_provisioning suite 86/86 pass; full project 1370/1370 pass. Co-authored-by: Cursor <cursoragent@cursor.com>
751 lines
28 KiB
Python
751 lines
28 KiB
Python
"""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._canonical_hash import (
|
|
aggregate_tile_hash,
|
|
)
|
|
from gps_denied_onboard.components.c10_provisioning.manifest_builder import (
|
|
TilesByBboxQuery,
|
|
)
|
|
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,
|
|
)
|
|
|