mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 17:21: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:
@@ -13,35 +13,79 @@ from gps_denied_onboard._types.inference import EngineCacheEntry
|
||||
from gps_denied_onboard._types.manifests import Manifest
|
||||
from gps_denied_onboard.components.c10_provisioning.config import (
|
||||
BackboneConfig,
|
||||
C10ManifestConfig,
|
||||
C10ProvisioningConfig,
|
||||
SigningMode,
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning.engine_compiler import (
|
||||
BackboneSpec,
|
||||
CompileEngineCallable,
|
||||
CompileOutcome,
|
||||
EngineCompiler,
|
||||
EngineCompileRequest,
|
||||
EngineCompileResult,
|
||||
EngineCompileSummary,
|
||||
EngineCompiler,
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning.errors import (
|
||||
C10ProvisioningError,
|
||||
ManifestWriteError,
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning.interface import (
|
||||
CacheProvisioner,
|
||||
ManifestSigner,
|
||||
SigningKeyHandle,
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning.manifest_builder import (
|
||||
VALID_SECTOR_CLASSES,
|
||||
Ed25519ManifestSigner,
|
||||
ManifestArtifact,
|
||||
ManifestBuilder,
|
||||
ManifestBuildInput,
|
||||
TileHashRecord,
|
||||
TilesByBboxQuery,
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning.manifest_verifier import (
|
||||
ArtifactCheck,
|
||||
ManifestVerifier,
|
||||
ManifestVerifierImpl,
|
||||
VerificationResult,
|
||||
VerifyFailReason,
|
||||
VerifyOutcome,
|
||||
)
|
||||
from gps_denied_onboard.config.schema import register_component_block
|
||||
|
||||
register_component_block("c10_provisioning", C10ProvisioningConfig)
|
||||
|
||||
__all__ = [
|
||||
"VALID_SECTOR_CLASSES",
|
||||
"ArtifactCheck",
|
||||
"BackboneConfig",
|
||||
"BackboneSpec",
|
||||
"C10ManifestConfig",
|
||||
"C10ProvisioningConfig",
|
||||
"C10ProvisioningError",
|
||||
"CacheProvisioner",
|
||||
"CompileEngineCallable",
|
||||
"CompileOutcome",
|
||||
"Ed25519ManifestSigner",
|
||||
"EngineCacheEntry",
|
||||
"EngineCompileRequest",
|
||||
"EngineCompileResult",
|
||||
"EngineCompileSummary",
|
||||
"EngineCompiler",
|
||||
"Manifest",
|
||||
"ManifestArtifact",
|
||||
"ManifestBuildInput",
|
||||
"ManifestBuilder",
|
||||
"ManifestSigner",
|
||||
"ManifestVerifier",
|
||||
"ManifestVerifierImpl",
|
||||
"ManifestWriteError",
|
||||
"SigningKeyHandle",
|
||||
"SigningMode",
|
||||
"TileHashRecord",
|
||||
"TilesByBboxQuery",
|
||||
"VerificationResult",
|
||||
"VerifyFailReason",
|
||||
"VerifyOutcome",
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""C10 cache-provisioning config block (AZ-321).
|
||||
"""C10 cache-provisioning config block (AZ-321, extended by AZ-323).
|
||||
|
||||
Registered into ``config.components['c10_provisioning']`` by the
|
||||
package ``__init__.py``. The composition-root factory
|
||||
@@ -7,6 +7,10 @@ reads this block to enumerate the project's backbones and to bound
|
||||
the workspace memory passed to
|
||||
:meth:`InferenceRuntime.compile_engine`.
|
||||
|
||||
AZ-323 extends the block with a nested :class:`C10ManifestConfig`
|
||||
that drives the operator-mode signing-key allowlist gate
|
||||
(C10-ST-01) and pins the Manifest schema version.
|
||||
|
||||
Backbone enumeration is config-driven (not hardcoded) so a new model
|
||||
is a YAML change rather than a code change — see the AZ-321 task
|
||||
spec §Constraints.
|
||||
@@ -15,16 +19,89 @@ spec §Constraints.
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from gps_denied_onboard.config.schema import ConfigError
|
||||
|
||||
__all__ = [
|
||||
"BackboneConfig",
|
||||
"C10ManifestConfig",
|
||||
"C10ProvisioningConfig",
|
||||
"SigningMode",
|
||||
]
|
||||
|
||||
|
||||
_DEFAULT_WORKSPACE_MB: int = 4096
|
||||
_DEFAULT_MANIFEST_SCHEMA_VERSION: str = "1.1"
|
||||
|
||||
|
||||
class SigningMode(str, Enum):
|
||||
"""C10 Manifest signing-mode (AZ-323, C10-ST-01).
|
||||
|
||||
``OPERATOR``: production — the key fingerprint MUST be in
|
||||
:attr:`C10ManifestConfig.allowed_operator_fingerprints` or the
|
||||
build fails closed with :class:`ManifestWriteError`.
|
||||
|
||||
``DEV``: development / research — any key is accepted; an
|
||||
operator-allowlisted key used in this mode emits a WARN log.
|
||||
"""
|
||||
|
||||
OPERATOR = "operator"
|
||||
DEV = "dev"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class C10ManifestConfig:
|
||||
"""Sub-block driving :class:`ManifestBuilder` policy (AZ-323).
|
||||
|
||||
``signing_mode`` controls the operator-key allowlist gate
|
||||
(C10-ST-01). ``allowed_operator_fingerprints`` is the SHA-256 hex
|
||||
of the raw 32-byte Ed25519 public key — 64 lowercase hex chars per
|
||||
entry. ``schema_version`` is written into the Manifest body so the
|
||||
AZ-324 verifier knows which optional blocks (e.g. ``flight``,
|
||||
added in ADR-010 / v1.1) to expect.
|
||||
"""
|
||||
|
||||
signing_mode: SigningMode = SigningMode.DEV
|
||||
allowed_operator_fingerprints: tuple[str, ...] = ()
|
||||
schema_version: str = _DEFAULT_MANIFEST_SCHEMA_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.schema_version:
|
||||
raise ConfigError(
|
||||
"C10ManifestConfig.schema_version must be a non-empty string"
|
||||
)
|
||||
seen: set[str] = set()
|
||||
for fp in self.allowed_operator_fingerprints:
|
||||
if not isinstance(fp, str):
|
||||
raise ConfigError(
|
||||
"C10ManifestConfig.allowed_operator_fingerprints entries "
|
||||
f"must be strings; got {type(fp).__name__}"
|
||||
)
|
||||
if len(fp) != 64:
|
||||
raise ConfigError(
|
||||
"C10ManifestConfig.allowed_operator_fingerprints entries "
|
||||
f"must be 64-hex-char SHA-256 digests; got {len(fp)} chars "
|
||||
f"for {fp!r}"
|
||||
)
|
||||
if fp.lower() != fp:
|
||||
raise ConfigError(
|
||||
"C10ManifestConfig.allowed_operator_fingerprints entries "
|
||||
f"must be lowercase hex; got {fp!r}"
|
||||
)
|
||||
try:
|
||||
int(fp, 16)
|
||||
except ValueError as exc:
|
||||
raise ConfigError(
|
||||
"C10ManifestConfig.allowed_operator_fingerprints contains "
|
||||
f"non-hex entry {fp!r}"
|
||||
) from exc
|
||||
if fp in seen:
|
||||
raise ConfigError(
|
||||
"C10ManifestConfig.allowed_operator_fingerprints contains "
|
||||
f"duplicate fingerprint {fp!r}"
|
||||
)
|
||||
seen.add(fp)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -88,10 +165,16 @@ class C10ProvisioningConfig:
|
||||
into :class:`BuildConfig`; defaults to 4 GiB which matches the
|
||||
C7 NFT-LIM-01 GPU memory budget. Operators can dial it down for
|
||||
Tier-2 compile workstations with less GPU memory.
|
||||
|
||||
``manifest`` carries the AZ-323 Manifest-builder policy
|
||||
(signing mode, allowed operator fingerprints, schema version).
|
||||
Defaulted to dev-mode with no allowlist so unit tests + replay
|
||||
runs that don't build Manifests stay no-op.
|
||||
"""
|
||||
|
||||
backbones: tuple[BackboneConfig, ...] = field(default_factory=tuple)
|
||||
workspace_mb: int = _DEFAULT_WORKSPACE_MB
|
||||
manifest: C10ManifestConfig = field(default_factory=C10ManifestConfig)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.workspace_mb <= 0:
|
||||
|
||||
@@ -52,6 +52,10 @@ from gps_denied_onboard._types.inference import (
|
||||
OptimizationProfile,
|
||||
PrecisionMode,
|
||||
)
|
||||
from gps_denied_onboard._types.inference_errors import (
|
||||
CalibrationCacheError,
|
||||
EngineBuildError,
|
||||
)
|
||||
from gps_denied_onboard._types.manifests import HostCapabilities
|
||||
from gps_denied_onboard.helpers.engine_filename_schema import (
|
||||
EngineFilenameSchema,
|
||||
@@ -275,14 +279,14 @@ class EngineCompiler:
|
||||
entry = self._runtime.compile_engine(
|
||||
backbone.onnx_path, build_config
|
||||
)
|
||||
except Exception as exc:
|
||||
# The C7 InferenceRuntime contract scopes exceptions to its
|
||||
# `RuntimeError` family (`EngineBuildError`,
|
||||
# `CalibrationCacheError`, ...). The c10 layer is forbidden
|
||||
# from importing the c7 errors module (architecture rule
|
||||
# AC-6 / test_az270_compose_root.test_ac6); we catch the
|
||||
# broader `Exception` and dispatch by class name in the log
|
||||
# payload. Re-raising preserves the original type.
|
||||
except (EngineBuildError, CalibrationCacheError) as exc:
|
||||
# AZ-507 narrowed the catch to the documented C7 typed-error
|
||||
# envelope (`_types/inference_errors.py` re-exports
|
||||
# `EngineBuildError` + `CalibrationCacheError` from
|
||||
# `c7_inference.errors` without violating the AZ-270 lint).
|
||||
# Unknown exceptions intentionally propagate unhandled — they
|
||||
# are programmer errors, not C7 contract failures, and must
|
||||
# not be swallowed under a structured "compile.error" log.
|
||||
self._log.error(
|
||||
"c10.engine.compile.error",
|
||||
extra={
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""C10 cache-provisioning error family.
|
||||
|
||||
Rooted at :class:`C10ProvisioningError`; today the family contains
|
||||
:class:`ManifestWriteError` (AZ-323) covering signing-key load failure,
|
||||
fingerprint-allowlist rejection, and any I/O failure path during
|
||||
``ManifestBuilder.build_manifest``. AZ-324 / AZ-325 add additional
|
||||
subtypes (``ManifestVerifierError``, ``ManifestCoverageError``,
|
||||
``ContentHashMismatchError``) under the same root as they land.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"C10ProvisioningError",
|
||||
"ManifestWriteError",
|
||||
]
|
||||
|
||||
|
||||
class C10ProvisioningError(Exception):
|
||||
"""Base class for the C10 cache-provisioning error family."""
|
||||
|
||||
|
||||
class ManifestWriteError(C10ProvisioningError):
|
||||
"""``ManifestBuilder.build_manifest`` could not produce a signed Manifest.
|
||||
|
||||
Surfaces three failure modes:
|
||||
|
||||
1. Operator-mode signing key fingerprint not in the configured
|
||||
allowlist (C10-ST-01).
|
||||
2. Signing key file unreadable or malformed PEM (the underlying
|
||||
``cryptography`` exception is chained via ``__cause__``).
|
||||
3. Any underlying atomic-write / sidecar failure during Manifest
|
||||
or signature emission.
|
||||
|
||||
Callers catch this single envelope; the structured `kind=
|
||||
"c10.manifest.build.error"` log payload (set by ``ManifestBuilder``)
|
||||
carries the discriminator field.
|
||||
"""
|
||||
@@ -1,4 +1,8 @@
|
||||
"""C10 `CacheProvisioner` Protocol.
|
||||
"""C10 Public-API Protocols.
|
||||
|
||||
- :class:`CacheProvisioner` (AZ-325, pending) — pre-flight orchestrator.
|
||||
- :class:`ManifestSigner` (AZ-323) — Ed25519 detached signing surface
|
||||
consumed by :class:`ManifestBuilder`.
|
||||
|
||||
Concrete impl: engine compile + descriptors + manifest + content-hash gate. See
|
||||
`_docs/02_document/components/11_c10_provisioning/`.
|
||||
@@ -7,12 +11,58 @@ Concrete impl: engine compile + descriptors + manifest + content-hash gate. See
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from gps_denied_onboard._types.manifests import Manifest
|
||||
|
||||
__all__ = [
|
||||
"CacheProvisioner",
|
||||
"ManifestSigner",
|
||||
"SigningKeyHandle",
|
||||
]
|
||||
|
||||
|
||||
class CacheProvisioner(Protocol):
|
||||
"""Pre-flight cache provisioning (engine compile + descriptor batch + manifest)."""
|
||||
|
||||
def provision(self, flight_id: str, output_root: Path) -> Manifest: ...
|
||||
|
||||
|
||||
class SigningKeyHandle(Protocol):
|
||||
"""Opaque handle returned by :meth:`ManifestSigner.load_signing_key`.
|
||||
|
||||
The Protocol intentionally exposes no methods — concrete signers
|
||||
(e.g. :class:`Ed25519ManifestSigner`) hold the actual key behind
|
||||
this marker so the caller can pass it back into :meth:`sign` /
|
||||
:meth:`public_key_fingerprint` without ever touching the secret
|
||||
material.
|
||||
"""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ManifestSigner(Protocol):
|
||||
"""Detached-signature provider for :class:`ManifestBuilder` (AZ-323).
|
||||
|
||||
Default impl is :class:`Ed25519ManifestSigner` using
|
||||
``cryptography.hazmat.primitives.asymmetric.ed25519``; tests
|
||||
inject a deterministic in-memory keypair.
|
||||
|
||||
Contract:
|
||||
- :meth:`load_signing_key` takes a path to an operator-supplied
|
||||
PEM-encoded PKCS8 Ed25519 private key, returns an opaque
|
||||
:class:`SigningKeyHandle`. Format errors raise
|
||||
:class:`gps_denied_onboard.components.c10_provisioning.errors.ManifestWriteError`
|
||||
with the underlying ``cryptography`` exception chained via
|
||||
``__cause__``.
|
||||
- :meth:`sign` produces a 64-byte raw Ed25519 signature over the
|
||||
payload bytes. Re-entry-safe; a single handle may be used to
|
||||
sign many payloads.
|
||||
- :meth:`public_key_fingerprint` returns the SHA-256 hex digest of
|
||||
the raw 32-byte public key (operator-mode allowlist key).
|
||||
"""
|
||||
|
||||
def load_signing_key(self, key_path: Path) -> SigningKeyHandle: ...
|
||||
|
||||
def sign(self, key: SigningKeyHandle, payload_bytes: bytes) -> bytes: ...
|
||||
|
||||
def public_key_fingerprint(self, key: SigningKeyHandle) -> str: ...
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
"""C10 ManifestBuilder + Ed25519ManifestSigner (AZ-323).
|
||||
|
||||
Produces the signed cache Manifest covering every shipped artifact
|
||||
plus the build-identity tuple whose canonical hash (``manifest_hash``)
|
||||
is the D-C10-1 idempotence key. Implements the AC-NEW-1 trust chain
|
||||
(takeoff arming refuses to deserialize engines before a Manifest
|
||||
verify succeeds — AZ-324 owns the verify; this task owns the build).
|
||||
|
||||
Cross-component DTOs (``LatLonAlt``, ``BoundingBox``) come from
|
||||
``_types/geo.py``; engine entries from ``_types/inference.py``;
|
||||
the ``Manifest`` placeholder DTO from ``_types/manifests.py``. No
|
||||
direct ``components.X`` imports — the AZ-507 lint forbids it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
from uuid import UUID
|
||||
|
||||
import orjson
|
||||
from cryptography.exceptions import UnsupportedAlgorithm
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
Ed25519PrivateKey,
|
||||
Ed25519PublicKey,
|
||||
)
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
from gps_denied_onboard._types.inference import EngineCacheEntry
|
||||
from gps_denied_onboard.clock import Clock
|
||||
from gps_denied_onboard.components.c10_provisioning.config import (
|
||||
C10ManifestConfig,
|
||||
SigningMode,
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning.errors import (
|
||||
ManifestWriteError,
|
||||
)
|
||||
from gps_denied_onboard.components.c10_provisioning.interface import (
|
||||
ManifestSigner,
|
||||
SigningKeyHandle,
|
||||
)
|
||||
from gps_denied_onboard.helpers.sha256_sidecar import (
|
||||
Sha256Sidecar,
|
||||
Sha256SidecarError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"VALID_SECTOR_CLASSES",
|
||||
"Ed25519ManifestSigner",
|
||||
"ManifestArtifact",
|
||||
"ManifestBuildInput",
|
||||
"ManifestBuilder",
|
||||
"TileHashRecord",
|
||||
"TilesByBboxQuery",
|
||||
]
|
||||
|
||||
_BUILD_LOG_KIND_PREFIX = "c10.manifest"
|
||||
_TAKEOFF_ORIGIN_DECIMALS = 9
|
||||
_MANIFEST_FILENAME = "Manifest.json"
|
||||
_SIGNATURE_FILENAME = "Manifest.json.sig"
|
||||
_ED25519_PUBKEY_BYTES = 32
|
||||
_ED25519_SIG_BYTES = 64
|
||||
|
||||
VALID_SECTOR_CLASSES: frozenset[str] = frozenset(
|
||||
{"active_conflict", "stable_rear"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TileHashRecord:
|
||||
"""Consumer-side DTO carrying the four sort keys + the per-tile digest.
|
||||
|
||||
AZ-323 only needs ``(zoom, lat, lon, source)`` for canonical
|
||||
ordering and ``sha256_hex`` for the aggregate hash. The
|
||||
composition-root adapter wraps C6's ``TileMetadata`` rows into
|
||||
this shape so the AZ-270 lint stays green (no
|
||||
``components.c6_tile_cache`` import from C10).
|
||||
"""
|
||||
|
||||
zoom: int
|
||||
lat: float
|
||||
lon: float
|
||||
source: str
|
||||
sha256_hex: str
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TilesByBboxQuery(Protocol):
|
||||
"""Consumer-side structural cut over C6's ``TileMetadataStore``.
|
||||
|
||||
The composition root adapts
|
||||
:class:`gps_denied_onboard.components.c6_tile_cache.TileMetadataStore`
|
||||
by translating its ``query_by_bbox`` return value into a tuple of
|
||||
:class:`TileHashRecord`. C10 depends on THIS Protocol so
|
||||
``components/c10_provisioning/*`` never imports ``components.c6_*``
|
||||
(AZ-270 + AZ-507 boundary).
|
||||
"""
|
||||
|
||||
def query_by_bbox(
|
||||
self,
|
||||
*,
|
||||
bbox: BoundingBox,
|
||||
zoom_levels: tuple[int, ...],
|
||||
sector_class: str,
|
||||
) -> Iterable[TileHashRecord]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManifestBuildInput:
|
||||
"""Frozen call argument for :meth:`ManifestBuilder.build_manifest`.
|
||||
|
||||
Per the AZ-323 spec ``sector_class`` is the c6 enum's ``.value``
|
||||
string ('active_conflict' / 'stable_rear'); the composition root
|
||||
translates the C6 enum to its string form before injecting so
|
||||
C10 stays free of the C6 import.
|
||||
|
||||
``takeoff_origin`` + ``flight_id`` are the ADR-010 / AZ-489
|
||||
pass-through fields — when supplied they are both baked into the
|
||||
Manifest body AND fed into the ``manifest_hash`` so a re-planned
|
||||
flight produces a fresh cache identity (AC-15 / AC-16).
|
||||
"""
|
||||
|
||||
cache_root: Path
|
||||
bbox: BoundingBox
|
||||
zoom_levels: tuple[int, ...]
|
||||
sector_class: str
|
||||
engine_entries: tuple[EngineCacheEntry, ...]
|
||||
descriptor_index_path: Path
|
||||
calibration_path: Path
|
||||
key_path: Path
|
||||
takeoff_origin: LatLonAlt | None = None
|
||||
flight_id: UUID | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManifestArtifact:
|
||||
"""Return value of :meth:`ManifestBuilder.build_manifest`.
|
||||
|
||||
``manifest_hash`` is the D-C10-1 idempotence key — derived from
|
||||
the build identity tuple, NOT from the Manifest file bytes (which
|
||||
include ``built_at`` and so differ across runs). The Manifest
|
||||
file's own SHA-256 lives on disk as ``Manifest.json.sha256`` per
|
||||
AC-11.
|
||||
"""
|
||||
|
||||
manifest_path: Path
|
||||
signature_path: Path
|
||||
manifest_hash: str
|
||||
signing_public_key_fingerprint: str
|
||||
total_artifacts_listed: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Ed25519SigningKey(SigningKeyHandle):
|
||||
"""Opaque handle wrapping a ``cryptography`` Ed25519 private key.
|
||||
|
||||
Frozen so callers cannot mutate the key in flight; the underlying
|
||||
``Ed25519PrivateKey`` object stays in the dataclass field but is
|
||||
not exposed by name on :class:`SigningKeyHandle`.
|
||||
"""
|
||||
|
||||
private_key: Ed25519PrivateKey = field(repr=False)
|
||||
public_key_raw: bytes
|
||||
fingerprint_hex: str
|
||||
|
||||
|
||||
class Ed25519ManifestSigner:
|
||||
"""Default :class:`ManifestSigner` impl backed by ``cryptography``.
|
||||
|
||||
Loads PEM-encoded PKCS8 Ed25519 private keys per AZ-323 Risk 4 —
|
||||
other formats (OpenSSH, raw 32-byte) raise
|
||||
:class:`ManifestWriteError` with the underlying ``cryptography``
|
||||
exception chained via ``__cause__`` (AC-9).
|
||||
"""
|
||||
|
||||
def load_signing_key(self, key_path: Path) -> SigningKeyHandle:
|
||||
try:
|
||||
pem_bytes = key_path.read_bytes()
|
||||
except (OSError, FileNotFoundError) as exc:
|
||||
raise ManifestWriteError(
|
||||
f"operator signing key load failed: cannot read {key_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
private_key = load_pem_private_key(pem_bytes, password=None)
|
||||
except (ValueError, TypeError, UnsupportedAlgorithm) as exc:
|
||||
raise ManifestWriteError(
|
||||
f"operator signing key load failed: malformed PEM at {key_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
if not isinstance(private_key, Ed25519PrivateKey):
|
||||
raise ManifestWriteError(
|
||||
"operator signing key load failed: not an Ed25519 private key "
|
||||
f"(got {type(private_key).__name__}); AZ-323 supports Ed25519 only"
|
||||
)
|
||||
|
||||
public_key = private_key.public_key()
|
||||
public_raw = _ed25519_public_raw(public_key)
|
||||
return _Ed25519SigningKey(
|
||||
private_key=private_key,
|
||||
public_key_raw=public_raw,
|
||||
fingerprint_hex=hashlib.sha256(public_raw).hexdigest(),
|
||||
)
|
||||
|
||||
def sign(self, key: SigningKeyHandle, payload_bytes: bytes) -> bytes:
|
||||
handle = _require_ed25519_handle(key)
|
||||
signature = handle.private_key.sign(payload_bytes)
|
||||
# Defensive: Ed25519 signatures are always 64 bytes — fail fast on
|
||||
# a library upgrade that changes the contract.
|
||||
if len(signature) != _ED25519_SIG_BYTES:
|
||||
raise ManifestWriteError(
|
||||
f"Ed25519 signer produced {len(signature)} bytes; expected "
|
||||
f"{_ED25519_SIG_BYTES}"
|
||||
)
|
||||
return signature
|
||||
|
||||
def public_key_fingerprint(self, key: SigningKeyHandle) -> str:
|
||||
return _require_ed25519_handle(key).fingerprint_hex
|
||||
|
||||
|
||||
def _ed25519_public_raw(public_key: Ed25519PublicKey) -> bytes:
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding,
|
||||
PublicFormat,
|
||||
)
|
||||
|
||||
raw = public_key.public_bytes(
|
||||
encoding=Encoding.Raw,
|
||||
format=PublicFormat.Raw,
|
||||
)
|
||||
if len(raw) != _ED25519_PUBKEY_BYTES:
|
||||
raise ManifestWriteError(
|
||||
f"Ed25519 public key has unexpected length: {len(raw)} != "
|
||||
f"{_ED25519_PUBKEY_BYTES}"
|
||||
)
|
||||
return raw
|
||||
|
||||
|
||||
def _require_ed25519_handle(key: SigningKeyHandle) -> _Ed25519SigningKey:
|
||||
if not isinstance(key, _Ed25519SigningKey):
|
||||
raise ManifestWriteError(
|
||||
"Ed25519ManifestSigner received a foreign SigningKeyHandle "
|
||||
f"({type(key).__name__}); only handles produced by load_signing_key "
|
||||
"are accepted"
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
class ManifestBuilder:
|
||||
"""Build a signed cache Manifest at ``cache_root/Manifest.json``.
|
||||
|
||||
Atomic-write contract: Manifest body + ``.sha256`` sidecar +
|
||||
``.sig`` are all written via :class:`Sha256Sidecar.write_atomic*`,
|
||||
so a kill mid-build leaves either the previous-good triple or
|
||||
the new triple — never a partial Manifest (AC-10).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sidecar: Sha256Sidecar,
|
||||
signer: ManifestSigner,
|
||||
tile_metadata_store: TilesByBboxQuery,
|
||||
logger: logging.Logger,
|
||||
clock: Clock,
|
||||
config: C10ManifestConfig,
|
||||
) -> None:
|
||||
self._sidecar = sidecar
|
||||
self._signer = signer
|
||||
self._tiles = tile_metadata_store
|
||||
self._log = logger
|
||||
self._clock = clock
|
||||
self._config = config
|
||||
|
||||
def build_manifest(self, request: ManifestBuildInput) -> ManifestArtifact:
|
||||
self._validate_request(request)
|
||||
key = self._load_and_gate_key(request.key_path)
|
||||
fingerprint = self._signer.public_key_fingerprint(key)
|
||||
self._gate_operator_mode(fingerprint)
|
||||
|
||||
calibration_sha256 = self._sha256_file(request.calibration_path)
|
||||
descriptor_index_sha256 = self._read_descriptor_index_sidecar(
|
||||
request.descriptor_index_path
|
||||
)
|
||||
|
||||
sorted_tiles = self._fetch_sorted_tiles(
|
||||
bbox=request.bbox,
|
||||
zoom_levels=request.zoom_levels,
|
||||
sector_class=request.sector_class,
|
||||
)
|
||||
tiles_coverage_sha256 = _aggregate_tile_hash(sorted_tiles)
|
||||
|
||||
engine_artifacts = tuple(
|
||||
{
|
||||
"path": str(entry.engine_path),
|
||||
"sha256": entry.sha256_hex,
|
||||
}
|
||||
for entry in request.engine_entries
|
||||
)
|
||||
|
||||
manifest_hash = _compute_manifest_hash(
|
||||
engine_entries=request.engine_entries,
|
||||
calibration_sha256=calibration_sha256,
|
||||
descriptor_index_sha256=descriptor_index_sha256,
|
||||
tiles_coverage_sha256=tiles_coverage_sha256,
|
||||
sector_class=request.sector_class,
|
||||
bbox=request.bbox,
|
||||
zoom_levels=request.zoom_levels,
|
||||
takeoff_origin=request.takeoff_origin,
|
||||
flight_id=request.flight_id,
|
||||
)
|
||||
|
||||
built_at_iso = _ns_to_iso_utc(self._clock.time_ns())
|
||||
|
||||
manifest_body = self._assemble_manifest_dict(
|
||||
schema_version=self._config.schema_version,
|
||||
bbox=request.bbox,
|
||||
zoom_levels=request.zoom_levels,
|
||||
sector_class=request.sector_class,
|
||||
built_at_iso=built_at_iso,
|
||||
manifest_hash=manifest_hash,
|
||||
flight_id=request.flight_id,
|
||||
takeoff_origin=request.takeoff_origin,
|
||||
engine_artifacts=engine_artifacts,
|
||||
descriptor_index_path=request.descriptor_index_path,
|
||||
descriptor_index_sha256=descriptor_index_sha256,
|
||||
calibration_path=request.calibration_path,
|
||||
calibration_sha256=calibration_sha256,
|
||||
tiles_coverage_sha256=tiles_coverage_sha256,
|
||||
tiles_count=len(sorted_tiles),
|
||||
fingerprint=fingerprint,
|
||||
)
|
||||
|
||||
canonical_bytes = _canonical_json_with_trailing_newline(manifest_body)
|
||||
manifest_path = request.cache_root / _MANIFEST_FILENAME
|
||||
signature_path = request.cache_root / _SIGNATURE_FILENAME
|
||||
|
||||
request.cache_root.mkdir(parents=True, exist_ok=True)
|
||||
self._atomic_write_manifest(manifest_path, canonical_bytes)
|
||||
signature_bytes = self._signer.sign(key, canonical_bytes)
|
||||
self._atomic_write_signature(signature_path, signature_bytes)
|
||||
|
||||
total_artifacts = len(engine_artifacts) + 3 # descriptor_index + calibration + tiles_coverage
|
||||
|
||||
self._log.info(
|
||||
f"{_BUILD_LOG_KIND_PREFIX}.build.success",
|
||||
extra={
|
||||
"kind": f"{_BUILD_LOG_KIND_PREFIX}.build.success",
|
||||
"kv": {
|
||||
"manifest_hash": manifest_hash,
|
||||
"total_artifacts_listed": total_artifacts,
|
||||
"signing_public_key_fingerprint": fingerprint,
|
||||
"tiles_count": len(sorted_tiles),
|
||||
"schema_version": self._config.schema_version,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return ManifestArtifact(
|
||||
manifest_path=manifest_path,
|
||||
signature_path=signature_path,
|
||||
manifest_hash=manifest_hash,
|
||||
signing_public_key_fingerprint=fingerprint,
|
||||
total_artifacts_listed=total_artifacts,
|
||||
)
|
||||
|
||||
def _validate_request(self, request: ManifestBuildInput) -> None:
|
||||
if request.sector_class not in VALID_SECTOR_CLASSES:
|
||||
raise ManifestWriteError(
|
||||
f"sector_class={request.sector_class!r} not in "
|
||||
f"{sorted(VALID_SECTOR_CLASSES)}"
|
||||
)
|
||||
if not request.zoom_levels:
|
||||
raise ManifestWriteError(
|
||||
"zoom_levels must be a non-empty tuple of ints"
|
||||
)
|
||||
if request.takeoff_origin is not None:
|
||||
origin = request.takeoff_origin
|
||||
if not (-90.0 <= origin.lat_deg <= 90.0):
|
||||
raise ManifestWriteError(
|
||||
f"takeoff_origin.lat_deg={origin.lat_deg} out of [-90, 90]"
|
||||
)
|
||||
if not (-180.0 <= origin.lon_deg <= 180.0):
|
||||
raise ManifestWriteError(
|
||||
f"takeoff_origin.lon_deg={origin.lon_deg} out of [-180, 180]"
|
||||
)
|
||||
|
||||
def _load_and_gate_key(self, key_path: Path) -> SigningKeyHandle:
|
||||
try:
|
||||
return self._signer.load_signing_key(key_path)
|
||||
except ManifestWriteError:
|
||||
# Already logged at the call site below; the signer raises with
|
||||
# an actionable diagnostic. We must still emit the ERROR record
|
||||
# so operators see a single structured "build.error" entry.
|
||||
self._log.error(
|
||||
f"{_BUILD_LOG_KIND_PREFIX}.build.error",
|
||||
extra={
|
||||
"kind": f"{_BUILD_LOG_KIND_PREFIX}.build.error",
|
||||
"kv": {
|
||||
"phase": "load_signing_key",
|
||||
"key_path": str(key_path),
|
||||
},
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
def _gate_operator_mode(self, fingerprint: str) -> None:
|
||||
allowlist = self._config.allowed_operator_fingerprints
|
||||
if self._config.signing_mode is SigningMode.OPERATOR:
|
||||
if fingerprint not in allowlist:
|
||||
self._log.error(
|
||||
f"{_BUILD_LOG_KIND_PREFIX}.build.error",
|
||||
extra={
|
||||
"kind": f"{_BUILD_LOG_KIND_PREFIX}.build.error",
|
||||
"kv": {
|
||||
"phase": "operator_mode_gate",
|
||||
"offered_fingerprint": fingerprint,
|
||||
"allowed_fingerprints": list(allowlist),
|
||||
},
|
||||
},
|
||||
)
|
||||
raise ManifestWriteError(
|
||||
"signing key fingerprint not in allowed_operator_fingerprints: "
|
||||
f"offered={fingerprint!r}, allowed={sorted(allowlist)!r}"
|
||||
)
|
||||
elif self._config.signing_mode is SigningMode.DEV:
|
||||
if fingerprint in allowlist:
|
||||
self._log.warning(
|
||||
f"{_BUILD_LOG_KIND_PREFIX}.dev_mode_with_operator_key",
|
||||
extra={
|
||||
"kind": f"{_BUILD_LOG_KIND_PREFIX}.dev_mode_with_operator_key",
|
||||
"kv": {
|
||||
"offered_fingerprint": fingerprint,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def _sha256_file(self, path: Path) -> str:
|
||||
try:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
except (OSError, FileNotFoundError) as exc:
|
||||
raise ManifestWriteError(
|
||||
f"manifest build: cannot hash artifact at {path}: {exc}"
|
||||
) from exc
|
||||
|
||||
def _read_descriptor_index_sidecar(self, descriptor_index_path: Path) -> str:
|
||||
sidecar_path = Path(str(descriptor_index_path) + ".sha256")
|
||||
try:
|
||||
text = sidecar_path.read_text(encoding="ascii").strip()
|
||||
except (OSError, FileNotFoundError) as exc:
|
||||
raise ManifestWriteError(
|
||||
"manifest build: descriptor_index sidecar missing at "
|
||||
f"{sidecar_path}: {exc}"
|
||||
) from exc
|
||||
if len(text) != 64:
|
||||
raise ManifestWriteError(
|
||||
"manifest build: descriptor_index sidecar at "
|
||||
f"{sidecar_path} is not 64 hex chars (got {len(text)})"
|
||||
)
|
||||
try:
|
||||
int(text, 16)
|
||||
except ValueError as exc:
|
||||
raise ManifestWriteError(
|
||||
"manifest build: descriptor_index sidecar at "
|
||||
f"{sidecar_path} is not hex: {exc}"
|
||||
) from exc
|
||||
if text.lower() != text:
|
||||
raise ManifestWriteError(
|
||||
"manifest build: descriptor_index sidecar at "
|
||||
f"{sidecar_path} must be lowercase hex"
|
||||
)
|
||||
return text
|
||||
|
||||
def _fetch_sorted_tiles(
|
||||
self,
|
||||
*,
|
||||
bbox: BoundingBox,
|
||||
zoom_levels: tuple[int, ...],
|
||||
sector_class: str,
|
||||
) -> tuple[TileHashRecord, ...]:
|
||||
raw = tuple(
|
||||
self._tiles.query_by_bbox(
|
||||
bbox=bbox,
|
||||
zoom_levels=zoom_levels,
|
||||
sector_class=sector_class,
|
||||
)
|
||||
)
|
||||
return tuple(
|
||||
sorted(raw, key=lambda r: (r.zoom, r.lat, r.lon, r.source))
|
||||
)
|
||||
|
||||
def _assemble_manifest_dict(
|
||||
self,
|
||||
*,
|
||||
schema_version: str,
|
||||
bbox: BoundingBox,
|
||||
zoom_levels: tuple[int, ...],
|
||||
sector_class: str,
|
||||
built_at_iso: str,
|
||||
manifest_hash: str,
|
||||
flight_id: UUID | None,
|
||||
takeoff_origin: LatLonAlt | None,
|
||||
engine_artifacts: tuple[dict[str, str], ...],
|
||||
descriptor_index_path: Path,
|
||||
descriptor_index_sha256: str,
|
||||
calibration_path: Path,
|
||||
calibration_sha256: str,
|
||||
tiles_coverage_sha256: str,
|
||||
tiles_count: int,
|
||||
fingerprint: str,
|
||||
) -> dict[str, object]:
|
||||
flight_block: dict[str, object] = {
|
||||
"flight_id": str(flight_id) if flight_id is not None else None,
|
||||
}
|
||||
if takeoff_origin is not None:
|
||||
flight_block["takeoff_origin"] = {
|
||||
"lat_deg": takeoff_origin.lat_deg,
|
||||
"lon_deg": takeoff_origin.lon_deg,
|
||||
"alt_m": takeoff_origin.alt_m,
|
||||
}
|
||||
|
||||
return {
|
||||
"schema_version": schema_version,
|
||||
"build": {
|
||||
"bbox": {
|
||||
"min_lat_deg": bbox.min_lat_deg,
|
||||
"min_lon_deg": bbox.min_lon_deg,
|
||||
"max_lat_deg": bbox.max_lat_deg,
|
||||
"max_lon_deg": bbox.max_lon_deg,
|
||||
},
|
||||
"zoom_levels": list(zoom_levels),
|
||||
"sector_class": sector_class,
|
||||
"built_at": built_at_iso,
|
||||
"manifest_hash": manifest_hash,
|
||||
},
|
||||
"flight": flight_block,
|
||||
"artifacts": {
|
||||
"engines": [dict(e) for e in engine_artifacts],
|
||||
"descriptor_index": {
|
||||
"path": str(descriptor_index_path),
|
||||
"sha256": descriptor_index_sha256,
|
||||
},
|
||||
"calibration": {
|
||||
"path": str(calibration_path),
|
||||
"sha256": calibration_sha256,
|
||||
},
|
||||
"tiles_coverage": {
|
||||
"sha256": tiles_coverage_sha256,
|
||||
"tile_count": tiles_count,
|
||||
},
|
||||
},
|
||||
"signing_public_key_fingerprint": fingerprint,
|
||||
}
|
||||
|
||||
def _atomic_write_manifest(self, path: Path, payload: bytes) -> None:
|
||||
try:
|
||||
self._sidecar.write_atomic_and_sidecar(path, payload)
|
||||
except Sha256SidecarError as exc:
|
||||
self._log.error(
|
||||
f"{_BUILD_LOG_KIND_PREFIX}.build.error",
|
||||
extra={
|
||||
"kind": f"{_BUILD_LOG_KIND_PREFIX}.build.error",
|
||||
"kv": {"phase": "write_manifest", "path": str(path)},
|
||||
},
|
||||
)
|
||||
raise ManifestWriteError(
|
||||
f"manifest build: atomic write failed at {path}: {exc}"
|
||||
) from exc
|
||||
|
||||
def _atomic_write_signature(self, path: Path, payload: bytes) -> None:
|
||||
try:
|
||||
self._sidecar.write_atomic(path, payload)
|
||||
except Sha256SidecarError as exc:
|
||||
self._log.error(
|
||||
f"{_BUILD_LOG_KIND_PREFIX}.build.error",
|
||||
extra={
|
||||
"kind": f"{_BUILD_LOG_KIND_PREFIX}.build.error",
|
||||
"kv": {"phase": "write_signature", "path": str(path)},
|
||||
},
|
||||
)
|
||||
raise ManifestWriteError(
|
||||
f"manifest build: atomic write failed at {path}: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _aggregate_tile_hash(records: tuple[TileHashRecord, ...]) -> str:
|
||||
hasher = hashlib.sha256()
|
||||
for r in records:
|
||||
hasher.update(
|
||||
(
|
||||
f"z{r.zoom}|lat{r.lat:.9f}|lon{r.lon:.9f}|src{r.source}"
|
||||
f":{r.sha256_hex}\n"
|
||||
).encode("ascii")
|
||||
)
|
||||
return hasher.hexdigest()
|
||||
|
||||
|
||||
def _canonical_json_with_trailing_newline(payload: dict[str, object]) -> bytes:
|
||||
body = orjson.dumps(
|
||||
payload,
|
||||
option=orjson.OPT_SORT_KEYS | orjson.OPT_INDENT_2,
|
||||
)
|
||||
if not body.endswith(b"\n"):
|
||||
body += b"\n"
|
||||
return body
|
||||
|
||||
|
||||
def _compute_manifest_hash(
|
||||
*,
|
||||
engine_entries: tuple[EngineCacheEntry, ...],
|
||||
calibration_sha256: str,
|
||||
descriptor_index_sha256: str,
|
||||
tiles_coverage_sha256: str,
|
||||
sector_class: str,
|
||||
bbox: BoundingBox,
|
||||
zoom_levels: tuple[int, ...],
|
||||
takeoff_origin: LatLonAlt | None,
|
||||
flight_id: UUID | None,
|
||||
) -> str:
|
||||
# Engine identity is `(model_name, precision, sm, jetpack, trt, sha256)`
|
||||
# so a stale-host fp16 build never collides with a fresh int8 build —
|
||||
# this matches the AZ-281 filename schema fields modulo the precision
|
||||
# axis (which fp16 vs int8 makes load-bearing).
|
||||
model_ids = sorted(
|
||||
(
|
||||
str(entry.engine_path),
|
||||
entry.sha256_hex,
|
||||
)
|
||||
for entry in engine_entries
|
||||
)
|
||||
origin_tuple: tuple[float, float, float] | None
|
||||
if takeoff_origin is not None:
|
||||
origin_tuple = (
|
||||
round(takeoff_origin.lat_deg, _TAKEOFF_ORIGIN_DECIMALS),
|
||||
round(takeoff_origin.lon_deg, _TAKEOFF_ORIGIN_DECIMALS),
|
||||
round(takeoff_origin.alt_m, _TAKEOFF_ORIGIN_DECIMALS),
|
||||
)
|
||||
else:
|
||||
origin_tuple = None
|
||||
build_identity = {
|
||||
"model_ids": [list(entry) for entry in model_ids],
|
||||
"calibration_sha256": calibration_sha256,
|
||||
"descriptor_index_sha256": descriptor_index_sha256,
|
||||
"tiles_coverage_sha256": tiles_coverage_sha256,
|
||||
"sector_class": sector_class,
|
||||
"bbox": [
|
||||
bbox.min_lat_deg,
|
||||
bbox.min_lon_deg,
|
||||
bbox.max_lat_deg,
|
||||
bbox.max_lon_deg,
|
||||
],
|
||||
"zoom_levels": sorted(zoom_levels),
|
||||
"takeoff_origin": list(origin_tuple) if origin_tuple is not None else None,
|
||||
"flight_id": str(flight_id) if flight_id is not None else None,
|
||||
}
|
||||
canonical = orjson.dumps(build_identity, option=orjson.OPT_SORT_KEYS)
|
||||
return hashlib.sha256(canonical).hexdigest()
|
||||
|
||||
|
||||
def _ns_to_iso_utc(time_ns: int) -> str:
|
||||
"""Format ns-since-epoch as RFC 3339 UTC with second precision.
|
||||
|
||||
Second precision suffices for ``built_at`` — operators inspect the
|
||||
Manifest at hour / minute granularity, and the build-identity
|
||||
hash deliberately excludes ``built_at`` so the AC-2 byte-for-byte
|
||||
determinism check works only by redacting this exact field.
|
||||
"""
|
||||
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time_ns / 1_000_000_000))
|
||||
@@ -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