Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions artifacts/verification/FV-FALCON-DC-CONFORMANCE.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
artifacts:
- id: FV-FALCON-DC-CONFORMANCE
type: sw-verification
title: "DroneCAN wire conformance — pydronecan reference-vector validation (fixes the DSDL bit-order bug)"
status: implemented
description: >
Verifies SWREQ-FALCON-DC-P02 + SWREQ-FALCON-DC-P03 along the CONFORMANCE
axis (does the decode match the real DSDL?), which the prior FV-FALCON-DC-002
/003 (robustness + self-consistency) could not: every self-constructed
round-trip test passed because encode + decode shared the same WRONG bit
order.

THE BUG (found 2026-06 by running external reference vectors): the
hand-rolled bit-field decoders used LSB-first packing, but DroneCAN v0 DSDL
uses `be_from_le_bits` (8-bit-chunk reversal — byte-aligned fields are
little-endian, sub-byte/multi-byte bit fields are MSB-first-per-stream-byte).
Affected, against the pydronecan reference (the impl PX4/ArduPilot libcanard
conforms to):
- NodeStatus health/mode/sub_mode: read 1/6/4 instead of 2/3/1.
- esc.RawCommand int14 (FLIGHT-CRITICAL): [8191,0,-8192,4096] read as
[-1241,899,0,16] — falcon would have sent garbage throttles to a real ESC.
- esc.Status rpm/power_rating/esc_index: wrong bit fields.
- MagneticFieldStrength: a phantom ahrs_id (that field is in
MagneticFieldStrength2/1003), shifting the whole field by a byte.

THE FIX: a faithful DSDL bit codec (crate::dsdl, be_from_le_bits /
le_from_be_bits) replacing the LSB-first readers; the byte-aligned float16/
u32 paths (already correct) are unchanged. Validated against canonical
pydronecan payloads embedded as CONFORMANCE tests; the vectors are
regenerable via scripts/gen-dronecan-vectors.py.

Evidence (crates/relay-dronecan), each a real named test/harness:
- msg::tests::node_status_decodes_fields (canonical 0403020199efbe).
- msg::tests::raw_command_matches_pydronecan_reference (canonical
ff7c0000080010 decodes [8191,0,-8192,4096]; encode reproduces the bytes).
- sensors::tests::esc_status_decodes_fields (070000000049003c885c8813190c).
- sensors::tests::mag_decodes_three_axes (003c00c00038, no ahrs_id).
- sensors::tests::baro_decodes_pressure_f32 / battery_prefix_decodes_voltage_current.
- dsdl::tests::raw_command_int14_against_reference / write_reproduces_reference_bytes.
- Kani DC-K08 verify_dsdl_read_bounded (the codec is total + result < 2^w).
ORACLE: `cargo test -p relay-dronecan` green (42 tests); the pydronecan
vectors decode to their canonical field values.
tags: [verification, falcon, dronecan, conformance, pydronecan, reference-vectors, relay-dronecan, kani, fix]
fields:
method: formal-verification
steps:
- run: "cargo test -p relay-dronecan dsdl::tests::raw_command_int14_against_reference"
- run: "cargo test -p relay-dronecan dsdl::tests::write_reproduces_reference_bytes"
- run: "cargo test -p relay-dronecan msg::tests::node_status_decodes_fields"
- run: "cargo test -p relay-dronecan msg::tests::raw_command_matches_pydronecan_reference"
- run: "cargo test -p relay-dronecan sensors::tests::esc_status_decodes_fields"
- run: "cargo test -p relay-dronecan sensors::tests::mag_decodes_three_axes"
- run: "cargo test -p relay-dronecan sensors::tests::baro_decodes_pressure_f32"
- run: "cargo test -p relay-dronecan sensors::tests::battery_prefix_decodes_voltage_current"
- run: "cargo kani -p relay-dronecan --harness verify_dsdl_read_bounded"
links:
- type: verifies
target: SWREQ-FALCON-DC-P02
- type: verifies
target: SWREQ-FALCON-DC-P03
149 changes: 149 additions & 0 deletions crates/relay-dronecan/plain/src/dsdl.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
//! Faithful port of the DroneCAN v0 (UAVCAN v0) DSDL bit serialization.
//!
//! THE conformance core. The wire convention (pydronecan's `be_from_le_bits` /
//! `le_from_be_bits`, the same the PX4/ArduPilot libcanard implementations use):
//! the bit stream is filled MSB-first within each byte, and a primitive of width
//! `w` is stored byte-**little-endian** with each byte emitted MSB-first.
//!
//! Consequence — and the bug this module fixes: a BYTE-ALIGNED field reduces to
//! `from_le_bytes` (which is why the float16/u32 decodes were already correct),
//! but a SUB-BYTE or BIT-MISALIGNED field (NodeStatus health/mode, esc.RawCommand
//! int14, esc.Status rpm) is NOT simple LSB-first packing — the earlier
//! hand-rolled `pos/8, 1<<(pos%8)` readers were wrong against the real DSDL, and
//! every self-constructed round-trip test missed it because encode and decode
//! shared the same wrong convention. Validated here against pydronecan reference
//! vectors (`tests::pydronecan_reference_vectors` in the message modules).
//!
//! All reads/writes are bounds-guarded — total, never panic.

