feat(adr-118/p1.4): BfldFrame (header + payload + CRC32) — 24/24 GREEN

Iter 4. Lands the central wire-format primitive: complete frames with
header + arbitrary-length payload, protected by CRC-32/ISO-HDLC.

Added:
- crc = "3" dependency (CRC-32/ISO-HDLC, same poly as Ethernet / zlib)
- src/frame.rs: CRC32_ALG const and crc32_of_payload(&[u8]) -> u32
- src/frame.rs: BfldFrame { header, payload: Vec<u8> } (gated on `std`)
  * BfldFrame::new(header, payload) — auto-syncs payload_len + payload_crc32
  * BfldFrame::to_bytes() -> Vec<u8> — header LE bytes ‖ payload
  * BfldFrame::from_bytes(&[u8]) -> Result<Self, BfldError>
- BfldError::TruncatedFrame { got, need } variant
- Doc strings on BfldError::Crc and BfldError::PrivacyViolation field names
- tests/frame_roundtrip.rs (7 named tests, gated on feature = "std"):
    frame_roundtrip_preserves_header_and_payload
    frame_new_syncs_payload_len_and_crc
    frame_serialization_is_deterministic
    frame_rejects_payload_crc_mismatch
    frame_rejects_truncated_buffer_smaller_than_header
    frame_rejects_truncated_buffer_smaller_than_payload
    empty_payload_is_valid (CRC of empty payload is 0x00000000)

Test config:
- cargo test --no-default-features → 17 passed (frame_roundtrip cfg-out)
- cargo test (default features = std)  → 24 passed (3+6+7+8)

ADR-119 ACs progressed:
- AC4 partial: bad-magic + bad-version + CRC-mismatch + truncation rejected
  with typed errors; field-level masking lives in the privacy_gate iter.
- AC5: BfldFrame round-trip preserves header + payload + CRC.
- AC6: Identical inputs produce bit-identical bytes (asserted explicitly).

Out of scope (next iter):
- Payload section parser (compressed_angle_matrix, amplitude_proxy, ...)
  — only the byte buffer is opaque so far; sections need length prefixes.
