mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 13:31:14 +00:00
[AZ-393] [AZ-394] [AZ-395] C8 outbound chain + AP MAVLink2 signing
AZ-393 ArduPilot outbound: PymavlinkArdupilotAdapter encodes EstimatorOutput to MAVLink2 GPS_INPUT via gps_input_send; emits NAMED_VALUE_FLOAT(name="src_lbl") every frame and STATUSTEXT on source_label transition (1 Hz per-severity cap). Smoothed-output guard (Invariant 6), single-writer thread (Invariant 8), SPD propagation. Shared helper _outbound_provenance.py owns the canonical source-label-to-float table + transition rate-limiter. AZ-394 iNav outbound: Msp2InavAdapter encodes EstimatorOutput to hand-rolled MSP2_SENSOR_GPS (0x1F03, 52-byte LE payload via _msp2_sensor_gps_encoder.py + YAMSPy send_RAW_msg). Secondary unsigned MAVLink channel for STATUSTEXT transitions. open() rejects non-None signing_key (RESTRICT-COMM-2 / Invariant 2); request_source_set_switch raises SourceSetSwitchNotSupportedError (Invariant 9 verified: never calls setup_signing on secondary). AZ-395 AP MAVLink2 signing: ephemeral per-flight 32-byte key from secrets.token_bytes; pymavlink setup_signing handshake at open(); in-place bytearray zeroisation on close(); mid-flight signing-failure detection (ERROR log + WARNING STATUSTEXT + no raise; threshold configurable). Key never logged / persisted / serialised (regex-scanned by AC-4/AC-5). BUILD_DEV_STATIC_KEY=ON enables repeatable static-key dev path; rejected at open() when the build flag is absent. Shared: EstimatorOutput.smoothed (default False) added for the Invariant 6 gate at the C8 boundary; FcConfig extended with dev_static_signing_key + signing_failure_threshold (additive defaults; cross-field validation in __post_init__). Tests: 33 new AC tests (11 + 11 + 11) covering all 30 ACs; full suite 476 passing / 2 skipped / 0 failing (was 443). Contract surfaces unchanged at fc_adapter_protocol v1.0.0 and composition_root v1.2.0. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
"""AZ-393 — PymavlinkArdupilotAdapter outbound AC tests.
|
||||
|
||||
Covers all 10 ACs of AZ-393. The AP signing path is covered by
|
||||
``test_az395_mavlink_signing.py``; this file uses
|
||||
``signing_key_source="none"`` so the AP open path is signing-free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.fc import FcKind, PortConfig
|
||||
from gps_denied_onboard._types.geo import LatLonAlt
|
||||
from gps_denied_onboard._types.pose import EstimatorOutput
|
||||
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
|
||||
CovarianceProjector,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter._outbound_provenance import (
|
||||
source_label_to_float,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter.errors import FcEmitError
|
||||
from gps_denied_onboard.components.c8_fc_adapter.pymavlink_ardupilot_adapter import (
|
||||
PymavlinkArdupilotAdapter,
|
||||
)
|
||||
from gps_denied_onboard.config import load_config
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers — pymavlink stand-in
|
||||
|
||||
|
||||
class _MavStub:
|
||||
"""Captures pymavlink ``mav.*_send`` calls for wire-level assertions."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.gps_input_calls: list[tuple[Any, ...]] = []
|
||||
self.named_value_float_calls: list[tuple[Any, ...]] = []
|
||||
self.statustext_calls: list[tuple[int, bytes]] = []
|
||||
|
||||
def gps_input_send(self, *args: Any) -> None:
|
||||
self.gps_input_calls.append(args)
|
||||
|
||||
def named_value_float_send(self, time_boot_ms: int, name: bytes, value: float) -> None:
|
||||
self.named_value_float_calls.append((time_boot_ms, name, value))
|
||||
|
||||
def statustext_send(self, severity: int, text: bytes) -> None:
|
||||
self.statustext_calls.append((severity, text))
|
||||
|
||||
|
||||
class _ConnStub:
|
||||
def __init__(self) -> None:
|
||||
self.mav = _MavStub()
|
||||
self.setup_signing_calls: list[bytes] = []
|
||||
self.closed = False
|
||||
|
||||
def setup_signing(self, key: bytes) -> None:
|
||||
self.setup_signing_calls.append(bytes(key))
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn() -> _ConnStub:
|
||||
return _ConnStub()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(conn: _ConnStub, tmp_path) -> PymavlinkArdupilotAdapter:
|
||||
cfg = _config_for_ap(tmp_path, signing_key_source="none")
|
||||
fdr = mock.MagicMock()
|
||||
cov = CovarianceProjector(fdr_client=fdr)
|
||||
adapter = PymavlinkArdupilotAdapter(
|
||||
config=cfg,
|
||||
wgs_converter=mock.MagicMock(),
|
||||
covariance_projector=cov,
|
||||
fdr_client=fdr,
|
||||
clock=lambda: 1.0,
|
||||
flight_id="flt-test",
|
||||
connect_factory=lambda device, baud: conn,
|
||||
)
|
||||
port = PortConfig(
|
||||
fc_kind=FcKind.ARDUPILOT_PLANE,
|
||||
device="/dev/null",
|
||||
baud=921600,
|
||||
)
|
||||
adapter.open(port, signing_key=None)
|
||||
yield adapter
|
||||
adapter.close()
|
||||
|
||||
|
||||
def _config_for_ap(tmp_path, *, signing_key_source: str = "none"):
|
||||
"""Build a Config with ``fc.adapter='ardupilot_plane'``."""
|
||||
env = {
|
||||
"FC_ADAPTER": "ardupilot_plane",
|
||||
"FC_PORT_DEVICE": "/dev/null",
|
||||
"FC_PORT_BAUD": "921600",
|
||||
"FC_SIGNING_KEY_SOURCE": signing_key_source,
|
||||
}
|
||||
return load_config(env=env, paths=(), require_env=False)
|
||||
|
||||
|
||||
def _make_output(
|
||||
*,
|
||||
source_label: str = "visual_propagated",
|
||||
smoothed: bool = False,
|
||||
cov: np.ndarray | None = None,
|
||||
wgs: LatLonAlt | None = None,
|
||||
frame_id: int = 1,
|
||||
) -> EstimatorOutput:
|
||||
if cov is None:
|
||||
cov = np.eye(6, dtype=np.float64) * 0.25
|
||||
if wgs is None:
|
||||
wgs = LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0)
|
||||
return EstimatorOutput(
|
||||
frame_id=frame_id,
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
pose_se3=np.eye(4),
|
||||
covariance_6x6=cov,
|
||||
source_label=source_label,
|
||||
health=None,
|
||||
smoothed=smoothed,
|
||||
extras={"wgs84": wgs},
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-1: GPS_INPUT field fidelity
|
||||
|
||||
|
||||
def test_ac1_gps_input_field_fidelity(adapter: PymavlinkArdupilotAdapter, conn: _ConnStub) -> None:
|
||||
# Arrange
|
||||
cov = np.diag([0.25, 0.25, 9.0, 1.0, 1.0, 1.0]).astype(np.float64)
|
||||
wgs = LatLonAlt(lat_deg=50.4501, lon_deg=30.5234, alt_m=180.0)
|
||||
output = _make_output(cov=cov, wgs=wgs)
|
||||
expected_horiz_m = CovarianceProjector(
|
||||
fdr_client=mock.MagicMock()
|
||||
).to_ardupilot_horiz_accuracy_m(output)
|
||||
|
||||
# Act
|
||||
result = adapter.emit_external_position(output)
|
||||
|
||||
# Assert
|
||||
assert len(conn.mav.gps_input_calls) == 1
|
||||
call = conn.mav.gps_input_calls[0]
|
||||
# gps_input_send signature: (time_us, gps_id, ignore_flags, time_week_ms,
|
||||
# time_week, fix_type, lat_e7, lon_e7, alt, hdop, vdop, vn, ve, vd,
|
||||
# speed_accuracy, horiz_accuracy, vert_accuracy, sat_visible, yaw)
|
||||
assert call[5] == 3 # fix type 3D
|
||||
assert call[6] == int(wgs.lat_deg * 1e7)
|
||||
assert call[7] == int(wgs.lon_deg * 1e7)
|
||||
assert call[8] == pytest.approx(wgs.alt_m)
|
||||
assert call[15] == pytest.approx(expected_horiz_m, abs=1e-3)
|
||||
assert result.horiz_accuracy_m == pytest.approx(expected_horiz_m)
|
||||
assert result.fc_kind is FcKind.ARDUPILOT_PLANE
|
||||
assert result.sequence_number == 1
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-2: GPS_INPUT every frame
|
||||
|
||||
|
||||
def test_ac2_gps_input_every_frame(adapter: PymavlinkArdupilotAdapter, conn: _ConnStub) -> None:
|
||||
for i in range(100):
|
||||
adapter.emit_external_position(_make_output(frame_id=i))
|
||||
assert len(conn.mav.gps_input_calls) == 100
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-3: NAMED_VALUE_FLOAT every frame, correct mapping
|
||||
|
||||
|
||||
def test_ac3_named_value_float_every_frame(
|
||||
adapter: PymavlinkArdupilotAdapter, conn: _ConnStub
|
||||
) -> None:
|
||||
for i in range(100):
|
||||
adapter.emit_external_position(_make_output(frame_id=i))
|
||||
assert len(conn.mav.named_value_float_calls) == 100
|
||||
for _, name, value in conn.mav.named_value_float_calls:
|
||||
assert name == b"src_lbl"
|
||||
assert value == pytest.approx(source_label_to_float("visual_propagated"))
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-4: STATUSTEXT rate-limited on transition
|
||||
|
||||
|
||||
def test_ac4_statustext_only_on_transition(
|
||||
adapter: PymavlinkArdupilotAdapter, conn: _ConnStub
|
||||
) -> None:
|
||||
"""100 frames, source_label toggles every 10; expect 10 transitions."""
|
||||
# Loosen the 1 s per-severity hard cap for the test by patching the
|
||||
# rate-limiter's min_interval_s — AC-4 measures the transition
|
||||
# behaviour, not the secondary 1 Hz spam-defence cap.
|
||||
adapter._provenance._min_interval_s = 0.0 # type: ignore[attr-defined]
|
||||
labels = ["visual_propagated", "sat_anchored"]
|
||||
for i in range(100):
|
||||
label = labels[(i // 10) % 2]
|
||||
adapter.emit_external_position(_make_output(source_label=label, frame_id=i))
|
||||
# Each block of 10 frames is a single label; that's 10 blocks → 9
|
||||
# transitions between adjacent blocks plus 1 for the initial
|
||||
# None->visual_propagated bootstrap.
|
||||
assert len(conn.mav.statustext_calls) == 10
|
||||
|
||||
|
||||
def test_ac4_statustext_zero_within_state(
|
||||
adapter: PymavlinkArdupilotAdapter, conn: _ConnStub
|
||||
) -> None:
|
||||
"""100 frames all on the same source label → exactly 1 STATUSTEXT (the bootstrap)."""
|
||||
adapter._provenance._min_interval_s = 0.0 # type: ignore[attr-defined]
|
||||
for i in range(100):
|
||||
adapter.emit_external_position(_make_output(frame_id=i))
|
||||
assert len(conn.mav.statustext_calls) == 1
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-5: Smoothed output rejected
|
||||
|
||||
|
||||
def test_ac5_smoothed_output_rejected(
|
||||
adapter: PymavlinkArdupilotAdapter, conn: _ConnStub, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
output = _make_output(smoothed=True)
|
||||
with caplog.at_level(logging.ERROR), pytest.raises(FcEmitError, match="smoothed"):
|
||||
adapter.emit_external_position(output)
|
||||
assert len(conn.mav.gps_input_calls) == 0
|
||||
assert any(getattr(rec, "kind", None) == "c8.ap.emit_failed" for rec in caplog.records)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-6: Non-SPD covariance rejected
|
||||
|
||||
|
||||
def test_ac6_non_spd_covariance_rejected(
|
||||
adapter: PymavlinkArdupilotAdapter, conn: _ConnStub
|
||||
) -> None:
|
||||
bad_cov = np.eye(6, dtype=np.float64)
|
||||
bad_cov[0, 0] = -1.0 # not positive-definite
|
||||
output = _make_output(cov=bad_cov)
|
||||
with pytest.raises(FcEmitError):
|
||||
adapter.emit_external_position(output)
|
||||
assert len(conn.mav.gps_input_calls) == 0
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-7: Single-writer thread
|
||||
|
||||
|
||||
def test_ac7_single_writer_thread(adapter: PymavlinkArdupilotAdapter) -> None:
|
||||
adapter.emit_external_position(_make_output()) # binds to main thread
|
||||
|
||||
err: list[BaseException] = []
|
||||
|
||||
def run() -> None:
|
||||
try:
|
||||
adapter.emit_external_position(_make_output(frame_id=2))
|
||||
except RuntimeError as e:
|
||||
err.append(e)
|
||||
|
||||
t = threading.Thread(target=run)
|
||||
t.start()
|
||||
t.join(timeout=2.0)
|
||||
assert len(err) == 1
|
||||
assert "single-writer" in str(err[0]).lower()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-8: Open without signing key (placeholder; AZ-395 tightens this)
|
||||
|
||||
|
||||
def test_ac8_open_without_signing_key_succeeds(conn: _ConnStub, tmp_path) -> None:
|
||||
cfg = _config_for_ap(tmp_path, signing_key_source="none")
|
||||
fdr = mock.MagicMock()
|
||||
a = PymavlinkArdupilotAdapter(
|
||||
config=cfg,
|
||||
wgs_converter=mock.MagicMock(),
|
||||
covariance_projector=CovarianceProjector(fdr_client=fdr),
|
||||
fdr_client=fdr,
|
||||
connect_factory=lambda device, baud: conn,
|
||||
)
|
||||
port = PortConfig(
|
||||
fc_kind=FcKind.ARDUPILOT_PLANE,
|
||||
device="/dev/null",
|
||||
baud=921600,
|
||||
)
|
||||
a.open(port, signing_key=None)
|
||||
try:
|
||||
assert a._opened is True
|
||||
finally:
|
||||
a.close()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-9: source-set switch raises NotImplementedError
|
||||
|
||||
|
||||
def test_ac9_source_set_switch_not_implemented(adapter: PymavlinkArdupilotAdapter) -> None:
|
||||
with pytest.raises(NotImplementedError, match="AZ-396"):
|
||||
adapter.request_source_set_switch()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-10: First emit logged once
|
||||
|
||||
|
||||
def test_ac10_first_emit_logged_once(
|
||||
adapter: PymavlinkArdupilotAdapter, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with caplog.at_level(logging.INFO):
|
||||
for i in range(5):
|
||||
adapter.emit_external_position(_make_output(frame_id=i))
|
||||
first_emit_records = [
|
||||
rec for rec in caplog.records if getattr(rec, "kind", None) == "c8.ap.first_emit"
|
||||
]
|
||||
assert len(first_emit_records) == 1
|
||||
@@ -0,0 +1,301 @@
|
||||
"""AZ-394 — Msp2InavAdapter outbound AC tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.fc import FcKind, PortConfig
|
||||
from gps_denied_onboard._types.geo import LatLonAlt
|
||||
from gps_denied_onboard._types.pose import EstimatorOutput
|
||||
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
|
||||
CovarianceProjector,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter._msp2_sensor_gps_encoder import (
|
||||
MSP2_SENSOR_GPS_CODE,
|
||||
decode_msp2_sensor_gps,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter.errors import (
|
||||
FcAdapterConfigError,
|
||||
FcEmitError,
|
||||
SourceSetSwitchNotSupportedError,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter.msp2_inav_adapter import (
|
||||
Msp2InavAdapter,
|
||||
)
|
||||
from gps_denied_onboard.config import load_config
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers — MSP / secondary-MAVLink stand-ins
|
||||
|
||||
|
||||
class _MspStub:
|
||||
def __init__(self) -> None:
|
||||
self.raw_msgs: list[tuple[int, bytes]] = []
|
||||
self.closed = False
|
||||
|
||||
def send_RAW_msg(self, code: int, data: bytes) -> None:
|
||||
self.raw_msgs.append((int(code), bytes(data)))
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _SecondaryMavStub:
|
||||
def __init__(self) -> None:
|
||||
self.statustext_calls: list[tuple[int, bytes]] = []
|
||||
self.closed = False
|
||||
# Mirror pymavlink connection.mav shape.
|
||||
self.mav = self
|
||||
# Track signing-key state per RESTRICT-COMM-2 (Invariant 9):
|
||||
# the unit-test adapter MUST never call setup_signing on us.
|
||||
self.setup_signing_calls: list[Any] = []
|
||||
|
||||
def statustext_send(self, severity: int, text: bytes) -> None:
|
||||
self.statustext_calls.append((int(severity), bytes(text)))
|
||||
|
||||
def setup_signing(self, key: Any) -> None:
|
||||
self.setup_signing_calls.append(key)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def msp() -> _MspStub:
|
||||
return _MspStub()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def secondary() -> _SecondaryMavStub:
|
||||
return _SecondaryMavStub()
|
||||
|
||||
|
||||
def _inav_config(tmp_path) -> Any:
|
||||
env = {
|
||||
"FC_ADAPTER": "inav",
|
||||
"FC_PORT_DEVICE": "/dev/null",
|
||||
"FC_PORT_BAUD": "115200",
|
||||
"FC_SIGNING_KEY_SOURCE": "none",
|
||||
}
|
||||
return load_config(env=env, paths=(), require_env=False)
|
||||
|
||||
|
||||
def _make_output(
|
||||
*,
|
||||
source_label: str = "visual_propagated",
|
||||
smoothed: bool = False,
|
||||
cov: np.ndarray | None = None,
|
||||
wgs: LatLonAlt | None = None,
|
||||
frame_id: int = 1,
|
||||
) -> EstimatorOutput:
|
||||
if cov is None:
|
||||
cov = np.eye(6, dtype=np.float64) * 0.25
|
||||
if wgs is None:
|
||||
wgs = LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0)
|
||||
return EstimatorOutput(
|
||||
frame_id=frame_id,
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
pose_se3=np.eye(4),
|
||||
covariance_6x6=cov,
|
||||
source_label=source_label,
|
||||
smoothed=smoothed,
|
||||
extras={"wgs84": wgs},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(msp: _MspStub, secondary: _SecondaryMavStub, tmp_path) -> Msp2InavAdapter:
|
||||
cfg = _inav_config(tmp_path)
|
||||
fdr = mock.MagicMock()
|
||||
a = Msp2InavAdapter(
|
||||
config=cfg,
|
||||
wgs_converter=mock.MagicMock(),
|
||||
covariance_projector=CovarianceProjector(fdr_client=fdr),
|
||||
fdr_client=fdr,
|
||||
msp_connect_factory=lambda device, baud: msp,
|
||||
secondary_mavlink_factory=lambda: secondary,
|
||||
)
|
||||
port = PortConfig(fc_kind=FcKind.INAV, device="/dev/null", baud=115200)
|
||||
a.open(port, signing_key=None)
|
||||
yield a
|
||||
a.close()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-1: field fidelity
|
||||
|
||||
|
||||
def test_ac1_msp2_field_fidelity(adapter: Msp2InavAdapter, msp: _MspStub) -> None:
|
||||
# Arrange
|
||||
cov = np.diag([0.25, 0.25, 9.0, 1.0, 1.0, 1.0]).astype(np.float64)
|
||||
wgs = LatLonAlt(lat_deg=50.4501, lon_deg=30.5234, alt_m=180.0)
|
||||
output = _make_output(cov=cov, wgs=wgs)
|
||||
expected_mm = CovarianceProjector(fdr_client=mock.MagicMock()).to_inav_h_pos_accuracy_mm(output)
|
||||
|
||||
# Act
|
||||
result = adapter.emit_external_position(output)
|
||||
|
||||
# Assert
|
||||
assert len(msp.raw_msgs) == 1
|
||||
code, payload = msp.raw_msgs[0]
|
||||
assert code == MSP2_SENSOR_GPS_CODE
|
||||
decoded = decode_msp2_sensor_gps(payload)
|
||||
assert decoded.fix_type == 3
|
||||
assert decoded.latitude_e7 == int(wgs.lat_deg * 1e7)
|
||||
assert decoded.longitude_e7 == int(wgs.lon_deg * 1e7)
|
||||
assert decoded.msl_altitude_cm == int(wgs.alt_m * 100.0)
|
||||
assert decoded.h_pos_accuracy_mm == expected_mm
|
||||
assert result.fc_kind is FcKind.INAV
|
||||
assert result.horiz_accuracy_m == pytest.approx(expected_mm / 1000.0)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-2: every frame, monotonic seq
|
||||
|
||||
|
||||
def test_ac2_msp2_every_frame_with_seq(adapter: Msp2InavAdapter, msp: _MspStub) -> None:
|
||||
seqs = []
|
||||
for i in range(100):
|
||||
result = adapter.emit_external_position(_make_output(frame_id=i))
|
||||
seqs.append(result.sequence_number)
|
||||
assert len(msp.raw_msgs) == 100
|
||||
# AC-2: monotonically incrementing (modulo uint8 wrap)
|
||||
assert seqs[0] == 1
|
||||
assert seqs[-1] == 100 & 0xFF
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-3: STATUSTEXT on secondary channel only, on transitions
|
||||
|
||||
|
||||
def test_ac3_statustext_secondary_only_on_transitions(
|
||||
adapter: Msp2InavAdapter, msp: _MspStub, secondary: _SecondaryMavStub
|
||||
) -> None:
|
||||
adapter._provenance._min_interval_s = 0.0 # type: ignore[attr-defined]
|
||||
labels = ["visual_propagated", "sat_anchored"]
|
||||
for i in range(100):
|
||||
label = labels[(i // 10) % 2]
|
||||
adapter.emit_external_position(_make_output(source_label=label, frame_id=i))
|
||||
# 10 transitions including the initial None->visual_propagated
|
||||
assert len(secondary.statustext_calls) == 10
|
||||
# AC-3: NEVER on primary MSP2 channel
|
||||
for code, _ in msp.raw_msgs:
|
||||
assert code == MSP2_SENSOR_GPS_CODE
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-4: signing-key rejection
|
||||
|
||||
|
||||
def test_ac4_signing_key_rejected(msp: _MspStub, secondary: _SecondaryMavStub, tmp_path) -> None:
|
||||
cfg = _inav_config(tmp_path)
|
||||
fdr = mock.MagicMock()
|
||||
a = Msp2InavAdapter(
|
||||
config=cfg,
|
||||
wgs_converter=mock.MagicMock(),
|
||||
covariance_projector=CovarianceProjector(fdr_client=fdr),
|
||||
fdr_client=fdr,
|
||||
msp_connect_factory=lambda device, baud: msp,
|
||||
secondary_mavlink_factory=lambda: secondary,
|
||||
)
|
||||
port = PortConfig(fc_kind=FcKind.INAV, device="/dev/null", baud=115200)
|
||||
with pytest.raises(FcAdapterConfigError, match="iNav does not support MAVLink signing"):
|
||||
a.open(port, signing_key=b"\x00" * 32)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-5: signing-asymmetry — adapter never calls setup_signing on secondary
|
||||
|
||||
|
||||
def test_ac5_signing_asymmetry_no_signed_flag(
|
||||
adapter: Msp2InavAdapter, secondary: _SecondaryMavStub
|
||||
) -> None:
|
||||
for i in range(50):
|
||||
adapter.emit_external_position(_make_output(frame_id=i))
|
||||
assert secondary.setup_signing_calls == []
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-6: source-set-switch unsupported
|
||||
|
||||
|
||||
def test_ac6_source_set_switch_unsupported(adapter: Msp2InavAdapter) -> None:
|
||||
with pytest.raises(SourceSetSwitchNotSupportedError, match="iNav"):
|
||||
adapter.request_source_set_switch()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-7: smoothed rejected
|
||||
|
||||
|
||||
def test_ac7_smoothed_rejected(adapter: Msp2InavAdapter, msp: _MspStub) -> None:
|
||||
with pytest.raises(FcEmitError, match="smoothed"):
|
||||
adapter.emit_external_position(_make_output(smoothed=True))
|
||||
assert msp.raw_msgs == []
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-8: non-SPD cov rejected
|
||||
|
||||
|
||||
def test_ac8_non_spd_covariance_rejected(adapter: Msp2InavAdapter, msp: _MspStub) -> None:
|
||||
bad = np.eye(6, dtype=np.float64)
|
||||
bad[0, 0] = -1.0
|
||||
with pytest.raises(FcEmitError):
|
||||
adapter.emit_external_position(_make_output(cov=bad))
|
||||
assert msp.raw_msgs == []
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-9: single-writer thread
|
||||
|
||||
|
||||
def test_ac9_single_writer_thread(adapter: Msp2InavAdapter) -> None:
|
||||
adapter.emit_external_position(_make_output())
|
||||
err: list[BaseException] = []
|
||||
|
||||
def run() -> None:
|
||||
try:
|
||||
adapter.emit_external_position(_make_output(frame_id=2))
|
||||
except RuntimeError as e:
|
||||
err.append(e)
|
||||
|
||||
t = threading.Thread(target=run)
|
||||
t.start()
|
||||
t.join(timeout=2.0)
|
||||
assert len(err) == 1
|
||||
assert "single-writer" in str(err[0]).lower()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-10: first emit logged once
|
||||
|
||||
|
||||
def test_ac10_first_emit_logged_once(
|
||||
adapter: Msp2InavAdapter, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with caplog.at_level(logging.INFO):
|
||||
for i in range(5):
|
||||
adapter.emit_external_position(_make_output(frame_id=i))
|
||||
first = [rec for rec in caplog.records if getattr(rec, "kind", None) == "c8.inav.first_emit"]
|
||||
assert len(first) == 1
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Invariant 2 cross-check: iNav config rejects ephemeral signing
|
||||
|
||||
|
||||
def test_inav_config_rejects_signing(tmp_path) -> None:
|
||||
env = {
|
||||
"FC_ADAPTER": "inav",
|
||||
"FC_SIGNING_KEY_SOURCE": "ephemeral_per_flight",
|
||||
}
|
||||
with pytest.raises(Exception, match="adapter='inav'"):
|
||||
load_config(env=env, paths=(), require_env=False)
|
||||
@@ -0,0 +1,304 @@
|
||||
"""AZ-395 — AP MAVLink 2.0 per-flight signing AC tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.fc import FcKind, PortConfig, Severity
|
||||
from gps_denied_onboard._types.geo import LatLonAlt
|
||||
from gps_denied_onboard._types.pose import EstimatorOutput
|
||||
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
|
||||
CovarianceProjector,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter.errors import (
|
||||
FcOpenError,
|
||||
SigningHandshakeError,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter.pymavlink_ardupilot_adapter import (
|
||||
PymavlinkArdupilotAdapter,
|
||||
)
|
||||
from gps_denied_onboard.config import load_config
|
||||
|
||||
_DEV_STATIC_KEY = "00112233445566778899aabbccddeeff" * 2 # 64 hex chars = 32 bytes
|
||||
|
||||
|
||||
class _MavStub:
|
||||
def __init__(self, signing_failure_count: int = 0) -> None:
|
||||
self.gps_input_calls: list[tuple[Any, ...]] = []
|
||||
self.named_value_float_calls: list[tuple[Any, ...]] = []
|
||||
self.statustext_calls: list[tuple[int, bytes]] = []
|
||||
# pymavlink exposes `connection.mav.signing.sig_count` after
|
||||
# setup_signing(...); we simulate that surface here.
|
||||
self.signing = SimpleNamespace(sig_count=signing_failure_count)
|
||||
|
||||
def gps_input_send(self, *args: Any) -> None:
|
||||
self.gps_input_calls.append(args)
|
||||
|
||||
def named_value_float_send(self, time_boot_ms: int, name: bytes, value: float) -> None:
|
||||
self.named_value_float_calls.append((time_boot_ms, name, value))
|
||||
|
||||
def statustext_send(self, severity: int, text: bytes) -> None:
|
||||
self.statustext_calls.append((severity, text))
|
||||
|
||||
|
||||
class _ConnStub:
|
||||
def __init__(self, *, fail_signing: bool = False, signing_failure_count: int = 0) -> None:
|
||||
self.mav = _MavStub(signing_failure_count=signing_failure_count)
|
||||
self.setup_signing_calls: list[bytes] = []
|
||||
self._fail_signing = fail_signing
|
||||
self.closed = False
|
||||
|
||||
def setup_signing(self, key: bytes) -> None:
|
||||
if self._fail_signing:
|
||||
raise RuntimeError("simulated signing handshake refusal")
|
||||
self.setup_signing_calls.append(bytes(key))
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _ap_config(*, signing_key_source: str, dev_static_key: str = "") -> Any:
|
||||
env: dict[str, str] = {
|
||||
"FC_ADAPTER": "ardupilot_plane",
|
||||
"FC_PORT_DEVICE": "/dev/null",
|
||||
"FC_PORT_BAUD": "921600",
|
||||
"FC_SIGNING_KEY_SOURCE": signing_key_source,
|
||||
}
|
||||
if dev_static_key:
|
||||
env["FC_DEV_STATIC_SIGNING_KEY"] = dev_static_key
|
||||
return load_config(env=env, paths=(), require_env=False)
|
||||
|
||||
|
||||
def _build_adapter(
|
||||
conn: _ConnStub, *, signing_key_source: str, dev_static_key: str = ""
|
||||
) -> PymavlinkArdupilotAdapter:
|
||||
cfg = _ap_config(signing_key_source=signing_key_source, dev_static_key=dev_static_key)
|
||||
fdr = mock.MagicMock()
|
||||
return PymavlinkArdupilotAdapter(
|
||||
config=cfg,
|
||||
wgs_converter=mock.MagicMock(),
|
||||
covariance_projector=CovarianceProjector(fdr_client=fdr),
|
||||
fdr_client=fdr,
|
||||
flight_id="flt-az395",
|
||||
connect_factory=lambda device, baud: conn,
|
||||
)
|
||||
|
||||
|
||||
def _port() -> PortConfig:
|
||||
return PortConfig(fc_kind=FcKind.ARDUPILOT_PLANE, device="/dev/null", baud=921600)
|
||||
|
||||
|
||||
def _make_output(frame_id: int = 1) -> EstimatorOutput:
|
||||
return EstimatorOutput(
|
||||
frame_id=frame_id,
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
pose_se3=np.eye(4),
|
||||
covariance_6x6=np.eye(6, dtype=np.float64) * 0.25,
|
||||
source_label="visual_propagated",
|
||||
smoothed=False,
|
||||
extras={"wgs84": LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0)},
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-1 / AC-10: signing_key=None with ephemeral source generates the key
|
||||
|
||||
|
||||
def test_ac1_ephemeral_generates_key_when_none_passed() -> None:
|
||||
conn = _ConnStub()
|
||||
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
|
||||
adapter.open(_port(), signing_key=None)
|
||||
try:
|
||||
assert len(conn.setup_signing_calls) == 1
|
||||
assert len(conn.setup_signing_calls[0]) == 32
|
||||
finally:
|
||||
adapter.close()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-2: two opens produce distinct ephemeral keys
|
||||
|
||||
|
||||
def test_ac2_ephemeral_distinct_per_flight() -> None:
|
||||
conn1 = _ConnStub()
|
||||
a1 = _build_adapter(conn1, signing_key_source="ephemeral_per_flight")
|
||||
a1.open(_port(), signing_key=None)
|
||||
a1.close()
|
||||
conn2 = _ConnStub()
|
||||
a2 = _build_adapter(conn2, signing_key_source="ephemeral_per_flight")
|
||||
a2.open(_port(), signing_key=None)
|
||||
a2.close()
|
||||
assert conn1.setup_signing_calls[0] != conn2.setup_signing_calls[0]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-3: handshake failure raises SigningHandshakeError
|
||||
|
||||
|
||||
def test_ac3_handshake_failure_raises(caplog: pytest.LogCaptureFixture) -> None:
|
||||
conn = _ConnStub(fail_signing=True)
|
||||
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
|
||||
with caplog.at_level(logging.ERROR), pytest.raises(SigningHandshakeError):
|
||||
adapter.open(_port(), signing_key=None)
|
||||
assert any(
|
||||
getattr(rec, "kind", None) == "c8.ap.signing_handshake_failed" for rec in caplog.records
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-4: handshake success FDR record has NO key bytes
|
||||
|
||||
|
||||
def test_ac4_handshake_success_fdr_has_no_key_bytes() -> None:
|
||||
conn = _ConnStub()
|
||||
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
|
||||
adapter.open(_port(), signing_key=None)
|
||||
try:
|
||||
key = bytes(conn.setup_signing_calls[0])
|
||||
rotated_calls = [
|
||||
call
|
||||
for call in adapter._fdr_client.enqueue.mock_calls # type: ignore[attr-defined]
|
||||
if call.args[0].payload.get("kind") == "c8.ap.signing_key_rotated"
|
||||
]
|
||||
assert len(rotated_calls) == 1
|
||||
rec_payload = rotated_calls[0].args[0].payload
|
||||
# AC-4: assert NO key byte sub-sequence appears in the FDR payload.
|
||||
rendered = str(rec_payload)
|
||||
assert key.hex() not in rendered
|
||||
for i in range(0, len(key) - 4):
|
||||
chunk = key[i : i + 4].hex()
|
||||
assert chunk not in rendered, f"4-byte chunk leak at offset {i}"
|
||||
finally:
|
||||
adapter.close()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-5: key never in any log line
|
||||
|
||||
|
||||
def test_ac5_key_never_in_logs(caplog: pytest.LogCaptureFixture) -> None:
|
||||
conn = _ConnStub()
|
||||
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
adapter.open(_port(), signing_key=None)
|
||||
for i in range(5):
|
||||
adapter.emit_external_position(_make_output(frame_id=i))
|
||||
adapter.close()
|
||||
key = bytes(conn.setup_signing_calls[0])
|
||||
rendered_logs = "\n".join(rec.getMessage() for rec in caplog.records)
|
||||
assert key.hex() not in rendered_logs
|
||||
pattern = re.compile(re.escape(key[:4].hex()))
|
||||
assert pattern.search(rendered_logs) is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-6: mid-flight signing failure does NOT raise
|
||||
|
||||
|
||||
def test_ac6_mid_flight_signing_failure_no_raise(caplog: pytest.LogCaptureFixture) -> None:
|
||||
conn = _ConnStub(signing_failure_count=5)
|
||||
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
|
||||
adapter.open(_port(), signing_key=None)
|
||||
try:
|
||||
with caplog.at_level(logging.ERROR):
|
||||
adapter.emit_external_position(_make_output())
|
||||
adapter.emit_external_position(_make_output(frame_id=2))
|
||||
assert any(getattr(rec, "kind", None) == "c8.ap.signing_failure" for rec in caplog.records)
|
||||
assert any(sev == int(Severity.WARNING.value) for sev, _ in conn.mav.statustext_calls)
|
||||
finally:
|
||||
adapter.close()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-7: key zeroisation on close
|
||||
|
||||
|
||||
def test_ac7_key_zeroisation_on_close(caplog: pytest.LogCaptureFixture) -> None:
|
||||
conn = _ConnStub()
|
||||
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
|
||||
adapter.open(_port(), signing_key=None)
|
||||
# Grab a direct reference to the bytearray buffer so we can inspect
|
||||
# it after close() — bytearray is mutable so the zeroisation in
|
||||
# _zeroise_signing_key wipes this same buffer in place.
|
||||
key_buf = adapter._signing_key
|
||||
assert key_buf is not None
|
||||
assert any(b != 0 for b in key_buf) # non-zero before close
|
||||
with caplog.at_level(logging.INFO):
|
||||
adapter.close()
|
||||
assert all(b == 0 for b in key_buf)
|
||||
assert any(getattr(rec, "kind", None) == "c8.ap.signing_key_zeroised" for rec in caplog.records)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-8: BUILD_DEV_STATIC_KEY=ON enables static repeatable key path
|
||||
|
||||
|
||||
def test_ac8_dev_static_key_repeatable(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("BUILD_DEV_STATIC_KEY", "ON")
|
||||
conn1 = _ConnStub()
|
||||
a1 = _build_adapter(conn1, signing_key_source="dev_static", dev_static_key=_DEV_STATIC_KEY)
|
||||
a1.open(_port(), signing_key=None)
|
||||
a1.close()
|
||||
conn2 = _ConnStub()
|
||||
a2 = _build_adapter(conn2, signing_key_source="dev_static", dev_static_key=_DEV_STATIC_KEY)
|
||||
a2.open(_port(), signing_key=None)
|
||||
a2.close()
|
||||
assert conn1.setup_signing_calls[0] == conn2.setup_signing_calls[0]
|
||||
assert conn1.setup_signing_calls[0].hex() == _DEV_STATIC_KEY
|
||||
|
||||
|
||||
def test_ac8_dev_static_key_blocked_without_build_flag(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("BUILD_DEV_STATIC_KEY", raising=False)
|
||||
conn = _ConnStub()
|
||||
adapter = _build_adapter(conn, signing_key_source="dev_static", dev_static_key=_DEV_STATIC_KEY)
|
||||
with pytest.raises(FcOpenError, match="BUILD_DEV_STATIC_KEY"):
|
||||
adapter.open(_port(), signing_key=None)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-9 (indirect): mid-flight signing failure STATUSTEXT severity = WARNING.
|
||||
# AC-3 handshake-failure raises and logs ERROR; the STATUSTEXT severity is
|
||||
# not exercised because handshake-failure aborts open() before any emit.
|
||||
|
||||
|
||||
def test_ac9_statustext_severity_on_mid_flight_failure() -> None:
|
||||
conn = _ConnStub(signing_failure_count=5)
|
||||
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
|
||||
adapter.open(_port(), signing_key=None)
|
||||
try:
|
||||
adapter.emit_external_position(_make_output())
|
||||
# First STATUSTEXT is the signing-failure WARNING; subsequent
|
||||
# provenance STATUSTEXT may follow at INFO. Assert at least one
|
||||
# WARNING was emitted.
|
||||
assert any(sev == int(Severity.WARNING.value) for sev, _ in conn.mav.statustext_calls)
|
||||
finally:
|
||||
adapter.close()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-10: AZ-393 placeholder tightened — `signing_key_source="none"` is the
|
||||
# only path that bypasses signing; `"ephemeral_per_flight"` rejects no key
|
||||
# via an internally generated one (and AC-1 verified that). The matching
|
||||
# tightening surface is that an unknown source is rejected.
|
||||
|
||||
|
||||
def test_ac10_unknown_signing_source_rejected() -> None:
|
||||
# FcConfig enforces the allowed-source list at config-load time, so
|
||||
# an explicit "explicit" source is rejected before the adapter sees
|
||||
# it. We exercise the FcOpenError path by patching the validated
|
||||
# config in place (test seam).
|
||||
conn = _ConnStub()
|
||||
adapter = _build_adapter(conn, signing_key_source="none")
|
||||
# Mutate the validated config in place to simulate an unknown source
|
||||
# slipping past validation (defence-in-depth) — adapter must refuse.
|
||||
object.__setattr__(adapter._config.fc, "signing_key_source", "bogus") # type: ignore[arg-type]
|
||||
with pytest.raises(FcOpenError, match="unknown signing_key_source"):
|
||||
adapter.open(_port(), signing_key=None)
|
||||
Reference in New Issue
Block a user