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:
ruv 2026-05-24 13:38:11 -04:00
parent c965e3e6c0
commit be4dad6ede
2 changed files with 217 additions and 9 deletions

View File

@ -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(&timestamp_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)
}
}

View File

@ -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);
}