mirror of
https://github.com/ruvnet/RuView
synced 2026-08-06 19:51:43 +00:00
a3d26a4fad
Iter 36. Locks down the ADR-119 §2.1 forward-compat promise that
reserved flag bits round-trip unchanged through the parser. A future
protocol revision may light up bits 2 or 4..=15; today's parser
preserves them so a node running iter N can forward unknown bits to
a peer running iter N+M without losing information.
Added (in src/frame.rs::flags):
- pub const KNOWN_FLAGS_MASK = HAS_CSI_DELTA | PRIVACY_MODE | SELF_ONLY
(the three currently-named flags, occupying bits 0, 1, 3)
- pub const RESERVED_FLAGS_MASK = !KNOWN_FLAGS_MASK
(bit 2 + bits 4..=15 — every position not currently assigned)
- Docstrings reference ADR-119 §2.1 verbatim so a future reviewer
understands why the constants exist.
tests/reserved_flags.rs (8 named tests, all green, no_std-compatible
so they run in BOTH feature configs):
known_flags_mask_covers_exactly_three_named_flags
(count_ones() == 3 catches accidental flag additions that should
also update KNOWN_FLAGS_MASK)
reserved_and_known_masks_are_complementary
(mask | reserved == u16::MAX; mask & reserved == 0)
known_flags_do_not_overlap_with_each_other
(HAS_CSI_DELTA, PRIVACY_MODE, SELF_ONLY all on distinct bits)
header_preserves_reserved_flag_bits_through_round_trip
*** Headline test: set RESERVED_FLAGS_MASK on a header, serialize,
parse, verify the bits survived. ***
header_preserves_mixed_known_and_reserved_bits
(HAS_CSI_DELTA | PRIVACY_MODE | (1<<7) | (1<<14) — mixed case)
reserved_bits_do_not_collide_with_self_only_bit_3
(bit 2 is reserved but bit 3 is named — pins the asymmetry)
all_zero_flags_round_trip_cleanly
all_one_flags_round_trip_cleanly (stress: every bit set)
The new tests are no_std-compatible (no Vec / no serde) so they run
in both `cargo test --no-default-features` and default feature
configs. The no_default test count therefore jumps from 72 to 80.
ADR-124 status (iter step 0 sibling check):
- docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md unchanged
at 431 lines. SENSE-BRIDGE scope remains orthogonal.
ACs progressed:
- ADR-119 §2.1 "Reserved flag bits 2-15 lock in future-extension
order; any new bit assignment is a version bump." — the test now
enforces the OTHER half of this contract: a peer running the
future version can set a reserved bit and our parser will preserve
it through the round-trip rather than masking it off.
Test config:
- cargo test --no-default-features → 80 passed (72 + 8 no_std-compat)
- cargo test → 243 passed (235 + 8)
Out of scope (next iter target):
- PR-readiness pivot: witness bundle regeneration, CHANGELOG batch
across iters 1-36, AC closeout table for the PR description.
All in-crate ACs are now covered; remaining work is either
external-resource-gated (KIT BFId, Pi5/Nexmon) or PR-prep.
Co-Authored-By: claude-flow <ruv@ruv.net>
314 lines
13 KiB
Rust
314 lines
13 KiB
Rust
//! `BfldFrame` wire-format primitives. See ADR-119.
|
|
//!
|
|
//! The header is `#[repr(C, packed)]` so the wire byte order is fixed across
|
|
//! x86_64, aarch64, and xtensa-esp32s3 — and so the witness-bundle pattern
|
|
//! (ADR-028) extends cleanly to BFLD frames.
|
|
//!
|
|
//! 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;
|
|
|
|
/// Current `BfldFrame` major version. Bumps on any incompatible layout change.
|
|
pub const BFLD_VERSION: u16 = 1;
|
|
|
|
/// Size of the packed header in bytes. Asserted at compile time below.
|
|
///
|
|
/// Note: ADR-119 AC1 initially claimed 40 bytes — that was a counting error.
|
|
/// Actual packed layout sums to 86. Updated 2026-05-24 to match implementation.
|
|
pub const BFLD_HEADER_SIZE: usize = 86;
|
|
|
|
/// Flag bits in `BfldFrameHeader::flags`. See ADR-119 §2.1.
|
|
pub mod flags {
|
|
/// Payload contains an optional CSI delta section.
|
|
pub const HAS_CSI_DELTA: u16 = 1 << 0;
|
|
/// `privacy_mode` is engaged: identity-derived fields suppressed.
|
|
pub const PRIVACY_MODE: u16 = 1 << 1;
|
|
/// ESP32-S3 self-only adapter (ADR-123 §2.5): no `identity_risk_score`.
|
|
pub const SELF_ONLY: u16 = 1 << 3;
|
|
|
|
/// Bitmask covering every named flag this version of the crate knows
|
|
/// about. Useful for "did the wire form set any flags I don't recognize?"
|
|
/// forward-compat checks.
|
|
pub const KNOWN_FLAGS_MASK: u16 = HAS_CSI_DELTA | PRIVACY_MODE | SELF_ONLY;
|
|
|
|
/// Complement of [`KNOWN_FLAGS_MASK`] — every bit position not currently
|
|
/// assigned a meaning. Bits set in this mask MUST round-trip unchanged
|
|
/// per ADR-119 §2.1 ("Reserved flag bits 2-15 lock in future-extension
|
|
/// order; any new bit assignment is a version bump"). A future protocol
|
|
/// revision may light these up; today's parser preserves them so a node
|
|
/// running iter N can forward unknown bits to a peer running iter N+M
|
|
/// without losing information.
|
|
pub const RESERVED_FLAGS_MASK: u16 = !KNOWN_FLAGS_MASK;
|
|
}
|
|
|
|
/// On-the-wire BFLD frame header. 86 bytes, little-endian, packed.
|
|
#[repr(C, packed)]
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
pub struct BfldFrameHeader {
|
|
/// Must equal [`BFLD_MAGIC`].
|
|
pub magic: u32,
|
|
/// Layout version. Currently [`BFLD_VERSION`].
|
|
pub version: u16,
|
|
/// Flag bits — see [`flags`].
|
|
pub flags: u16,
|
|
/// Monotonic capture-clock timestamp in nanoseconds.
|
|
pub timestamp_ns: u64,
|
|
/// BLAKE3-keyed(site_salt, ap_mac)[0..16] — ADR-120 §2.3.
|
|
pub ap_hash: [u8; 16],
|
|
/// BLAKE3-keyed(site_salt ‖ day_epoch, sta_mac)[0..16] — daily-rotated.
|
|
pub sta_hash: [u8; 16],
|
|
/// Ephemeral session identifier, rotated on capture-session boundary.
|
|
pub session_id: [u8; 16],
|
|
/// 802.11 channel number.
|
|
pub channel: u16,
|
|
/// Channel bandwidth in MHz: 20 / 40 / 80 / 160.
|
|
pub bandwidth_mhz: u16,
|
|
/// Received signal strength in dBm.
|
|
pub rssi_dbm: i16,
|
|
/// Noise floor in dBm.
|
|
pub noise_floor_dbm: i16,
|
|
/// Number of OFDM subcarriers represented.
|
|
pub n_subcarriers: u16,
|
|
/// Number of transmit antennas.
|
|
pub n_tx: u8,
|
|
/// Number of receive antennas.
|
|
pub n_rx: u8,
|
|
/// 0=f32, 1=i16, 2=i8, 3=packed (4-bit nibbles).
|
|
pub quantization: u8,
|
|
/// `PrivacyClass` byte — see ADR-120 §2.1.
|
|
pub privacy_class: u8,
|
|
/// Length of the payload section in bytes.
|
|
pub payload_len: u32,
|
|
/// CRC-32/ISO-HDLC over payload bytes only.
|
|
pub payload_crc32: u32,
|
|
}
|
|
|
|
const_assert_eq!(core::mem::size_of::<BfldFrameHeader>(), BFLD_HEADER_SIZE);
|
|
|
|
impl BfldFrameHeader {
|
|
/// Build a header with `magic` and `version` already set correctly.
|
|
/// All other fields default to zero — caller fills them in.
|
|
#[must_use]
|
|
pub fn empty() -> Self {
|
|
Self {
|
|
magic: BFLD_MAGIC,
|
|
version: BFLD_VERSION,
|
|
..Self::default()
|
|
}
|
|
}
|
|
|
|
/// Serialize to canonical little-endian wire form (86 bytes).
|
|
#[must_use]
|
|
#[allow(clippy::too_many_lines)]
|
|
pub fn to_le_bytes(&self) -> [u8; BFLD_HEADER_SIZE] {
|
|
let mut buf = [0u8; BFLD_HEADER_SIZE];
|
|
let mut o = 0usize;
|
|
|
|
// Copy locally to dodge `#[repr(packed)]` unaligned-borrow warnings.
|
|
let magic = self.magic;
|
|
let version = self.version;
|
|
let flags = self.flags;
|
|
let timestamp_ns = self.timestamp_ns;
|
|
let channel = self.channel;
|
|
let bandwidth_mhz = self.bandwidth_mhz;
|
|
let rssi_dbm = self.rssi_dbm;
|
|
let noise_floor_dbm = self.noise_floor_dbm;
|
|
let n_subcarriers = self.n_subcarriers;
|
|
let payload_len = self.payload_len;
|
|
let payload_crc32 = self.payload_crc32;
|
|
|
|
buf[o..o + 4].copy_from_slice(&magic.to_le_bytes()); o += 4;
|
|
buf[o..o + 2].copy_from_slice(&version.to_le_bytes()); o += 2;
|
|
buf[o..o + 2].copy_from_slice(&flags.to_le_bytes()); o += 2;
|
|
buf[o..o + 8].copy_from_slice(×tamp_ns.to_le_bytes()); o += 8;
|
|
buf[o..o + 16].copy_from_slice(&self.ap_hash); o += 16;
|
|
buf[o..o + 16].copy_from_slice(&self.sta_hash); o += 16;
|
|
buf[o..o + 16].copy_from_slice(&self.session_id); o += 16;
|
|
buf[o..o + 2].copy_from_slice(&channel.to_le_bytes()); o += 2;
|
|
buf[o..o + 2].copy_from_slice(&bandwidth_mhz.to_le_bytes()); o += 2;
|
|
buf[o..o + 2].copy_from_slice(&rssi_dbm.to_le_bytes()); o += 2;
|
|
buf[o..o + 2].copy_from_slice(&noise_floor_dbm.to_le_bytes()); o += 2;
|
|
buf[o..o + 2].copy_from_slice(&n_subcarriers.to_le_bytes()); o += 2;
|
|
buf[o] = self.n_tx; o += 1;
|
|
buf[o] = self.n_rx; o += 1;
|
|
buf[o] = self.quantization; o += 1;
|
|
buf[o] = self.privacy_class; o += 1;
|
|
buf[o..o + 4].copy_from_slice(&payload_len.to_le_bytes()); o += 4;
|
|
buf[o..o + 4].copy_from_slice(&payload_crc32.to_le_bytes()); o += 4;
|
|
|
|
debug_assert_eq!(o, BFLD_HEADER_SIZE);
|
|
buf
|
|
}
|
|
|
|
/// Parse from canonical little-endian wire form.
|
|
///
|
|
/// Returns [`BfldError::InvalidMagic`] if the magic prefix is wrong, and
|
|
/// [`BfldError::UnsupportedVersion`] for a version this build cannot decode.
|
|
/// Field-level validation (CRC, payload_len bounds) is deliberately *not*
|
|
/// performed here — that lives at the frame-level parser.
|
|
pub fn from_le_bytes(bytes: &[u8; BFLD_HEADER_SIZE]) -> Result<Self, BfldError> {
|
|
let magic = u32::from_le_bytes(bytes[0..4].try_into().unwrap());
|
|
if magic != BFLD_MAGIC {
|
|
return Err(BfldError::InvalidMagic(magic));
|
|
}
|
|
let version = u16::from_le_bytes(bytes[4..6].try_into().unwrap());
|
|
if version != BFLD_VERSION {
|
|
return Err(BfldError::UnsupportedVersion(version));
|
|
}
|
|
|
|
let mut h = Self {
|
|
magic,
|
|
version,
|
|
flags: u16::from_le_bytes(bytes[6..8].try_into().unwrap()),
|
|
timestamp_ns: u64::from_le_bytes(bytes[8..16].try_into().unwrap()),
|
|
ap_hash: [0; 16],
|
|
sta_hash: [0; 16],
|
|
session_id: [0; 16],
|
|
channel: u16::from_le_bytes(bytes[64..66].try_into().unwrap()),
|
|
bandwidth_mhz: u16::from_le_bytes(bytes[66..68].try_into().unwrap()),
|
|
rssi_dbm: i16::from_le_bytes(bytes[68..70].try_into().unwrap()),
|
|
noise_floor_dbm: i16::from_le_bytes(bytes[70..72].try_into().unwrap()),
|
|
n_subcarriers: u16::from_le_bytes(bytes[72..74].try_into().unwrap()),
|
|
n_tx: bytes[74],
|
|
n_rx: bytes[75],
|
|
quantization: bytes[76],
|
|
privacy_class: bytes[77],
|
|
payload_len: u32::from_le_bytes(bytes[78..82].try_into().unwrap()),
|
|
payload_crc32: u32::from_le_bytes(bytes[82..86].try_into().unwrap()),
|
|
};
|
|
h.ap_hash.copy_from_slice(&bytes[16..32]);
|
|
h.sta_hash.copy_from_slice(&bytes[32..48]);
|
|
h.session_id.copy_from_slice(&bytes[48..64]);
|
|
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 }
|
|
}
|
|
|
|
/// Construct a frame from a typed `BfldPayload`. The header `flags`
|
|
/// `HAS_CSI_DELTA` bit is auto-synced from `payload.csi_delta.is_some()`,
|
|
/// then the payload is serialized via [`crate::payload::BfldPayload::to_bytes`]
|
|
/// and the resulting bytes feed [`BfldFrame::new`]. The CRC therefore covers
|
|
/// the **section-prefixed** wire bytes per ADR-119 §2.2.
|
|
#[must_use]
|
|
pub fn from_payload(
|
|
mut header: BfldFrameHeader,
|
|
payload: &crate::payload::BfldPayload,
|
|
) -> Self {
|
|
let include_csi_delta = payload.csi_delta.is_some();
|
|
if include_csi_delta {
|
|
header.flags |= flags::HAS_CSI_DELTA;
|
|
} else {
|
|
header.flags &= !flags::HAS_CSI_DELTA;
|
|
}
|
|
let bytes = payload.to_bytes(include_csi_delta);
|
|
Self::new(header, bytes)
|
|
}
|
|
|
|
/// Parse the opaque payload bytes back into a typed [`crate::payload::BfldPayload`].
|
|
/// Consults `header.flags & HAS_CSI_DELTA` so the parser matches the
|
|
/// originating encoder's framing.
|
|
pub fn parse_payload(&self) -> Result<crate::payload::BfldPayload, BfldError> {
|
|
let expect_csi_delta = (self.header.flags & flags::HAS_CSI_DELTA) != 0;
|
|
crate::payload::BfldPayload::from_bytes(&self.payload, expect_csi_delta)
|
|
}
|
|
|
|
/// 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 })
|
|
}
|
|
}
|
|
|