feat(adr-118/p1.5): payload section parser (BfldPayload) — 32/32 GREEN
Iter 5. Implements ADR-119 §2.2 payload layout: 4-byte LE length prefix
followed by section bytes, in this fixed order:
compressed_angle_matrix ‖ amplitude_proxy ‖ phase_proxy ‖ snr_vector
‖ csi_delta (iff flags.bit0)
‖ vendor_extension (length 0 allowed)
Added:
- src/payload.rs (gated on `feature = "std"`):
* BfldPayload struct with 6 fields (csi_delta: Option<Vec<u8>>)
* SECTION_PREFIX_LEN const (= 4)
* to_bytes(include_csi_delta: bool) -> Vec<u8>
* wire_len(include_csi_delta: bool) -> usize (predictive, no allocation)
* from_bytes(&[u8], expect_csi_delta: bool) -> Result<Self, BfldError>
* push_section / read_section helpers (private)
- BfldError::MalformedSection { offset, reason } variant
- pub use BfldPayload from lib.rs (cfg-gated mirror of BfldFrame)
tests/payload_sections.rs (8 named tests, all green):
payload_roundtrip_with_csi_delta
payload_roundtrip_without_csi_delta
wire_len_matches_to_bytes_length
empty_payload_has_five_zero_length_sections
parser_rejects_buffer_shorter_than_first_length_prefix
parser_rejects_section_body_running_past_buffer_end
parser_rejects_trailing_bytes_after_vendor_extension
csi_delta_flag_mismatch_with_payload_is_detectable_via_trailing_bytes
ACs progressed:
- AC5 ↑ — full section-level round-trip preservation (round-trip with and
without csi_delta both pass).
- AC6 ↑ — deterministic section encoding (length prefixes use to_le_bytes,
body is byte-stable).
- AC1 partial — section layout now parses with bounded errors; CBFR-specific
parsing (Phi/Psi Givens decoders) is a separate iter inside extractor.rs.
Test config:
- cargo test --no-default-features → 17 passed (payload module cfg-out)
- cargo test → 32 passed (3 + 6 + 7 + 8 + 8)
Out of scope (next iter target):
- Wire integration: feed BfldPayload bytes through BfldFrame::new so the
header.payload_crc32 covers the section-prefixed bytes per ADR-119 §2.2
("CRC32 covers all section bytes including length prefixes").
- A no_std-friendly BfldPayloadRef<'_> borrowing variant (ESP32-S3 path).
- Givens-rotation angle decoder (Phi/Psi extraction from compressed_angle_matrix).
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
775661b2e8
commit
73ba8d3b27
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<u8>` 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<u8>,
|
||||
/// Per-subcarrier amplitude proxy.
|
||||
pub amplitude_proxy: Vec<u8>,
|
||||
/// Per-subcarrier phase proxy.
|
||||
pub phase_proxy: Vec<u8>,
|
||||
/// Per-subcarrier SNR vector.
|
||||
pub snr_vector: Vec<u8>,
|
||||
/// Optional CSI delta fusion section (present iff header `flags.bit0` set).
|
||||
pub csi_delta: Option<Vec<u8>>,
|
||||
/// Vendor-extension bytes outside the witness hash. Length 0 is permitted.
|
||||
pub vendor_extension: Vec<u8>,
|
||||
}
|
||||
|
||||
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<u8> {
|
||||
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<Self, BfldError> {
|
||||
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<u8>, 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<Vec<u8>, 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())
|
||||
}
|
||||
|
|
@ -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:?}"),
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue