mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
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:
Generated
+16
@@ -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",
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user