feat(adr-118/p1.2): header encode/decode + 6 round-trip tests (9/9 GREEN)
Iter 2 of the BFLD rollout. Adds the canonical little-endian wire form for
BfldFrameHeader with safe (no unsafe) encoders/decoders. Covers ADR-119 AC5
(round-trip preservation), AC6 (deterministic serialization), and partial
AC1 (constant wire size) / AC4 (rejects bad magic + bad version).
Added:
- BfldFrameHeader::empty() — convenience constructor with magic/version set
- BfldFrameHeader::to_le_bytes() -> [u8; 86]
- BfldFrameHeader::from_le_bytes(&[u8; 86]) -> Result<Self, BfldError>
- Field-level doc strings on every header field (clears all 21 missing-docs
warnings the iter 1 commit logged)
- tests/header_roundtrip.rs — 6 named tests:
header_roundtrip_preserves_all_fields
header_serialization_is_deterministic
header_magic_is_at_offset_zero_little_endian (LE byte order proof)
parsing_rejects_invalid_magic
parsing_rejects_unsupported_version
wire_size_is_constant
Implementation notes:
- Used #[derive(Default)] on BfldFrameHeader so empty() can build cleanly.
- to_le_bytes copies packed fields into locals first to dodge unaligned-
borrow lints; from_le_bytes uses try_into() on byte slices.
- All field reads/writes are #[forbid(unsafe_code)] compliant.
Out of scope (next iter targets):
- BfldFrame (header + payload sections + section-length prefixes + CRC32
computation over payload bytes only) — needs the `crc` crate dependency.
- PrivacyGate::demote(...) skeleton (ADR-120 §2.4).
- SinkMarker traits (LocalSink / NetworkSink / MatterSink) — ADR-120 §2.2.
cargo test -p wifi-densepose-bfld --no-default-features → 9 passed, 0 failed
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
c965e3e6c0
commit
be4dad6ede
|
|
@ -3,9 +3,15 @@
|
|||
//! The header is `#[repr(C, packed)]` so the wire byte order is fixed across
|
||||
//! x86_64, aarch64, and xtensa-esp32s3 — and so the witness-bundle pattern
|
||||
//! (ADR-028) extends cleanly to BFLD frames.
|
||||
//!
|
||||
//! All multi-byte integers serialize as **little-endian**. The
|
||||
//! `to_le_bytes`/`from_le_bytes` helpers encode/decode without `unsafe`, which
|
||||
//! is forbidden in this crate; the encoded bytes are the canonical wire form.
|
||||
|
||||
use static_assertions::const_assert_eq;
|
||||
|
||||
use crate::BfldError;
|
||||
|
||||
/// Magic value identifying a `BfldFrame`. Reads as "BFLD" in hex-dump tools.
|
||||
pub const BFLD_MAGIC: u32 = 0xBF1D_0001;
|
||||
|
||||
|
|
@ -29,35 +35,143 @@ pub mod flags {
|
|||
}
|
||||
|
||||
/// On-the-wire BFLD frame header. 86 bytes, little-endian, packed.
|
||||
///
|
||||
/// All multi-byte integer fields are little-endian when serialized. The packed
|
||||
/// layout guarantees zero internal padding; readers must use `read_unaligned`
|
||||
/// (or the accessor helpers added in a later commit).
|
||||
#[repr(C, packed)]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct BfldFrameHeader {
|
||||
/// Must equal [`BFLD_MAGIC`].
|
||||
pub magic: u32,
|
||||
/// Layout version. Currently [`BFLD_VERSION`].
|
||||
pub version: u16,
|
||||
/// Flag bits — see [`flags`].
|
||||
pub flags: u16,
|
||||
/// Monotonic capture-clock timestamp in nanoseconds.
|
||||
pub timestamp_ns: u64,
|
||||
|
||||
/// BLAKE3-keyed(site_salt, ap_mac)[0..16] — ADR-120 §2.3.
|
||||
pub ap_hash: [u8; 16],
|
||||
/// BLAKE3-keyed(site_salt ‖ day_epoch, sta_mac)[0..16] — daily-rotated.
|
||||
pub sta_hash: [u8; 16],
|
||||
/// Ephemeral session identifier, rotated on capture-session boundary.
|
||||
pub session_id: [u8; 16],
|
||||
|
||||
/// 802.11 channel number.
|
||||
pub channel: u16,
|
||||
/// Channel bandwidth in MHz: 20 / 40 / 80 / 160.
|
||||
pub bandwidth_mhz: u16,
|
||||
/// Received signal strength in dBm.
|
||||
pub rssi_dbm: i16,
|
||||
/// Noise floor in dBm.
|
||||
pub noise_floor_dbm: i16,
|
||||
|
||||
/// Number of OFDM subcarriers represented.
|
||||
pub n_subcarriers: u16,
|
||||
/// Number of transmit antennas.
|
||||
pub n_tx: u8,
|
||||
/// Number of receive antennas.
|
||||
pub n_rx: u8,
|
||||
/// 0=f32, 1=i16, 2=i8, 3=packed (4-bit nibbles).
|
||||
pub quantization: u8,
|
||||
/// `PrivacyClass` byte — see ADR-120 §2.1.
|
||||
pub privacy_class: u8,
|
||||
|
||||
/// Length of the payload section in bytes.
|
||||
pub payload_len: u32,
|
||||
/// CRC-32/ISO-HDLC over payload bytes only.
|
||||
pub payload_crc32: u32,
|
||||
}
|
||||
|
||||
const_assert_eq!(core::mem::size_of::<BfldFrameHeader>(), BFLD_HEADER_SIZE);
|
||||
|
||||
impl BfldFrameHeader {
|
||||
/// Build a header with `magic` and `version` already set correctly.
|
||||
/// All other fields default to zero — caller fills them in.
|
||||
#[must_use]
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
magic: BFLD_MAGIC,
|
||||
version: BFLD_VERSION,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize to canonical little-endian wire form (86 bytes).
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn to_le_bytes(&self) -> [u8; BFLD_HEADER_SIZE] {
|
||||
let mut buf = [0u8; BFLD_HEADER_SIZE];
|
||||
let mut o = 0usize;
|
||||
|
||||
// Copy locally to dodge `#[repr(packed)]` unaligned-borrow warnings.
|
||||
let magic = self.magic;
|
||||
let version = self.version;
|
||||
let flags = self.flags;
|
||||
let timestamp_ns = self.timestamp_ns;
|
||||
let channel = self.channel;
|
||||
let bandwidth_mhz = self.bandwidth_mhz;
|
||||
let rssi_dbm = self.rssi_dbm;
|
||||
let noise_floor_dbm = self.noise_floor_dbm;
|
||||
let n_subcarriers = self.n_subcarriers;
|
||||
let payload_len = self.payload_len;
|
||||
let payload_crc32 = self.payload_crc32;
|
||||
|
||||
buf[o..o + 4].copy_from_slice(&magic.to_le_bytes()); o += 4;
|
||||
buf[o..o + 2].copy_from_slice(&version.to_le_bytes()); o += 2;
|
||||
buf[o..o + 2].copy_from_slice(&flags.to_le_bytes()); o += 2;
|
||||
buf[o..o + 8].copy_from_slice(×tamp_ns.to_le_bytes()); o += 8;
|
||||
buf[o..o + 16].copy_from_slice(&self.ap_hash); o += 16;
|
||||
buf[o..o + 16].copy_from_slice(&self.sta_hash); o += 16;
|
||||
buf[o..o + 16].copy_from_slice(&self.session_id); o += 16;
|
||||
buf[o..o + 2].copy_from_slice(&channel.to_le_bytes()); o += 2;
|
||||
buf[o..o + 2].copy_from_slice(&bandwidth_mhz.to_le_bytes()); o += 2;
|
||||
buf[o..o + 2].copy_from_slice(&rssi_dbm.to_le_bytes()); o += 2;
|
||||
buf[o..o + 2].copy_from_slice(&noise_floor_dbm.to_le_bytes()); o += 2;
|
||||
buf[o..o + 2].copy_from_slice(&n_subcarriers.to_le_bytes()); o += 2;
|
||||
buf[o] = self.n_tx; o += 1;
|
||||
buf[o] = self.n_rx; o += 1;
|
||||
buf[o] = self.quantization; o += 1;
|
||||
buf[o] = self.privacy_class; o += 1;
|
||||
buf[o..o + 4].copy_from_slice(&payload_len.to_le_bytes()); o += 4;
|
||||
buf[o..o + 4].copy_from_slice(&payload_crc32.to_le_bytes()); o += 4;
|
||||
|
||||
debug_assert_eq!(o, BFLD_HEADER_SIZE);
|
||||
buf
|
||||
}
|
||||
|
||||
/// Parse from canonical little-endian wire form.
|
||||
///
|
||||
/// Returns [`BfldError::InvalidMagic`] if the magic prefix is wrong, and
|
||||
/// [`BfldError::UnsupportedVersion`] for a version this build cannot decode.
|
||||
/// Field-level validation (CRC, payload_len bounds) is deliberately *not*
|
||||
/// performed here — that lives at the frame-level parser.
|
||||
pub fn from_le_bytes(bytes: &[u8; BFLD_HEADER_SIZE]) -> Result<Self, BfldError> {
|
||||
let magic = u32::from_le_bytes(bytes[0..4].try_into().unwrap());
|
||||
if magic != BFLD_MAGIC {
|
||||
return Err(BfldError::InvalidMagic(magic));
|
||||
}
|
||||
let version = u16::from_le_bytes(bytes[4..6].try_into().unwrap());
|
||||
if version != BFLD_VERSION {
|
||||
return Err(BfldError::UnsupportedVersion(version));
|
||||
}
|
||||
|
||||
let mut h = Self {
|
||||
magic,
|
||||
version,
|
||||
flags: u16::from_le_bytes(bytes[6..8].try_into().unwrap()),
|
||||
timestamp_ns: u64::from_le_bytes(bytes[8..16].try_into().unwrap()),
|
||||
ap_hash: [0; 16],
|
||||
sta_hash: [0; 16],
|
||||
session_id: [0; 16],
|
||||
channel: u16::from_le_bytes(bytes[64..66].try_into().unwrap()),
|
||||
bandwidth_mhz: u16::from_le_bytes(bytes[66..68].try_into().unwrap()),
|
||||
rssi_dbm: i16::from_le_bytes(bytes[68..70].try_into().unwrap()),
|
||||
noise_floor_dbm: i16::from_le_bytes(bytes[70..72].try_into().unwrap()),
|
||||
n_subcarriers: u16::from_le_bytes(bytes[72..74].try_into().unwrap()),
|
||||
n_tx: bytes[74],
|
||||
n_rx: bytes[75],
|
||||
quantization: bytes[76],
|
||||
privacy_class: bytes[77],
|
||||
payload_len: u32::from_le_bytes(bytes[78..82].try_into().unwrap()),
|
||||
payload_crc32: u32::from_le_bytes(bytes[82..86].try_into().unwrap()),
|
||||
};
|
||||
h.ap_hash.copy_from_slice(&bytes[16..32]);
|
||||
h.sta_hash.copy_from_slice(&bytes[32..48]);
|
||||
h.session_id.copy_from_slice(&bytes[48..64]);
|
||||
Ok(h)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
//! Acceptance tests for `BfldFrameHeader` serialization (ADR-119 AC5/AC6).
|
||||
|
||||
use wifi_densepose_bfld::frame::flags;
|
||||
use wifi_densepose_bfld::{BfldError, BfldFrameHeader, BFLD_HEADER_SIZE, BFLD_MAGIC};
|
||||
|
||||
fn sample_header() -> BfldFrameHeader {
|
||||
let mut h = BfldFrameHeader::empty();
|
||||
h.flags = flags::HAS_CSI_DELTA | flags::PRIVACY_MODE;
|
||||
h.timestamp_ns = 0x0123_4567_89AB_CDEF;
|
||||
h.ap_hash = [0xAA; 16];
|
||||
h.sta_hash = [0xBB; 16];
|
||||
h.session_id = [0xCC; 16];
|
||||
h.channel = 36;
|
||||
h.bandwidth_mhz = 80;
|
||||
h.rssi_dbm = -55;
|
||||
h.noise_floor_dbm = -95;
|
||||
h.n_subcarriers = 234;
|
||||
h.n_tx = 3;
|
||||
h.n_rx = 4;
|
||||
h.quantization = 1;
|
||||
h.privacy_class = 2;
|
||||
h.payload_len = 12_345;
|
||||
h.payload_crc32 = 0xDEAD_BEEF;
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_roundtrip_preserves_all_fields() {
|
||||
let original = sample_header();
|
||||
let bytes = original.to_le_bytes();
|
||||
let parsed = BfldFrameHeader::from_le_bytes(&bytes).expect("parse must succeed");
|
||||
|
||||
assert_eq!({ parsed.magic }, BFLD_MAGIC);
|
||||
assert_eq!({ parsed.version }, 1);
|
||||
assert_eq!({ parsed.flags }, flags::HAS_CSI_DELTA | flags::PRIVACY_MODE);
|
||||
assert_eq!({ parsed.timestamp_ns }, 0x0123_4567_89AB_CDEF);
|
||||
assert_eq!(parsed.ap_hash, [0xAA; 16]);
|
||||
assert_eq!(parsed.sta_hash, [0xBB; 16]);
|
||||
assert_eq!(parsed.session_id, [0xCC; 16]);
|
||||
assert_eq!({ parsed.channel }, 36);
|
||||
assert_eq!({ parsed.bandwidth_mhz }, 80);
|
||||
assert_eq!({ parsed.rssi_dbm }, -55);
|
||||
assert_eq!({ parsed.noise_floor_dbm }, -95);
|
||||
assert_eq!({ parsed.n_subcarriers }, 234);
|
||||
assert_eq!(parsed.n_tx, 3);
|
||||
assert_eq!(parsed.n_rx, 4);
|
||||
assert_eq!(parsed.quantization, 1);
|
||||
assert_eq!(parsed.privacy_class, 2);
|
||||
assert_eq!({ parsed.payload_len }, 12_345);
|
||||
assert_eq!({ parsed.payload_crc32 }, 0xDEAD_BEEF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_serialization_is_deterministic() {
|
||||
let h = sample_header();
|
||||
let a = h.to_le_bytes();
|
||||
let b = h.to_le_bytes();
|
||||
assert_eq!(a, b, "two serializations of the same header must be bit-identical");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_magic_is_at_offset_zero_little_endian() {
|
||||
let bytes = sample_header().to_le_bytes();
|
||||
// BFLD_MAGIC = 0xBF1D_0001 → little-endian: 01 00 1D BF
|
||||
assert_eq!(&bytes[0..4], &[0x01, 0x00, 0x1D, 0xBF]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsing_rejects_invalid_magic() {
|
||||
let mut bytes = sample_header().to_le_bytes();
|
||||
bytes[0] = 0xFF; // clobber magic
|
||||
match BfldFrameHeader::from_le_bytes(&bytes) {
|
||||
Err(BfldError::InvalidMagic(got)) => {
|
||||
assert_ne!(got, BFLD_MAGIC);
|
||||
}
|
||||
other => panic!("expected InvalidMagic, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsing_rejects_unsupported_version() {
|
||||
let mut bytes = sample_header().to_le_bytes();
|
||||
bytes[4] = 99; // version field at offset 4 (LE u16)
|
||||
bytes[5] = 0;
|
||||
match BfldFrameHeader::from_le_bytes(&bytes) {
|
||||
Err(BfldError::UnsupportedVersion(v)) => assert_eq!(v, 99),
|
||||
other => panic!("expected UnsupportedVersion, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_size_is_constant() {
|
||||
assert_eq!(sample_header().to_le_bytes().len(), BFLD_HEADER_SIZE);
|
||||
}
|
||||
Loading…
Reference in New Issue