Files
autopilot/crates/mavlink_layer/tests/signing.rs
T
Oleksandr Bezdieniezhnykh 69c0629350
ci/woodpecker/push/build-arm Pipeline failed
[AZ-643] [AZ-665] [AZ-672] mavlink+mapobjects+vlm batch 4
AZ-643 mavlink_layer:
- ack demux on COMMAND_LONG/COMMAND_ACK with oneshot dispatch and
  configurable deadline; MavlinkHandle::send_command + SendCommandError
- MAVLink-2 signing: Signer/Verifier built on SHA-256, key + timestamp
  source, incompat-flag wiring in encoder, reject + counter in decoder
- new tests: tests/ack_demux.rs (3) + tests/signing.rs (5)

AZ-665 mapobjects_store:
- internal/h3_index.rs (h3o wrapper, cell_of, grid_disk, haversine)
- internal/store.rs (in-memory (cell -> Vec<MapObject>) hashmap with
  k-ring classify and class-group resolution)
- public API: MapObjectsStoreHandle::classify(ClassifyInput) ->
  Classification {New|Moved|Existing}
- AC1-4 in tests/classify.rs; AC5 perf gate (#[ignore], passes in
  --release)

AZ-672 vlm_client + autopilot:
- DisabledVlmProvider in shared::contracts; VlmProvider::name() for
  composition-root diagnostics
- vlm_client::VlmClient gated behind feature = "vlm"; placeholder
  until AZ-673 lands the real NanoLLM IPC
- autopilot: vlm_client is now optional = true under feature vlm;
  Runtime::select_vlm_provider picks DisabledVlmProvider when feature
  off OR config.vlm.enabled = false

Workspace deps: +sha2 (mavlink signing), +h3o (mapobjects index).
Batch report: _docs/03_implementation/batch_04_cycle1_report.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 13:31:42 +03:00

237 lines
7.5 KiB
Rust

