From 775661b2e8815395c6617a5d5a12be60218c10bd Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 24 May 2026 13:58:26 -0400 Subject: [PATCH] =?UTF-8?q?feat(adr-118/p1.4):=20BfldFrame=20(header=20+?= =?UTF-8?q?=20payload=20+=20CRC32)=20=E2=80=94=2024/24=20GREEN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 } (gated on `std`) * BfldFrame::new(header, payload) — auto-syncs payload_len + payload_crc32 * BfldFrame::to_bytes() -> Vec — header LE bytes ‖ payload * BfldFrame::from_bytes(&[u8]) -> Result - 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 --- v2/Cargo.lock | 16 +++ v2/crates/wifi-densepose-bfld/Cargo.toml | 1 + v2/crates/wifi-densepose-bfld/src/frame.rs | 94 ++++++++++++++++ v2/crates/wifi-densepose-bfld/src/lib.rs | 23 +++- .../tests/frame_roundtrip.rs | 106 ++++++++++++++++++ 5 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 v2/crates/wifi-densepose-bfld/tests/frame_roundtrip.rs diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 8f23b6b6..a51d4a63 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -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", diff --git a/v2/crates/wifi-densepose-bfld/Cargo.toml b/v2/crates/wifi-densepose-bfld/Cargo.toml index 7207a1d0..e187a6ff 100644 --- a/v2/crates/wifi-densepose-bfld/Cargo.toml +++ b/v2/crates/wifi-densepose-bfld/Cargo.toml @@ -21,6 +21,7 @@ soul-signature = [] [dependencies] thiserror.workspace = true static_assertions = "1.1" +crc = "3" [dev-dependencies] proptest.workspace = true diff --git a/v2/crates/wifi-densepose-bfld/src/frame.rs b/v2/crates/wifi-densepose-bfld/src/frame.rs index cdc68f7f..6fedeb24 100644 --- a/v2/crates/wifi-densepose-bfld/src/frame.rs +++ b/v2/crates/wifi-densepose-bfld/src/frame.rs @@ -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 = crc::Crc::::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`). 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, +} + +#[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) -> 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 { + 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 { + 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 }) + } +} + diff --git a/v2/crates/wifi-densepose-bfld/src/lib.rs b/v2/crates/wifi-densepose-bfld/src/lib.rs index 6a0dc7bd..2ad9cc60 100644 --- a/v2/crates/wifi-densepose-bfld/src/lib.rs +++ b/v2/crates/wifi-densepose-bfld/src/lib.rs @@ -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, + }, } diff --git a/v2/crates/wifi-densepose-bfld/tests/frame_roundtrip.rs b/v2/crates/wifi-densepose-bfld/tests/frame_roundtrip.rs new file mode 100644 index 00000000..e4c3814b --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/frame_roundtrip.rs @@ -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`). + +#![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 { + // 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); +}