diff --git a/docs/adr/ADR-115-home-assistant-integration.md b/docs/adr/ADR-115-home-assistant-integration.md index 42d88429..b7a886ad 100644 --- a/docs/adr/ADR-115-home-assistant-integration.md +++ b/docs/adr/ADR-115-home-assistant-integration.md @@ -2,12 +2,12 @@ | Field | Value | |-------|-------| -| **Status** | Proposed | +| **Status** | **Accepted** (MQTT track P1–P7 + P8a + P9 + P10 shipped 2026-05-23 in PR #778, 410 lib tests, witness bundle VERIFIED) / **Proposed** (Matter SDK wiring P8b deferred to v0.7.1 per §9.10) | | **Date** | 2026-05-23 | | **Deciders** | ruv | -| **Codename** | **HA-DISCO** (MQTT) + **HA-FABRIC** (Matter) | +| **Codename** | **HA-DISCO** (MQTT) + **HA-FABRIC** (Matter) + **HA-MIND** (semantic primitives) | | **Relates to** | ADR-018 (CSI binary frame format), ADR-021 (ESP32 vitals), ADR-031 (RuView sensing-first), ADR-039 (edge vitals packet 0xC511_0002), ADR-079 (camera ground-truth), ADR-103 (cog-person-count), ADR-110 (ESP32-C6 firmware), ADR-114 (cog-quantum-vitals) | -| **Tracking issue** | TBD — file under RuView issue tracker, link in §10 | +| **Tracking issue** | [#776](https://github.com/ruvnet/RuView/issues/776) — implementation in PR [#778](https://github.com/ruvnet/RuView/pull/778) | | **Related issues** | [#574](https://github.com/ruvnet/RuView/issues/574) (mDNS for seed_url), [#760](https://github.com/ruvnet/RuView/issues/760) (sensing UI), [#761](https://github.com/ruvnet/RuView/issues/761) (HA competitor scan) | --- diff --git a/docs/adr/README.md b/docs/adr/README.md index 5df4e2d2..759e0953 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -89,6 +89,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme | [ADR-035](ADR-035-live-sensing-ui-accuracy.md) | Live Sensing UI Accuracy and Data Transparency | Accepted | | [ADR-036](ADR-036-rvf-training-pipeline-ui.md) | Training Pipeline UI Integration | Proposed | | [ADR-043](ADR-043-sensing-server-ui-api-completion.md) | Sensing Server UI API Completion (14 endpoints) | Accepted | +| [ADR-115](ADR-115-home-assistant-integration.md) | Home Assistant integration via MQTT auto-discovery + Matter bridge (HA-DISCO + HA-FABRIC + HA-MIND) | Accepted (MQTT track) / Proposed (Matter SDK P8b) | ### Architecture and infrastructure diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index cdff701f..89f77ffe 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -93,6 +93,11 @@ tower = { workspace = true } # Heavy dep tree (~80 transitive crates) so it's dev-only; benches live # behind --features mqtt because they bench the mqtt module. criterion = { version = "0.5", features = ["html_reports"] } +# ADR-115 P9 — property-based fuzzing for the wire-boundary security +# audit. Catches edge cases the example-based unit tests would miss +# (random Unicode, control chars, etc.). Pinned to a small version that +# doesn't pull in proptest-derive (we don't need it). +proptest = { version = "1.5", default-features = false, features = ["std"] } [[bench]] name = "mqtt_throughput" diff --git a/v2/crates/wifi-densepose-sensing-server/src/mqtt/security.rs b/v2/crates/wifi-densepose-sensing-server/src/mqtt/security.rs index 255c90fd..d11feb8c 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/mqtt/security.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/mqtt/security.rs @@ -250,4 +250,77 @@ mod tests { // --mqtt-password flag, this test fails on purpose. assert!(password_via_env_only(Some("secret")).is_err()); } + + // ─── Property-based fuzzing (proptest) ────────────────────────── + // + // The example-based tests above hit the obvious cases. These + // property tests hit *every* case clap could pass us: random + // Unicode, control chars, embedded NULs at arbitrary offsets, + // multi-character wildcards, etc. They catch regressions where a + // future refactor accidentally narrows the rejection envelope. + + use proptest::prelude::*; + + proptest! { + /// For ANY string that contains `+`, `#`, NUL, or `/`, the + /// safety check must return false. No exceptions. + #[test] + fn topic_segment_rejects_anything_with_wildcards_or_separators( + prefix in "[a-zA-Z0-9_-]{0,16}", + suffix in "[a-zA-Z0-9_-]{0,16}", + offender in proptest::char::any().prop_filter( + "must be reserved char", |c| matches!(c, '+' | '#' | '\0' | '/') + ), + ) { + let s = format!("{prefix}{offender}{suffix}"); + prop_assert!(!topic_segment_is_safe(&s), "must reject {:?}", s); + } + + /// For any non-empty string containing ONLY chars from the + /// "safe" alphabet (alphanumeric + a few punctuation), the + /// check must pass. + #[test] + fn topic_segment_accepts_safe_alphabet(s in "[a-zA-Z0-9_.\\-]{1,64}") { + prop_assert!(topic_segment_is_safe(&s), "must accept {:?}", s); + } + + /// Empty strings always rejected, regardless of input source. + #[test] + fn topic_segment_always_rejects_empty(seed in any::()) { + let _ = seed; // just to randomize the test runner + prop_assert!(!topic_segment_is_safe("")); + } + + /// Payload-size check: every size ≤ MAX_PUBLISH_BYTES is OK; + /// every size > MAX_PUBLISH_BYTES errors with the actual size. + #[test] + fn payload_size_check_is_monotonic( + len in 0usize..=(MAX_PUBLISH_BYTES * 2) + ) { + // Don't actually allocate MAX_PUBLISH_BYTES * 2 of memory + // every test; use a small payload + lie about its length + // via slicing semantics. The function only checks .len(). + let buf = vec![0u8; len]; + let r = check_payload_size(&buf); + if len > MAX_PUBLISH_BYTES { + prop_assert!(r.is_err()); + prop_assert_eq!(r.unwrap_err(), len); + } else { + prop_assert!(r.is_ok()); + } + } + + /// Path safety: a path containing NUL or newline must be + /// rejected, regardless of the rest of the path. + #[test] + fn path_safety_rejects_nul_or_newline_anywhere( + prefix in "[a-zA-Z0-9_/.\\-]{0,32}", + suffix in "[a-zA-Z0-9_/.\\-]{0,32}", + offender in prop_oneof!["\\u{0000}", "\\n"], + ) { + let s = format!("{prefix}{offender}{suffix}"); + let p = std::path::Path::new(&s); + prop_assert!(!path_is_safe(p), "must reject path with offender: {:?}", s); + } + } }