diff --git a/v2/crates/wifi-densepose-bfld/src/frame.rs b/v2/crates/wifi-densepose-bfld/src/frame.rs index 7c36facb..cdc68f7f 100644 --- a/v2/crates/wifi-densepose-bfld/src/frame.rs +++ b/v2/crates/wifi-densepose-bfld/src/frame.rs @@ -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::(), 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 { + 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) + } +} diff --git a/v2/crates/wifi-densepose-bfld/tests/header_roundtrip.rs b/v2/crates/wifi-densepose-bfld/tests/header_roundtrip.rs new file mode 100644 index 00000000..a0faf74c --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/header_roundtrip.rs @@ -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); +}