diff --git a/v2/crates/wifi-densepose-bfld/src/lib.rs b/v2/crates/wifi-densepose-bfld/src/lib.rs index 2ad9cc60..6008f3fd 100644 --- a/v2/crates/wifi-densepose-bfld/src/lib.rs +++ b/v2/crates/wifi-densepose-bfld/src/lib.rs @@ -14,11 +14,15 @@ #![cfg_attr(not(feature = "std"), no_std)] pub mod frame; +#[cfg(feature = "std")] +pub mod payload; pub mod sink; pub use frame::{BfldFrameHeader, BFLD_MAGIC, BFLD_VERSION, BFLD_HEADER_SIZE}; #[cfg(feature = "std")] pub use frame::BfldFrame; +#[cfg(feature = "std")] +pub use payload::BfldPayload; pub use sink::{check_class, LocalSink, MatterSink, NetworkSink, Sink}; /// Privacy classification carried in every `BfldFrame`. See ADR-120 §2.1. @@ -113,4 +117,13 @@ pub enum BfldError { /// Bytes the header indicates are required. need: usize, }, + + /// Payload section length-prefix decoding failed or trailing bytes left over. + #[error("malformed payload section at offset {offset}: {reason}")] + MalformedSection { + /// Byte offset within the payload where parsing failed. + offset: usize, + /// Human-readable reason for the failure. + reason: &'static str, + }, } diff --git a/v2/crates/wifi-densepose-bfld/src/payload.rs b/v2/crates/wifi-densepose-bfld/src/payload.rs new file mode 100644 index 00000000..5d36c85b --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/src/payload.rs @@ -0,0 +1,150 @@ +//! BFLD payload section parser. See ADR-119 §2.2. +//! +//! The payload is a length-prefixed sequence of typed sections in this fixed +//! order: +//! +//! ```text +//! payload = compressed_angle_matrix +//! ‖ amplitude_proxy +//! ‖ phase_proxy +//! ‖ snr_vector +//! ‖ csi_delta (present iff flags.bit0 set) +//! ‖ vendor_extension (length 0 allowed) +//! ``` +//! +//! Each section is encoded as `[u32 len_le][bytes...]`. Vendor extension is +//! always present in the wire form (length may be zero); CSI delta is gated by +//! the header `flags::HAS_CSI_DELTA` bit and is omitted entirely when off. +//! +//! Gated on `std` because the parser hands the caller owned `Vec` sections. +//! A future zero-copy `BfldPayloadRef<'_>` variant will land alongside the +//! ESP32-S3 self-only adapter (ADR-123 §2.5). + +#![cfg(feature = "std")] + +use crate::BfldError; + +/// Length-prefix size in bytes for each section. +pub const SECTION_PREFIX_LEN: usize = 4; + +/// Parsed payload sections. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct BfldPayload { + /// Compressed beamforming angle matrix (Φ/ψ Givens rotations). + pub compressed_angle_matrix: Vec, + /// Per-subcarrier amplitude proxy. + pub amplitude_proxy: Vec, + /// Per-subcarrier phase proxy. + pub phase_proxy: Vec, + /// Per-subcarrier SNR vector. + pub snr_vector: Vec, + /// Optional CSI delta fusion section (present iff header `flags.bit0` set). + pub csi_delta: Option>, + /// Vendor-extension bytes outside the witness hash. Length 0 is permitted. + pub vendor_extension: Vec, +} + +impl BfldPayload { + /// Serialize to canonical wire form. + /// + /// `include_csi_delta` must match the header `flags::HAS_CSI_DELTA` bit + /// the resulting payload will be paired with. When `true`, the `csi_delta` + /// section is emitted (using an empty section if `self.csi_delta` is `None`). + /// When `false`, the section is omitted entirely. + #[must_use] + pub fn to_bytes(&self, include_csi_delta: bool) -> Vec { + let mut out = Vec::with_capacity(self.wire_len(include_csi_delta)); + push_section(&mut out, &self.compressed_angle_matrix); + push_section(&mut out, &self.amplitude_proxy); + push_section(&mut out, &self.phase_proxy); + push_section(&mut out, &self.snr_vector); + if include_csi_delta { + let csi = self.csi_delta.as_deref().unwrap_or(&[]); + push_section(&mut out, csi); + } + push_section(&mut out, &self.vendor_extension); + out + } + + /// Predict the wire size of a future `to_bytes` call without serializing. + #[must_use] + pub fn wire_len(&self, include_csi_delta: bool) -> usize { + let mut n = SECTION_PREFIX_LEN * 5 // 4 mandatory + vendor + + self.compressed_angle_matrix.len() + + self.amplitude_proxy.len() + + self.phase_proxy.len() + + self.snr_vector.len() + + self.vendor_extension.len(); + if include_csi_delta { + n += SECTION_PREFIX_LEN + self.csi_delta.as_deref().map_or(0, <[u8]>::len); + } + n + } + + /// Parse from canonical wire form. + /// + /// `expect_csi_delta` must reflect the paired header's `flags::HAS_CSI_DELTA` + /// bit. Returns `MalformedSection` if a section length runs past the buffer + /// end, or if trailing bytes remain after the vendor-extension section. + pub fn from_bytes(bytes: &[u8], expect_csi_delta: bool) -> Result { + let mut cursor = 0usize; + let compressed_angle_matrix = read_section(bytes, &mut cursor)?; + let amplitude_proxy = read_section(bytes, &mut cursor)?; + let phase_proxy = read_section(bytes, &mut cursor)?; + let snr_vector = read_section(bytes, &mut cursor)?; + let csi_delta = if expect_csi_delta { + Some(read_section(bytes, &mut cursor)?) + } else { + None + }; + let vendor_extension = read_section(bytes, &mut cursor)?; + + if cursor != bytes.len() { + return Err(BfldError::MalformedSection { + offset: cursor, + reason: "trailing bytes after vendor_extension", + }); + } + Ok(Self { + compressed_angle_matrix, + amplitude_proxy, + phase_proxy, + snr_vector, + csi_delta, + vendor_extension, + }) + } +} + +fn push_section(out: &mut Vec, bytes: &[u8]) { + let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX); + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(bytes); +} + +fn read_section(bytes: &[u8], cursor: &mut usize) -> Result, BfldError> { + let start = *cursor; + if start + SECTION_PREFIX_LEN > bytes.len() { + return Err(BfldError::MalformedSection { + offset: start, + reason: "section length prefix runs past buffer end", + }); + } + let len_bytes: [u8; 4] = bytes[start..start + SECTION_PREFIX_LEN].try_into().unwrap(); + let len = u32::from_le_bytes(len_bytes) as usize; + let data_start = start + SECTION_PREFIX_LEN; + let data_end = data_start + .checked_add(len) + .ok_or(BfldError::MalformedSection { + offset: start, + reason: "section length overflows usize", + })?; + if data_end > bytes.len() { + return Err(BfldError::MalformedSection { + offset: start, + reason: "section body runs past buffer end", + }); + } + *cursor = data_end; + Ok(bytes[data_start..data_end].to_vec()) +} diff --git a/v2/crates/wifi-densepose-bfld/tests/payload_sections.rs b/v2/crates/wifi-densepose-bfld/tests/payload_sections.rs new file mode 100644 index 00000000..dae33a3b --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/payload_sections.rs @@ -0,0 +1,105 @@ +//! Acceptance tests for ADR-119 §2.2 payload section layout. + +#![cfg(feature = "std")] + +use wifi_densepose_bfld::payload::SECTION_PREFIX_LEN; +use wifi_densepose_bfld::{BfldError, BfldPayload}; + +fn full_payload() -> BfldPayload { + BfldPayload { + compressed_angle_matrix: vec![0x11; 64], + amplitude_proxy: vec![0x22; 32], + phase_proxy: vec![0x33; 32], + snr_vector: vec![0x44; 16], + csi_delta: Some(vec![0x55; 48]), + vendor_extension: vec![0xAA, 0xBB, 0xCC], + } +} + +#[test] +fn payload_roundtrip_with_csi_delta() { + let p = full_payload(); + let bytes = p.to_bytes(true); + let parsed = BfldPayload::from_bytes(&bytes, true).expect("parse must succeed"); + assert_eq!(parsed, p); +} + +#[test] +fn payload_roundtrip_without_csi_delta() { + let mut p = full_payload(); + p.csi_delta = None; + let bytes = p.to_bytes(false); + let parsed = BfldPayload::from_bytes(&bytes, false).expect("parse must succeed"); + assert_eq!(parsed, p); +} + +#[test] +fn wire_len_matches_to_bytes_length() { + let p = full_payload(); + assert_eq!(p.wire_len(true), p.to_bytes(true).len()); + assert_eq!(p.wire_len(false), p.to_bytes(false).len()); +} + +#[test] +fn empty_payload_has_five_zero_length_sections() { + let p = BfldPayload::default(); + let bytes = p.to_bytes(false); + // 5 mandatory sections (compressed_angle_matrix, amplitude_proxy, phase_proxy, + // snr_vector, vendor_extension), each just the 4-byte length prefix. + assert_eq!(bytes.len(), SECTION_PREFIX_LEN * 5); + assert!(bytes.iter().all(|&b| b == 0)); + let parsed = BfldPayload::from_bytes(&bytes, false).expect("empty parse must succeed"); + assert_eq!(parsed, p); +} + +#[test] +fn parser_rejects_buffer_shorter_than_first_length_prefix() { + let too_short = [0u8; 3]; + match BfldPayload::from_bytes(&too_short, false) { + Err(BfldError::MalformedSection { offset, .. }) => assert_eq!(offset, 0), + other => panic!("expected MalformedSection at offset 0, got {other:?}"), + } +} + +#[test] +fn parser_rejects_section_body_running_past_buffer_end() { + // Section claims 1000 bytes, buffer only has 4 + 10. + let mut bytes = Vec::new(); + bytes.extend_from_slice(&1000u32.to_le_bytes()); + bytes.extend_from_slice(&[0xCC; 10]); + match BfldPayload::from_bytes(&bytes, false) { + Err(BfldError::MalformedSection { offset, reason }) => { + assert_eq!(offset, 0); + assert!(reason.contains("body")); + } + other => panic!("expected MalformedSection (body), got {other:?}"), + } +} + +#[test] +fn parser_rejects_trailing_bytes_after_vendor_extension() { + let mut bytes = BfldPayload::default().to_bytes(false); + bytes.push(0xFF); // unexpected trailing byte + match BfldPayload::from_bytes(&bytes, false) { + Err(BfldError::MalformedSection { reason, .. }) => { + assert!(reason.contains("trailing")); + } + other => panic!("expected trailing-bytes MalformedSection, got {other:?}"), + } +} + +#[test] +fn csi_delta_flag_mismatch_with_payload_is_detectable_via_trailing_bytes() { + // Serialize WITH csi_delta but parse WITHOUT — the parser will hit the + // csi_delta section's bytes after reading vendor_extension, triggering the + // trailing-bytes guard. (Real flag/payload consistency is the caller's job; + // this test just confirms the parser doesn't silently accept misalignment.) + let p = full_payload(); + let bytes = p.to_bytes(true); + match BfldPayload::from_bytes(&bytes, false) { + Err(BfldError::MalformedSection { reason, .. }) => { + assert!(reason.contains("trailing")); + } + other => panic!("expected MalformedSection from flag/payload skew, got {other:?}"), + } +}