From eb996294fb14b336a716f8dc961acb8de73a91c7 Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 24 May 2026 13:43:05 -0400 Subject: [PATCH] feat(adr-118/p1.3): Sink marker traits + PrivacyClass::try_from (17/17 GREEN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iter 3. Lands the structural enforcement of ADR-118 invariant I1 ("raw BFI never exits the node") and ADR-120 §2.2 ("Sink marker types"). Added: - src/sink.rs: * Sink trait with MIN_CLASS and KIND associated constants * LocalSink (Raw OK), NetworkSink (Derived+ only), MatterSink (Anonymous+) * Hierarchy: MatterSink: NetworkSink (every Matter sink is a NetworkSink) * check_class(class) runtime gate, returns PrivacyViolation{reason:KIND} * Zero-sized kind tags: LocalKind / NetworkKind / MatterKind - PrivacyClass::as_u8() const helper - TryFrom for PrivacyClass (0..=3 valid; 4..=255 → InvalidPrivacyClass) - BfldError::InvalidPrivacyClass(u8) variant tests/sink_enforcement.rs adds 8 tests: privacy_class_try_from_accepts_all_four_valid_bytes privacy_class_try_from_rejects_out_of_range_bytes privacy_class_byte_roundtrip_is_stable local_sink_accepts_all_classes network_sink_rejects_raw_frames network_sink_accepts_derived_anonymous_restricted matter_sink_rejects_raw_and_derived matter_sink_accepts_anonymous_and_restricted Out of scope (next iter): - BfldFrame (header + payload + section length-prefixes + CRC32 over payload) — needs the `crc` crate dependency. - PrivacyGate::demote(frame, target_class) transformer (ADR-120 §2.4). - compile-fail test that proves a sink-trait bound rejects Raw at compile time — needs `trybuild` integration; deferred to a separate iter. cargo test -p wifi-densepose-bfld --no-default-features → 17 passed, 0 failed (3 frame_header_size + 6 header_roundtrip + 8 sink_enforcement) Co-Authored-By: claude-flow --- v2/crates/wifi-densepose-bfld/src/lib.rs | 28 +++++- v2/crates/wifi-densepose-bfld/src/sink.rs | 92 ++++++++++++++++++ .../tests/sink_enforcement.rs | 93 +++++++++++++++++++ 3 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 v2/crates/wifi-densepose-bfld/src/sink.rs create mode 100644 v2/crates/wifi-densepose-bfld/tests/sink_enforcement.rs diff --git a/v2/crates/wifi-densepose-bfld/src/lib.rs b/v2/crates/wifi-densepose-bfld/src/lib.rs index 79afd441..6a0dc7bd 100644 --- a/v2/crates/wifi-densepose-bfld/src/lib.rs +++ b/v2/crates/wifi-densepose-bfld/src/lib.rs @@ -9,13 +9,15 @@ //! - **I2**: Identity embedding is in-RAM-only. //! - **I3**: Cross-site identity correlation is cryptographically impossible. //! -//! Status: P1 scaffold — frame format only. P2–P6 land in subsequent commits. +//! Status: P1 in progress — frame format + sink marker traits. P2–P6 follow. #![cfg_attr(not(feature = "std"), no_std)] pub mod frame; +pub mod sink; pub use frame::{BfldFrameHeader, BFLD_MAGIC, BFLD_VERSION, BFLD_HEADER_SIZE}; +pub use sink::{check_class, LocalSink, MatterSink, NetworkSink, Sink}; /// Privacy classification carried in every `BfldFrame`. See ADR-120 §2.1. #[repr(u8)] @@ -47,6 +49,26 @@ impl PrivacyClass { pub const fn allows_matter(self) -> bool { matches!(self, Self::Anonymous | Self::Restricted) } + + /// Returns the byte value of this class (0..=3) for serialization. + #[must_use] + pub const fn as_u8(self) -> u8 { + self as u8 + } +} + +impl TryFrom for PrivacyClass { + type Error = BfldError; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Self::Raw), + 1 => Ok(Self::Derived), + 2 => Ok(Self::Anonymous), + 3 => Ok(Self::Restricted), + other => Err(BfldError::InvalidPrivacyClass(other)), + } + } } /// Errors produced by BFLD operations. @@ -68,4 +90,8 @@ pub enum BfldError { /// Enforces structural invariant I1. #[error("privacy violation: {reason}")] PrivacyViolation { reason: &'static str }, + + /// Byte value did not map to any defined `PrivacyClass` (0..=3). + #[error("invalid PrivacyClass byte: {0}")] + InvalidPrivacyClass(u8), } diff --git a/v2/crates/wifi-densepose-bfld/src/sink.rs b/v2/crates/wifi-densepose-bfld/src/sink.rs new file mode 100644 index 00000000..19e213e1 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/src/sink.rs @@ -0,0 +1,92 @@ +//! Sink marker traits — structural enforcement of invariant I1. +//! +//! Every output destination (memory buffer, MQTT topic, Matter cluster) implements +//! exactly one of [`LocalSink`], [`NetworkSink`], or [`MatterSink`]. The associated +//! constant [`Sink::MIN_CLASS`] declares the lowest `PrivacyClass` value that sink +//! is willing to accept; the runtime gate [`check_class`] enforces this on every +//! publish. +//! +//! Mapping (ADR-120 §2.2, ADR-122 §2.4): +//! +//! | Sink trait | `MIN_CLASS` | Accepts classes | +//! |---------------|----------------------|-----------------| +//! | `LocalSink` | `PrivacyClass::Raw` | 0, 1, 2, 3 | +//! | `NetworkSink` | `PrivacyClass::Derived` | 1, 2, 3 | +//! | `MatterSink` | `PrivacyClass::Anonymous` | 2, 3 | +//! +//! `MatterSink: NetworkSink` — every Matter sink is also a network sink. + +use crate::{BfldError, PrivacyClass}; + +/// Base sink trait. Every sink type declares the minimum `PrivacyClass` it accepts. +pub trait Sink { + /// Lowest privacy class (highest information density) this sink will publish. + const MIN_CLASS: PrivacyClass; + /// Human-readable sink kind, used in `BfldError::PrivacyViolation` messages. + const KIND: &'static str; +} + +/// Marker for sinks that stay on the originating node (memory, in-RAM channel, +/// local file with explicit operator opt-in). Accepts every class including `Raw`. +pub trait LocalSink: Sink {} + +/// Marker for sinks that cross the node boundary (MQTT, HTTP, gRPC). Rejects +/// `Raw` frames by structural invariant I1. +pub trait NetworkSink: Sink {} + +/// Marker for sinks that bridge into the Matter cluster surface. Rejects `Raw` +/// and `Derived`; the `cog-ha-matter` boundary filter consumes only classes 2/3. +pub trait MatterSink: NetworkSink {} + +/// Runtime gate. Returns `Ok(())` if `class` is acceptable for `S`, otherwise +/// returns `BfldError::PrivacyViolation` with the offending sink kind. +/// +/// Class numerical order *is* meaningful here: a sink that accepts `MIN_CLASS` +/// also accepts every higher-numbered class (less identity content). The check +/// is therefore a simple `>=` on the byte representation. +pub fn check_class(class: PrivacyClass) -> Result<(), BfldError> { + if class.as_u8() >= S::MIN_CLASS.as_u8() { + Ok(()) + } else { + Err(BfldError::PrivacyViolation { + reason: S::KIND, + }) + } +} + +// --- Default sink types ---------------------------------------------------- +// +// Concrete sinks live in downstream crates (emitter.rs, mqtt.rs, the cog-ha-matter +// Matter bridge). These three "kind tags" are convenient zero-sized stand-ins for +// unit tests and for the privacy_gate compile-time tables. + +/// Zero-sized tag: a local in-memory ring buffer or file sink. +#[derive(Debug, Clone, Copy, Default)] +pub struct LocalKind; + +impl Sink for LocalKind { + const MIN_CLASS: PrivacyClass = PrivacyClass::Raw; + const KIND: &'static str = "LocalKind"; +} +impl LocalSink for LocalKind {} + +/// Zero-sized tag: a generic network sink (MQTT, HTTP, gRPC). +#[derive(Debug, Clone, Copy, Default)] +pub struct NetworkKind; + +impl Sink for NetworkKind { + const MIN_CLASS: PrivacyClass = PrivacyClass::Derived; + const KIND: &'static str = "NetworkKind"; +} +impl NetworkSink for NetworkKind {} + +/// Zero-sized tag: the Matter cluster boundary in `cog-ha-matter`. +#[derive(Debug, Clone, Copy, Default)] +pub struct MatterKind; + +impl Sink for MatterKind { + const MIN_CLASS: PrivacyClass = PrivacyClass::Anonymous; + const KIND: &'static str = "MatterKind"; +} +impl NetworkSink for MatterKind {} +impl MatterSink for MatterKind {} diff --git a/v2/crates/wifi-densepose-bfld/tests/sink_enforcement.rs b/v2/crates/wifi-densepose-bfld/tests/sink_enforcement.rs new file mode 100644 index 00000000..1ba8ba97 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/sink_enforcement.rs @@ -0,0 +1,93 @@ +//! Acceptance tests for ADR-120 §2.2 sink marker enforcement (invariant I1). + +use wifi_densepose_bfld::sink::{LocalKind, MatterKind, NetworkKind}; +use wifi_densepose_bfld::{check_class, BfldError, PrivacyClass}; + +// --- PrivacyClass::try_from ---------------------------------------------- + +#[test] +fn privacy_class_try_from_accepts_all_four_valid_bytes() { + assert_eq!(PrivacyClass::try_from(0).unwrap(), PrivacyClass::Raw); + assert_eq!(PrivacyClass::try_from(1).unwrap(), PrivacyClass::Derived); + assert_eq!(PrivacyClass::try_from(2).unwrap(), PrivacyClass::Anonymous); + assert_eq!(PrivacyClass::try_from(3).unwrap(), PrivacyClass::Restricted); +} + +#[test] +fn privacy_class_try_from_rejects_out_of_range_bytes() { + for b in [4u8, 5, 7, 17, 42, 100, 200, 255] { + match PrivacyClass::try_from(b) { + Err(BfldError::InvalidPrivacyClass(got)) => assert_eq!(got, b), + other => panic!("expected InvalidPrivacyClass({b}), got {other:?}"), + } + } +} + +#[test] +fn privacy_class_byte_roundtrip_is_stable() { + for c in [ + PrivacyClass::Raw, + PrivacyClass::Derived, + PrivacyClass::Anonymous, + PrivacyClass::Restricted, + ] { + assert_eq!(PrivacyClass::try_from(c.as_u8()).unwrap(), c); + } +} + +// --- LocalSink accepts everything --------------------------------------- + +#[test] +fn local_sink_accepts_all_classes() { + for c in [ + PrivacyClass::Raw, + PrivacyClass::Derived, + PrivacyClass::Anonymous, + PrivacyClass::Restricted, + ] { + check_class::(c).expect("LocalSink must accept every class"); + } +} + +// --- NetworkSink rejects Raw, accepts the rest -------------------------- + +#[test] +fn network_sink_rejects_raw_frames() { + let err = check_class::(PrivacyClass::Raw).unwrap_err(); + match err { + BfldError::PrivacyViolation { reason } => assert_eq!(reason, "NetworkKind"), + other => panic!("expected PrivacyViolation, got {other:?}"), + } +} + +#[test] +fn network_sink_accepts_derived_anonymous_restricted() { + for c in [ + PrivacyClass::Derived, + PrivacyClass::Anonymous, + PrivacyClass::Restricted, + ] { + check_class::(c) + .expect("NetworkSink must accept Derived/Anonymous/Restricted"); + } +} + +// --- MatterSink rejects Raw and Derived --------------------------------- + +#[test] +fn matter_sink_rejects_raw_and_derived() { + for c in [PrivacyClass::Raw, PrivacyClass::Derived] { + let err = check_class::(c).unwrap_err(); + match err { + BfldError::PrivacyViolation { reason } => assert_eq!(reason, "MatterKind"), + other => panic!("expected PrivacyViolation for {c:?}, got {other:?}"), + } + } +} + +#[test] +fn matter_sink_accepts_anonymous_and_restricted() { + for c in [PrivacyClass::Anonymous, PrivacyClass::Restricted] { + check_class::(c).expect("MatterSink must accept anonymous + restricted"); + } +}