Files
gps-denied-onboard/src/gps_denied_onboard/components/c10_provisioning/manifest_verifier.py
T
Oleksandr Bezdieniezhnykh ca0430a44d [AZ-515] Extract C10 canonical hash helpers to shared module
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>
2026-05-13 05:24:06 +03:00

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,
)