feat(adr-118/p1.3): Sink marker traits + PrivacyClass::try_from (17/17 GREEN)
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<S>(class) runtime gate, returns PrivacyViolation{reason:KIND}
* Zero-sized kind tags: LocalKind / NetworkKind / MatterKind
- PrivacyClass::as_u8() const helper
- TryFrom<u8> 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 <ruv@ruv.net>
This commit is contained in:
parent
be4dad6ede
commit
eb996294fb
|
|
@ -9,13 +9,15 @@
|
||||||
//! - **I2**: Identity embedding is in-RAM-only.
|
//! - **I2**: Identity embedding is in-RAM-only.
|
||||||
//! - **I3**: Cross-site identity correlation is cryptographically impossible.
|
//! - **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)]
|
#![cfg_attr(not(feature = "std"), no_std)]
|
||||||
|
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
|
pub mod sink;
|
||||||
|
|
||||||
pub use frame::{BfldFrameHeader, BFLD_MAGIC, BFLD_VERSION, BFLD_HEADER_SIZE};
|
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.
|
/// Privacy classification carried in every `BfldFrame`. See ADR-120 §2.1.
|
||||||
#[repr(u8)]
|
#[repr(u8)]
|
||||||
|
|
@ -47,6 +49,26 @@ impl PrivacyClass {
|
||||||
pub const fn allows_matter(self) -> bool {
|
pub const fn allows_matter(self) -> bool {
|
||||||
matches!(self, Self::Anonymous | Self::Restricted)
|
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<u8> for PrivacyClass {
|
||||||
|
type Error = BfldError;
|
||||||
|
|
||||||
|
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||||
|
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.
|
/// Errors produced by BFLD operations.
|
||||||
|
|
@ -68,4 +90,8 @@ pub enum BfldError {
|
||||||
/// Enforces structural invariant I1.
|
/// Enforces structural invariant I1.
|
||||||
#[error("privacy violation: {reason}")]
|
#[error("privacy violation: {reason}")]
|
||||||
PrivacyViolation { reason: &'static str },
|
PrivacyViolation { reason: &'static str },
|
||||||
|
|
||||||
|
/// Byte value did not map to any defined `PrivacyClass` (0..=3).
|
||||||
|
#[error("invalid PrivacyClass byte: {0}")]
|
||||||
|
InvalidPrivacyClass(u8),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<S: Sink>(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 {}
|
||||||
|
|
@ -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::<LocalKind>(c).expect("LocalSink must accept every class");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NetworkSink rejects Raw, accepts the rest --------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn network_sink_rejects_raw_frames() {
|
||||||
|
let err = check_class::<NetworkKind>(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::<NetworkKind>(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::<MatterKind>(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::<MatterKind>(c).expect("MatterSink must accept anonymous + restricted");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue