//! 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:?}" ); }