/// One stream bit at global position `p` (MSB-first within each byte).
/// Out-of-range -> 0.
#[inline]
fn stream_bit(payload: &[u8], p: usize) -> u64 {
let byte = p / 8;
if byte >= payload.len() {
return 0;
}
((payload[byte] >> (7 - (p % 8))) & 1) as u64
}

/// Read a `w`-bit unsigned DSDL primitive at bit offset `o` (w <= 64).
/// `be_from_le_bits`: the value's bytes are little-endian, each byte MSB-first
/// in the stream — i.e. 8-bit chunks of the field are reversed. Total.
pub fn read_uint(payload: &[u8], o: usize, w: usize) -> u64 {
let mut val: u64 = 0;
let nchunks = w.div_ceil(8);
let mut chunk = nchunks;
// most-significant chunk (highest stream offset) first
while chunk > 0 {
chunk -= 1;
let start = chunk * 8;
let end = if start + 8 < w { start + 8 } else { w };
let mut c: u64 = 0;
let mut i = start;
while i < end {
c = (c << 1) | stream_bit(payload, o + i);
i += 1;
}
val = (val << (end - start)) | c;
}
val
}

/// Read a `w`-bit signed DSDL primitive (two's complement), sign-extended to i64.
pub fn read_int(payload: &[u8], o: usize, w: usize) -> i64 {
let v = read_uint(payload, o, w);
if w == 0 || w >= 64 {
return v as i64;
}
if v & (1 << (w - 1)) != 0 {
(v as i64) - (1i64 << w)
} else {
v as i64
}
}

/// Write a `w`-bit unsigned value at bit offset `o` (`le_from_be_bits`, the
/// inverse of [`read_uint`]). The value's little-endian bytes are emitted
/// MSB-first per stream byte. Out-of-range writes are dropped (total). Only the
/// low `w` bits of `value` are used.
pub fn write_uint(payload: &mut [u8], o: usize, w: usize, value: u64) {
let nchunks = w.div_ceil(8);
let mut chunk = 0;
while chunk < nchunks {
let start = chunk * 8;
let end = if start + 8 < w { start + 8 } else { w };
let cbits = end - start;
let byte = (value >> (chunk * 8)) & 0xFF;
let mut k = 0;
while k < cbits {
let bit = (byte >> (cbits - 1 - k)) & 1; // MSB-first within the chunk
let p = o + start + k;
let pb = p / 8;
if pb < payload.len() {
let mask = 1u8 << (7 - (p % 8));
if bit != 0 {
payload[pb] |= mask;
} else {
payload[pb] &= !mask;
}
}
k += 1;
}
chunk += 1;
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn byte_aligned_matches_le_bytes() {
// a byte-aligned u32 read == from_le_bytes (proves the float16/u32 path
// was always correct).
let p = [0x04, 0x03, 0x02, 0x01];
assert_eq!(read_uint(&p, 0, 32), 0x0102_0304);
}

#[test]
fn nodestatus_subbyte_fields() {
// byte 4 = 0x99 packs health=2 / mode=3 / sub_mode=1 (MSB-first), per the
// pydronecan canonical NodeStatus 0403020199efbe.
let p = [0x04, 0x03, 0x02, 0x01, 0x99, 0xef, 0xbe];
assert_eq!(read_uint(&p, 32, 2), 2); // health
assert_eq!(read_uint(&p, 34, 3), 3); // mode
assert_eq!(read_uint(&p, 37, 3), 1); // sub_mode
}

#[test]
fn raw_command_int14_against_reference() {
// pydronecan canonical RawCommand [8191,0,-8192,4096] = ff7c0000080010
let p = [0xff, 0x7c, 0x00, 0x00, 0x08, 0x00, 0x10];
assert_eq!(read_int(&p, 0, 14), 8191);
assert_eq!(read_int(&p, 14, 14), 0);
assert_eq!(read_int(&p, 28, 14), -8192);
assert_eq!(read_int(&p, 42, 14), 4096);
}

#[test]
fn write_then_read_round_trips() {
// the codec is self-inverse for any offset/width/value in range
for &(o, w, v) in &[(0usize, 14usize, 8191u64), (14, 14, 0x2000), (32, 2, 2), (34, 3, 5), (5, 18, 0x3ABCD)] {
let mut buf = [0u8; 16];
write_uint(&mut buf, o, w, v);
assert_eq!(read_uint(&buf, o, w), v & ((1u64 << w) - 1), "o={o} w={w}");
}
}

#[test]
fn write_reproduces_reference_bytes() {
// encoding [8191,0,-8192,4096] must reproduce the pydronecan bytes
let mut buf = [0u8; 7];
write_uint(&mut buf, 0, 14, 8191);
write_uint(&mut buf, 14, 14, 0);
write_uint(&mut buf, 28, 14, (-8192i64 as u64) & 0x3FFF);
write_uint(&mut buf, 42, 14, 4096);
assert_eq!(buf, [0xff, 0x7c, 0x00, 0x00, 0x08, 0x00, 0x10]);
}
}
28 changes: 13 additions & 15 deletions crates/relay-dronecan/plain/src/kani_proofs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
//! target (totality + bounds), out of Verus's productive range.
#![cfg(kani)]

use crate::dsdl;
use crate::id::{decode_message_id, encode_message_id, MessageId};
use crate::msg::{decode_node_status, encode_raw_command, MAX_ESC};
use crate::sensors::read_bits;
use crate::tail::{decode_tail, encode_tail};
use crate::transfer::{encode_single_frame, CanFrame, Reassembler, MAX_PAYLOAD};

Expand Down Expand Up @@ -139,20 +139,18 @@ fn verify_encode_single_frame_bounded() {
}
}

