mirror of
https://github.com/ruvnet/RuView
synced 2026-07-26 18:01:48 +00:00
a369fbe66e
* fix(bfld): route process_to_frame payload through PrivacyGate (ADR-141 privacy bypass) BfldPipeline::process_to_frame stamped the frame header with the active privacy class but serialized the caller-supplied BfldPayload UNCHANGED via BfldFrame::from_payload. This let a frame labeled Anonymous(2) or Restricted(3) carry the full identity-leaky compressed_angle_matrix (+ amplitude/phase proxies, csi_delta) that PrivacyGate::demote is documented and tested (privacy_gate_demote.rs) to strip at exactly those classes. A NetworkSink accepts class >= Derived(1), so such a frame would publish the beamforming angle matrix — the identity surface — across the node boundary despite its restrictive class byte. The class byte lied about payload content. Fix: after building the frame at the active class, apply PrivacyGate::demote to the same class. demote() strips sections by target-class threshold (independent of any class transition), so a same-class demote performs no class change but brings the payload into policy compliance. Research classes (Raw/Derived) keep the full payload — demote is a no-op there. Pinned by three fails-on-old tests in pipeline_to_frame.rs: - process_to_frame_at_anonymous_strips_identity_leaky_sections (FAILED pre-fix) - process_to_frame_in_privacy_mode_strips_amplitude_and_phase (FAILED pre-fix) - process_to_frame_at_derived_preserves_full_payload (guards against over-strip) The pre-existing round-trip test is updated to assert the gated payload. Co-Authored-By: claude-flow <ruv@ruv.net> * fix(bfld): JSON-escape zone_id in MQTT state-topic payload render_events emitted the zone_activity payload as format!("\"{zone}\"") with no escaping, while ha_discovery.rs already escapes operator-controlled strings via push_str_field. A zone name containing a double-quote or backslash therefore produced malformed / injectable JSON on the state topic that Home Assistant parses (e.g. zone `a"b` -> payload `"a"b"`). Fix: add json_string_literal() mirroring ha_discovery's escaping (", \, \n, \r, \t, control chars) and use it for the zone payload. Value-identical for normal zone names (living_room etc.). Pinned by zone_payload_escapes_json_metacharacters (FAILED pre-fix); the existing zone_payload_is_json_string_with_quotes still passes unchanged. Co-Authored-By: claude-flow <ruv@ruv.net> * docs(adr-141): record bfld privacy+security review findings + CHANGELOG Document the two fixed bugs (process_to_frame privacy-bypass; zone_id JSON injection) and the dimensions confirmed clean (event-field gating, witness/hash framing, fail-closed) in ADR-141, plus CHANGELOG [Unreleased] Security/Fixed entries. Co-Authored-By: claude-flow <ruv@ruv.net>
171 lines
5.5 KiB
Rust
171 lines
5.5 KiB
Rust
//! Acceptance tests for ADR-122 §2.2 — MQTT topic routing.
|
|
|
|
#![cfg(feature = "std")]
|
|
|
|
use wifi_densepose_bfld::{render_events, BfldEvent, PrivacyClass, TopicMessage};
|
|
|
|
fn sample_event(class: PrivacyClass, with_zone: bool) -> BfldEvent {
|
|
BfldEvent::with_privacy_gating(
|
|
"seed-01".into(),
|
|
1_700_000_000_000_000_000,
|
|
true,
|
|
0.72,
|
|
2,
|
|
0.91,
|
|
if with_zone { Some("living_room".into()) } else { None },
|
|
class,
|
|
Some(0.34),
|
|
Some([0xAB; 32]),
|
|
)
|
|
}
|
|
|
|
fn topics_for(class: PrivacyClass) -> Vec<String> {
|
|
render_events(&sample_event(class, true))
|
|
.into_iter()
|
|
.map(|m| m.topic)
|
|
.collect()
|
|
}
|
|
|
|
// --- topic shape ---------------------------------------------------------
|
|
|
|
#[test]
|
|
fn topic_format_is_ruview_node_bfld_entity_state() {
|
|
let t = TopicMessage::ruview_topic("seed-42", "presence");
|
|
assert_eq!(t, "ruview/seed-42/bfld/presence/state");
|
|
}
|
|
|
|
#[test]
|
|
fn anonymous_class_publishes_six_topics_with_zone() {
|
|
let topics = topics_for(PrivacyClass::Anonymous);
|
|
assert_eq!(topics.len(), 6, "got {topics:?}");
|
|
let expected: Vec<&str> = vec![
|
|
"ruview/seed-01/bfld/presence/state",
|
|
"ruview/seed-01/bfld/motion/state",
|
|
"ruview/seed-01/bfld/person_count/state",
|
|
"ruview/seed-01/bfld/confidence/state",
|
|
"ruview/seed-01/bfld/zone_activity/state",
|
|
"ruview/seed-01/bfld/identity_risk/state",
|
|
];
|
|
for t in &expected {
|
|
assert!(topics.contains(&t.to_string()), "missing topic {t}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn anonymous_class_without_zone_omits_zone_activity_topic() {
|
|
let topics: Vec<String> = render_events(&sample_event(PrivacyClass::Anonymous, false))
|
|
.into_iter()
|
|
.map(|m| m.topic)
|
|
.collect();
|
|
assert!(!topics.iter().any(|t| t.contains("zone_activity")));
|
|
assert_eq!(topics.len(), 5);
|
|
}
|
|
|
|
// --- class-gated routing -------------------------------------------------
|
|
|
|
#[test]
|
|
fn restricted_class_omits_identity_risk_topic() {
|
|
let topics = topics_for(PrivacyClass::Restricted);
|
|
assert!(
|
|
!topics.iter().any(|t| t.contains("identity_risk")),
|
|
"Restricted (class 3) must NOT publish identity_risk: {topics:?}",
|
|
);
|
|
// Other entities still present.
|
|
assert!(topics.iter().any(|t| t.contains("presence")));
|
|
assert!(topics.iter().any(|t| t.contains("motion")));
|
|
}
|
|
|
|
#[test]
|
|
fn raw_and_derived_classes_publish_nothing() {
|
|
// Raw (0) and Derived (1) are local-only / research — never on the
|
|
// public topic tree.
|
|
let raw = render_events(&sample_event(PrivacyClass::Raw, true));
|
|
assert!(raw.is_empty(), "Raw class must publish nothing");
|
|
let derived = render_events(&sample_event(PrivacyClass::Derived, true));
|
|
assert!(derived.is_empty(), "Derived class must publish nothing");
|
|
}
|
|
|
|
// --- payload shape -------------------------------------------------------
|
|
|
|
#[test]
|
|
fn presence_payload_is_lowercase_json_bool() {
|
|
let msgs = render_events(&sample_event(PrivacyClass::Anonymous, false));
|
|
let pres = msgs
|
|
.iter()
|
|
.find(|m| m.topic.contains("presence"))
|
|
.expect("presence topic");
|
|
assert_eq!(pres.payload, "true");
|
|
}
|
|
|
|
#[test]
|
|
fn motion_payload_is_fixed_precision_decimal() {
|
|
let msgs = render_events(&sample_event(PrivacyClass::Anonymous, false));
|
|
let motion = msgs
|
|
.iter()
|
|
.find(|m| m.topic.contains("motion"))
|
|
.expect("motion topic");
|
|
assert_eq!(motion.payload, "0.720000");
|
|
}
|
|
|
|
#[test]
|
|
fn person_count_payload_is_bare_integer() {
|
|
let msgs = render_events(&sample_event(PrivacyClass::Anonymous, false));
|
|
let pc = msgs
|
|
.iter()
|
|
.find(|m| m.topic.contains("person_count"))
|
|
.expect("person_count topic");
|
|
assert_eq!(pc.payload, "2");
|
|
}
|
|
|
|
#[test]
|
|
fn zone_payload_is_json_string_with_quotes() {
|
|
let msgs = render_events(&sample_event(PrivacyClass::Anonymous, true));
|
|
let zone = msgs
|
|
.iter()
|
|
.find(|m| m.topic.contains("zone_activity"))
|
|
.expect("zone_activity topic");
|
|
assert_eq!(zone.payload, "\"living_room\"");
|
|
}
|
|
|
|
#[test]
|
|
fn zone_payload_escapes_json_metacharacters() {
|
|
// A zone name containing a double-quote or backslash must not break out of
|
|
// the JSON string literal it is emitted into. ha_discovery.rs already
|
|
// escapes operator-controlled strings via push_str_field; render_events
|
|
// must do the same for parity so the state-topic payload is always valid
|
|
// JSON that Home Assistant can parse.
|
|
let ev = BfldEvent::with_privacy_gating(
|
|
"seed-01".into(),
|
|
0,
|
|
true,
|
|
0.1,
|
|
1,
|
|
0.9,
|
|
Some(r#"living"room\back"#.into()),
|
|
PrivacyClass::Anonymous,
|
|
None,
|
|
None,
|
|
);
|
|
let msgs = render_events(&ev);
|
|
let zone = msgs
|
|
.iter()
|
|
.find(|m| m.topic.contains("zone_activity"))
|
|
.expect("zone_activity topic");
|
|
// Expected: the inner quote and backslash are backslash-escaped, wrapped in
|
|
// one pair of unescaped delimiter quotes -> a single valid JSON string.
|
|
assert_eq!(zone.payload, r#""living\"room\\back""#);
|
|
// And it must parse as JSON back to the original zone string.
|
|
let parsed: String = serde_json::from_str(&zone.payload).expect("valid JSON string");
|
|
assert_eq!(parsed, r#"living"room\back"#);
|
|
}
|
|
|
|
#[test]
|
|
fn identity_risk_payload_is_fixed_precision_decimal() {
|
|
let msgs = render_events(&sample_event(PrivacyClass::Anonymous, false));
|
|
let risk = msgs
|
|
.iter()
|
|
.find(|m| m.topic.contains("identity_risk"))
|
|
.expect("identity_risk topic");
|
|
assert_eq!(risk.payload, "0.340000");
|
|
}
|