- BfldFrameRef<'_> for ESP32-S3 self-only mode (no-alloc, ADR-123 §2.5).
- PrivacyGate::demote(frame, target_class) transformer (ADR-120 §2.4).

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-05-24 13:58:26 -04:00
parent eb996294fb
commit 775661b2e8
5 changed files with 238 additions and 2 deletions
Generated
+16
View File
@@ -1173,6 +1173,21 @@ dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -9143,6 +9158,7 @@ checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
name = "wifi-densepose-bfld"
version = "0.3.0"
dependencies = [
"crc",
"proptest",
"static_assertions",
"thiserror 2.0.18",
+1
View File
@@ -21,6 +21,7 @@ soul-signature = []
[dependencies]
thiserror.workspace = true
static_assertions = "1.1"
crc = "3"
[dev-dependencies]
proptest.workspace = true
@@ -7,11 +7,26 @@
//! 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.
//!
//! CRC-32/ISO-HDLC (the same polynomial Ethernet uses) protects the payload.
//! See [`crc32_of_payload`] for the canonical computation.
use static_assertions::const_assert_eq;
use crate::BfldError;
/// CRC-32/ISO-HDLC algorithm used to checksum payload bytes. Poly 0xEDB88320,
/// init 0xFFFFFFFF, xorout 0xFFFFFFFF, reflected — same as Ethernet / zlib.
pub const CRC32_ALG: crc::Crc<u32> = crc::Crc::<u32>::new(&crc::CRC_32_ISO_HDLC);
/// Compute the canonical CRC32 over `payload`. The header CRC field is **not**
/// included in the digest (ADR-119 §2.2: "CRC32 covers all section bytes
/// including length prefixes, but not the header").
#[must_use]
pub fn crc32_of_payload(payload: &[u8]) -> u32 {
CRC32_ALG.checksum(payload)
}
/// Magic value identifying a `BfldFrame`. Reads as "BFLD" in hex-dump tools.
pub const BFLD_MAGIC: u32 = 0xBF1D_0001;
@@ -175,3 +190,82 @@ impl BfldFrameHeader {
Ok(h)
}
}
// --- BfldFrame (header + payload) ------------------------------------------
//
// Gated on `std` because the payload is heap-allocated (`Vec<u8>`). ESP32-S3
// self-only mode (ADR-123 §2.5) will need a separate `BfldFrameRef<'_>` API
// that borrows a caller-provided buffer; that lands in a later iter.
/// Complete BFLD frame: header + payload bytes. The frame's wire form is
/// `header.to_le_bytes() ‖ payload`, with the header's `payload_len` and
/// `payload_crc32` fields kept consistent by `to_bytes`/`from_bytes`.
#[cfg(feature = "std")]
#[derive(Debug, Clone)]
pub struct BfldFrame {
/// Header — `payload_len` and `payload_crc32` reflect the payload below.
pub header: BfldFrameHeader,
/// Raw payload bytes. The internal section layout (compressed_angle_matrix,
/// amplitude_proxy, ...) lives in a later iter; for now the byte buffer is
/// opaque to this struct.
pub payload: Vec<u8>,
}
#[cfg(feature = "std")]
impl BfldFrame {
/// Construct a frame, automatically syncing `header.payload_len` and
/// `header.payload_crc32` to the supplied `payload`.
#[must_use]
pub fn new(mut header: BfldFrameHeader, payload: Vec<u8>) -> Self {
let len = u32::try_from(payload.len()).unwrap_or(u32::MAX);
header.payload_len = len;
header.payload_crc32 = crc32_of_payload(&payload);
Self { header, payload }
}
/// Serialize to wire form: 86 header bytes + `payload_len` payload bytes.
/// Always recomputes `payload_crc32` so the returned bytes are internally
/// consistent even if the caller mutated `header.payload_crc32` directly.
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
let mut header = self.header;
header.payload_len = u32::try_from(self.payload.len()).unwrap_or(u32::MAX);
header.payload_crc32 = crc32_of_payload(&self.payload);
let header_bytes = header.to_le_bytes();
let mut out = Vec::with_capacity(BFLD_HEADER_SIZE + self.payload.len());
out.extend_from_slice(&header_bytes);
out.extend_from_slice(&self.payload);
out
}
/// Parse from wire form. Validates magic, version, payload length, and CRC.
pub fn from_bytes(bytes: &[u8]) -> Result<Self, BfldError> {
if bytes.len() < BFLD_HEADER_SIZE {
return Err(BfldError::TruncatedFrame {
got: bytes.len(),
need: BFLD_HEADER_SIZE,
});
}
let header_bytes: &[u8; BFLD_HEADER_SIZE] =
bytes[..BFLD_HEADER_SIZE].try_into().unwrap();
let header = BfldFrameHeader::from_le_bytes(header_bytes)?;
let payload_len = header.payload_len as usize;
let expected_total = BFLD_HEADER_SIZE.saturating_add(payload_len);
if bytes.len() < expected_total {
return Err(BfldError::TruncatedFrame {
got: bytes.len(),
need: expected_total,
});
}
let payload = bytes[BFLD_HEADER_SIZE..expected_total].to_vec();
let actual = crc32_of_payload(&payload);
let expected = header.payload_crc32;
if actual != expected {
return Err(BfldError::Crc { expected, actual });
}
Ok(Self { header, payload })
}
}
+21 -2
View File
@@ -17,6 +17,8 @@ pub mod frame;
pub mod sink;
pub use frame::{BfldFrameHeader, BFLD_MAGIC, BFLD_VERSION, BFLD_HEADER_SIZE};
#[cfg(feature = "std")]
pub use frame::BfldFrame;
pub use sink::{check_class, LocalSink, MatterSink, NetworkSink, Sink};
/// Privacy classification carried in every `BfldFrame`. See ADR-120 §2.1.
@@ -84,14 +86,31 @@ pub enum BfldError {
/// Payload CRC32 mismatch — frame corrupted or tampered.
#[error("payload CRC mismatch: expected 0x{expected:08X}, got 0x{actual:08X}")]
Crc { expected: u32, actual: u32 },
Crc {
/// CRC value the header declared.
expected: u32,
/// CRC value computed over the received payload.
actual: u32,
},
/// Attempted to publish a class-0 (`Raw`) frame through a network sink.
/// Enforces structural invariant I1.
#[error("privacy violation: {reason}")]
PrivacyViolation { reason: &'static str },
PrivacyViolation {
/// `Sink::KIND` of the sink that rejected the frame.
reason: &'static str,
},
/// Byte value did not map to any defined `PrivacyClass` (0..=3).
#[error("invalid PrivacyClass byte: {0}")]
InvalidPrivacyClass(u8),
/// Buffer too short for header (86 bytes) or header + declared payload.
#[error("truncated frame: got {got} bytes, need at least {need}")]
TruncatedFrame {
/// Bytes available in the input buffer.
got: usize,
/// Bytes the header indicates are required.
need: usize,
},
}
@@ -0,0 +1,106 @@
//! Acceptance tests for `BfldFrame` round-trip (ADR-119 AC4/AC5/AC6).
//!
//! Requires the `std` feature; under `--no-default-features` the entire file
//! is compiled out (BfldFrame depends on `Vec<u8>`).
#![cfg(feature = "std")]
use wifi_densepose_bfld::frame::{crc32_of_payload, flags};
use wifi_densepose_bfld::{BfldError, BfldFrame, BfldFrameHeader, BFLD_HEADER_SIZE};
fn sample_header() -> BfldFrameHeader {
let mut h = BfldFrameHeader::empty();
h.flags = flags::HAS_CSI_DELTA;
h.timestamp_ns = 1_700_000_000_000_000_000;
h.channel = 36;
h.bandwidth_mhz = 80;
h.n_subcarriers = 234;
h.n_tx = 2;
h.n_rx = 2;
h.quantization = 1;
h.privacy_class = 2;
h
}
fn sample_payload() -> Vec<u8> {
// Pseudo-CBFR section: small but non-trivial.
(0u8..200).cycle().take(512).collect()
}
#[test]
fn frame_roundtrip_preserves_header_and_payload() {
let frame = BfldFrame::new(sample_header(), sample_payload());
let bytes = frame.to_bytes();
assert_eq!(bytes.len(), BFLD_HEADER_SIZE + 512);
let parsed = BfldFrame::from_bytes(&bytes).expect("parse must succeed");
assert_eq!(parsed.payload, sample_payload());
assert_eq!({ parsed.header.payload_len }, 512);
assert_eq!({ parsed.header.channel }, 36);
assert_eq!({ parsed.header.privacy_class }, 2);
}
#[test]
fn frame_new_syncs_payload_len_and_crc() {
let payload = sample_payload();
let frame = BfldFrame::new(BfldFrameHeader::empty(), payload.clone());
assert_eq!({ frame.header.payload_len }, payload.len() as u32);
assert_eq!({ frame.header.payload_crc32 }, crc32_of_payload(&payload));
}
#[test]
fn frame_serialization_is_deterministic() {
let frame = BfldFrame::new(sample_header(), sample_payload());
let a = frame.to_bytes();
let b = frame.to_bytes();
assert_eq!(a, b);
}
#[test]
fn frame_rejects_payload_crc_mismatch() {
let frame = BfldFrame::new(sample_header(), sample_payload());
let mut bytes = frame.to_bytes();
// Flip a payload byte; CRC over payload must now disagree with the header.
bytes[BFLD_HEADER_SIZE + 7] ^= 0xFF;
match BfldFrame::from_bytes(&bytes) {
Err(BfldError::Crc { expected, actual }) => assert_ne!(expected, actual),
other => panic!("expected Crc error, got {other:?}"),
}
}
#[test]
fn frame_rejects_truncated_buffer_smaller_than_header() {
let too_short = vec![0u8; 50];
match BfldFrame::from_bytes(&too_short) {
Err(BfldError::TruncatedFrame { got, need }) => {
assert_eq!(got, 50);
assert_eq!(need, BFLD_HEADER_SIZE);
}
other => panic!("expected TruncatedFrame, got {other:?}"),
}
}
#[test]
fn frame_rejects_truncated_buffer_smaller_than_payload() {
let frame = BfldFrame::new(sample_header(), sample_payload());
let bytes = frame.to_bytes();
let truncated = &bytes[..bytes.len() - 100];
match BfldFrame::from_bytes(truncated) {
Err(BfldError::TruncatedFrame { got, need }) => {
assert_eq!(got, BFLD_HEADER_SIZE + 412);
assert_eq!(need, BFLD_HEADER_SIZE + 512);
}
other => panic!("expected TruncatedFrame, got {other:?}"),
}
}
#[test]
fn empty_payload_is_valid() {
let frame = BfldFrame::new(sample_header(), Vec::new());
let bytes = frame.to_bytes();
let parsed = BfldFrame::from_bytes(&bytes).expect("empty payload must roundtrip");
assert_eq!(parsed.payload.len(), 0);
assert_eq!({ parsed.header.payload_len }, 0);
// CRC of empty buffer is the CRC-32/ISO-HDLC identity 0x00000000.
assert_eq!({ parsed.header.payload_crc32 }, 0);
}