//! AZ-643 — MAVLink-2 signing integration tests (AC-3 rejection, AC-4 disabled).
use std::time::Duration;
use tokio::net::UdpSocket;
use tokio::sync::watch;
use tokio::time::timeout;
use mavlink_layer::{
Decoder, DecoderEvent, Encoder, Heartbeat, MavlinkConnection, MavlinkLayer,
MavlinkLayerOptions, MavlinkMessage, Signer, SigningKey, SigningReject, Verifier,
};
fn options_for(uri: String) -> MavlinkLayerOptions {
let mut o = MavlinkLayerOptions::new(MavlinkConnection::new(uri));
o.link_timeout = Duration::from_millis(500);
o.reconnect_base = Duration::from_millis(50);
o.reconnect_cap = Duration::from_millis(200);
o
}
fn fixed_key(b: u8) -> SigningKey {
let mut k = [0u8; 32];
for (i, byte) in k.iter_mut().enumerate() {
*byte = b.wrapping_add(i as u8);
}
SigningKey::new(k)
}
#[test]
fn ac3_decoder_rejects_bad_signature() {
// Arrange: build a signed frame, then flip one bit in the signature trailer.
let signer = Signer::new(fixed_key(0x10), 5);
let encoder = Encoder::with_signer(1, 191, signer);
let _ = encoder; // signing is exercised through encode()
// Use a separate signer-on-encoder to produce a signed frame for the test.
let local_signer = Encoder::with_signer(1, 191, Signer::new(fixed_key(0x10), 5));
let mut frame = local_signer.encode(&MavlinkMessage::Heartbeat(Heartbeat {
custom_mode: 0,
mavtype: 2,
autopilot: 3,
base_mode: 0,
system_status: 4,
mavlink_version: 3,
}));
let last = frame.len() - 1;
frame[last] ^= 0x01;
// Act: feed it to a decoder with the matching verifier.
let mut dec = Decoder::with_verifier(Verifier::new(fixed_key(0x10)));
let events = dec.feed(&frame);
// Assert
let rejected = events.iter().find(|e| {
matches!(
e,
DecoderEvent::SigningMismatch {
reason: SigningReject::BadSignature,
..
}
)
});
assert!(
rejected.is_some(),
"expected SigningMismatch event, got {events:?}"
);
assert_eq!(dec.errors.snapshot().signing_mismatch, 1);
// The HEARTBEAT must NOT have been emitted as a Message.
let emitted = events
.iter()
.any(|e| matches!(e, DecoderEvent::Message { .. }));
assert!(!emitted, "rejected frame must not surface as Message");
}
#[test]
fn ac3_signed_frame_with_matching_key_passes() {
// Arrange
let encoder = Encoder::with_signer(1, 191, Signer::new(fixed_key(0xAB), 9));
let frame = encoder.encode(&MavlinkMessage::Heartbeat(Heartbeat {
custom_mode: 0,
mavtype: 2,
autopilot: 3,
base_mode: 0,
system_status: 4,
mavlink_version: 3,
}));
// Act
let mut dec = Decoder::with_verifier(Verifier::new(fixed_key(0xAB)));
let events = dec.feed(&frame);
// Assert
let mut got_message = false;
let mut got_mismatch = false;
for ev in &events {
match ev {
DecoderEvent::Message {
message: MavlinkMessage::Heartbeat(_),
..
} => got_message = true,
DecoderEvent::SigningMismatch { .. } => got_mismatch = true,
_ => {}
}
}
assert!(
got_message,
"valid signed heartbeat must surface as Message"
);
assert!(!got_mismatch, "valid signature must not trigger mismatch");
assert_eq!(dec.errors.snapshot().signing_mismatch, 0);
}
#[test]
fn ac4_signing_disabled_ignores_signature_field() {
// Arrange: build BOTH a signed frame and an unsigned frame.
let signed_enc = Encoder::with_signer(1, 191, Signer::new(fixed_key(0x33), 1));
let unsigned_enc = Encoder::new(1, 191);
let hb = MavlinkMessage::Heartbeat(Heartbeat {
custom_mode: 0,
mavtype: 2,
autopilot: 3,
base_mode: 0,
system_status: 4,
mavlink_version: 3,
});
let signed_frame = signed_enc.encode(&hb);
let unsigned_frame = unsigned_enc.encode(&hb);
// Act: feed both into a Decoder with NO verifier (signing disabled).
let mut dec = Decoder::new();
let signed_events = dec.feed(&signed_frame);
let unsigned_events = dec.feed(&unsigned_frame);
// Assert: both surface as Message, signing_mismatch counter stays at 0.
let signed_ok = signed_events.iter().any(|e| {
matches!(
e,
DecoderEvent::Message {
message: MavlinkMessage::Heartbeat(_),
..
}
)
});
let unsigned_ok = unsigned_events.iter().any(|e| {
matches!(
e,
DecoderEvent::Message {
message: MavlinkMessage::Heartbeat(_),
..
}
)
});
assert!(signed_ok, "with verifier=None, signed frames must decode");
assert!(
unsigned_ok,
"with verifier=None, unsigned frames must decode"
);
assert_eq!(
dec.errors.snapshot().signing_mismatch,
0,
"signing_mismatch counter must stay at 0 in disabled mode"
);
}
#[test]
fn unsigned_frame_rejected_when_verifier_present() {
// Defensive coverage: per the MAVLink spec, with signing enabled the
// decoder rejects unsigned frames. AC-3 only specifies the bad-signature
// case, but the spec-consistent behaviour is to reject both.
let unsigned_enc = Encoder::new(1, 191);
let frame = unsigned_enc.encode(&MavlinkMessage::Heartbeat(Heartbeat {
custom_mode: 0,
mavtype: 2,
autopilot: 3,
base_mode: 0,
system_status: 4,
mavlink_version: 3,
}));
let mut dec = Decoder::with_verifier(Verifier::new(fixed_key(0x44)));
let events = dec.feed(&frame);
assert!(events.iter().any(|e| matches!(
e,
DecoderEvent::SigningMismatch {
reason: SigningReject::Unsigned,
..
}
)));
assert_eq!(dec.errors.snapshot().signing_mismatch, 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn signing_enabled_layer_reports_via_health() {
// Arrange: a layer with signing on, plus a peer that captures the frames.
let peer = UdpSocket::bind("127.0.0.1:0").await.expect("bind peer");
let peer_addr = peer.local_addr().expect("peer addr").to_string();
let (_shutdown_tx, shutdown_rx) = watch::channel(false);
let mut opts = options_for(format!("udp://{peer_addr}"));
opts.signing = Some(mavlink_layer::SigningOptions {
key: fixed_key(0x55),
link_id: 3,
});
let (layer, handle) = MavlinkLayer::new(opts);
tokio::spawn(layer.run(shutdown_rx));
// Act: wait for one heartbeat so we have at least one signed frame.
let mut buf = vec![0u8; 1024];
let n = timeout(Duration::from_secs(2), peer.recv(&mut buf))
.await
.expect("heartbeat must arrive within 2 s")
.expect("udp recv");
// Assert: incompat_flags bit 0 (signed) is set on the outbound frame.
assert!(n >= 10, "frame too short");
assert!(
handle.signing_enabled(),
"signing_enabled() must reflect config"
);
assert_eq!(
buf[2] & 0x01,
0x01,
"outbound frame must have INCOMPAT_FLAG_SIGNED set when signing is enabled"
);
let detail = handle.health().detail.unwrap_or_default();
assert!(
detail.contains("signing_enabled=true"),
"health detail must surface signing_enabled=true; got {detail:?}"
);
assert!(
detail.contains("commands_in_flight=0"),
"health detail must surface commands_in_flight; got {detail:?}"
);
}