/// DC-K08 — the sensor bit-reader is total and range-bounded: for ANY payload,
/// bit offset, and width (<= 18, the widest esc.Status field), `read_bits` never
/// panics / never indexes out of bounds, and the result is `< 2^nbits`. The
/// integer floor under the esc.Status decode (the float16 telemetry values are
/// proptest-gated — Kani on f32 is intractable).
/// DC-K08 — the DSDL bit-codec is total and range-bounded: for ANY payload, bit
/// offset, and width (<= 18, the widest esc.Status bit-field), `dsdl::read_uint`
/// never panics / never indexes out of bounds, and the result is `< 2^w`. The
/// integer floor under every DSDL bit-field decode (the conformance core; the
/// float16 telemetry values are proptest-gated — Kani on f32 is intractable).
#[kani::proof]
fn verify_read_bits_bounded() {
fn verify_dsdl_read_bounded() {
let payload: [u8; 14] = kani::any();
let bit_off: usize = kani::any();
kani::assume(bit_off <= 112); // within the 14-byte (112-bit) esc.Status frame
let nbits: u32 = kani::any();
kani::assume(nbits <= 18);
let v = read_bits(&payload, bit_off, nbits);
if nbits < 32 {
assert!(v < (1u32 << nbits));
}
let o: usize = kani::any();
kani::assume(o <= 112); // within the 14-byte (112-bit) esc.Status frame
let w: usize = kani::any();
kani::assume(w >= 1 && w <= 18);
let v = dsdl::read_uint(&payload, o, w);
assert!(v < (1u64 << w));
}
1 change: 1 addition & 0 deletions crates/relay-dronecan/plain/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
#![forbid(unsafe_code)]

pub mod crc;
pub mod dsdl;
pub mod float16;
pub mod id;
pub mod msg;
Expand Down
71 changes: 36 additions & 35 deletions crates/relay-dronecan/plain/src/msg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
//! one mandatory message): node uptime/health/mode — the bus-presence +
//! node-health source for FDI.

use crate::dsdl;

/// Data-type id of uavcan.protocol.NodeStatus.
pub const DTID_NODE_STATUS: u16 = 341;
/// Data-type id of uavcan.equipment.esc.RawCommand.
Expand Down Expand Up @@ -45,10 +47,13 @@ pub fn decode_node_status(payload: &[u8]) -> Option<NodeStatus> {
return None;
}
Some(NodeStatus {
// byte-aligned fields are little-endian (== DSDL be_from_le_bits); the
// sub-byte fields use the DSDL bit codec (MSB-first within the byte) —
// the conformance fix vs the canonical pydronecan encoding.
uptime_sec: u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]),
health: payload[4] & 0x03,
mode: (payload[4] >> 2) & 0x07,
sub_mode: (payload[4] >> 5) & 0x07,
health: dsdl::read_uint(payload, 32, 2) as u8,
mode: dsdl::read_uint(payload, 34, 3) as u8,
sub_mode: dsdl::read_uint(payload, 37, 3) as u8,
vendor_status: u16::from_le_bytes([payload[5], payload[6]]),
})
}
Expand All @@ -67,18 +72,9 @@ pub fn encode_raw_command(channels: &[i16], out: &mut [u8]) -> usize {
for b in out[..nbytes].iter_mut() {
*b = 0;
}
let mut bit = 0usize;
for &c in channels.iter().take(n) {
let v = (c.clamp(ESC_CMD_MIN, ESC_CMD_MAX) as u16) & 0x3FFF; // 14-bit two's complement
let mut k = 0;
while k < 14 {
if v & (1 << k) != 0 {
let pos = bit + k;
out[pos / 8] |= 1 << (pos % 8);
}
k += 1;
}
bit += 14;
for (i, &c) in channels.iter().take(n).enumerate() {
let v = (c.clamp(ESC_CMD_MIN, ESC_CMD_MAX) as u16 & 0x3FFF) as u64; // int14 two's complement
dsdl::write_uint(out, i * 14, 14, v); // DSDL bit packing (conformance fix)
}
nbytes
}
Expand All @@ -97,20 +93,9 @@ pub struct RawCommand {
pub fn decode_raw_command(payload: &[u8]) -> RawCommand {
let n = (payload.len() * 8 / 14).min(MAX_ESC);
let mut channels = [0i16; MAX_ESC];
let mut bit = 0usize;
for ch in channels.iter_mut().take(n) {
let mut v: u16 = 0;
let mut k = 0;
while k < 14 {
let pos = bit + k;
if pos / 8 < payload.len() && payload[pos / 8] & (1 << (pos % 8)) != 0 {
v |= 1 << k;
}
k += 1;
}
// sign-extend the 14-bit two's complement to i16
*ch = if v & 0x2000 != 0 { (v | 0xC000) as i16 } else { v as i16 };
bit += 14;
for (i, ch) in channels.iter_mut().take(n).enumerate() {
// DSDL int14, sign-extended (conformance fix)
*ch = dsdl::read_int(payload, i * 14, 14) as i16;
}
RawCommand { channels, count: n }
}
Expand All @@ -119,19 +104,35 @@ pub fn decode_raw_command(payload: &[u8]) -> RawCommand {
mod tests {
use super::*;

/// CONFORMANCE: the canonical pydronecan NodeStatus(uptime=0x01020304,
/// health=2, mode=3, sub_mode=1, vendor=0xBEEF) is `0403020199efbe` —
/// byte 4 = 0x99 (MSB-first sub-byte fields). Catches the LSB-first bug.
#[test]
fn node_status_decodes_fields() {
// uptime 0x01020304, byte4 = health 2 | mode 5<<2 | sub_mode 1<<5 =
// 0b001_101_10 = 0x36 ; vendor 0xBEEF
let payload = [0x04, 0x03, 0x02, 0x01, 0b0010_1110, 0xEF, 0xBE];
let payload = [0x04, 0x03, 0x02, 0x01, 0x99, 0xEF, 0xBE];
let ns = decode_node_status(&payload).unwrap();
assert_eq!(ns.uptime_sec, 0x0102_0304);
assert_eq!(ns.health, 0b10); // bits 0-1
assert_eq!(ns.mode, 0b011); // bits 2-4
assert_eq!(ns.sub_mode, 0b001); // bits 5-7
assert_eq!(ns.health, 2);
assert_eq!(ns.mode, 3);
assert_eq!(ns.sub_mode, 1);
assert_eq!(ns.vendor_status, 0xBEEF);
}

/// CONFORMANCE (flight-critical): the canonical pydronecan RawCommand
/// [8191, 0, -8192, 4096] is `ff7c0000080010`. Decoding it must recover the
/// commands; encoding must reproduce the bytes. This is the bug that would
/// have sent garbage throttles to a real ESC.
#[test]
fn raw_command_matches_pydronecan_reference() {
let canonical = [0xff, 0x7c, 0x00, 0x00, 0x08, 0x00, 0x10];
let decoded = decode_raw_command(&canonical);
assert_eq!(&decoded.channels[..4], &[8191, 0, -8192, 4096]);
let mut buf = [0u8; 7];
let n = encode_raw_command(&[8191, 0, -8192, 4096], &mut buf);
assert_eq!(n, 7);
assert_eq!(buf, canonical);
}

#[test]
fn node_status_rejects_short_payload() {
assert_eq!(decode_node_status(&[0; 6]), None);
Expand Down
Loading
Loading