mirror of
https://github.com/ruvnet/RuView
synced 2026-08-09 20:21:43 +00:00
d25e331bbf
## Security audit (`mqtt::security`) New module enforcing the ADR-115 §3.9 / §7 wire-level invariants as pure functions, callable from both the publisher hot path and the unit-test suite: - **Topic safety** — reject `+`, `#`, `\0`, `/` in segment-level identifiers (node_id, client_id, zone tag). Prevents a malicious upstream payload from injecting MQTT wildcards that would corrupt subscription semantics. - **Path safety** — reject NUL / newline in TLS cert / CA paths. - **Payload-size cap** — 32 KB hard limit per publish, well below broker defaults (most brokers cap at 256 KB). Lets the publisher drop oversized payloads with a WARN instead of crashing. - **Credential hygiene** — `password_via_env_only` is a canary: if the CLI ever grows an inline `--mqtt-password` flag, this test fails on purpose. Today we only accept `--mqtt-password-env <VAR>`. - **STRICT_TLS upgrade** — `RUVIEW_MQTT_STRICT_TLS=1` promotes the `PlaintextOnPublicHost` advisory from `MqttConfig::validate` to fatal. This is the planned v0.8.0 default per ADR §9.5. - **Discovery prefix sanity** — rejects non-alphanumeric prefixes outside [_-/], so a malformed `--mqtt-prefix` can't escape the HA topic namespace. 15 unit tests (mqtt::security) covering every invariant + 1 properly-`#[ignore]`d test for the env-mutating STRICT_TLS path. ## Criterion benchmarks (`benches/mqtt_throughput.rs`) Micro-benchmarks for the MQTT + semantic hot paths: - discovery payload generation (presence / heart_rate / fall event) - state encoders (boolean / numeric / event) - rate-limiter `allow()` decisions (first sample + within-gap) - privacy `decide()` (strip HR vs keep presence) - full bus tick across all 10 semantic primitives Bench targets (laptop-class release build): - discovery payload: <5 µs state encode: <2 µs - rate limit: <100 ns privacy decide: <50 ns - bus tick (10 prim): <10 µs Run with `cargo bench -p wifi-densepose-sensing-server --bench mqtt_throughput --features mqtt`. Numbers will be captured into the witness bundle in P10. `criterion` 0.5 added as dev-dep. `[[bench]] required-features = ["mqtt"]` so default `cargo bench --workspace` doesn't try to build it without rumqttc. Lib test count: **372 passed** (357 → 372, +15 security tests). Refs #776. Co-Authored-By: claude-flow <ruv@ruv.net>
194 lines
5.8 KiB
Rust
194 lines
5.8 KiB
Rust
//! ADR-115 P9 — MQTT pipeline throughput micro-benchmark.
|
|
//!
|
|
//! Measures the hot-path cost of:
|
|
//! - Building a HA discovery payload (`DiscoveryBuilder::build`)
|
|
//! - Encoding a numeric state message (`StateEncoder::numeric`)
|
|
//! - Rate-limit decision (`RateLimiter::allow`)
|
|
//! - Privacy filter (`privacy::decide`)
|
|
//! - Full bus tick across all 10 semantic primitives
|
|
//!
|
|
//! Targets (laptop-class, single-threaded, release build):
|
|
//! - discovery payload: < 5 µs
|
|
//! - state encode: < 2 µs
|
|
//! - rate limit: < 100 ns
|
|
//! - privacy decide: < 50 ns
|
|
//! - bus tick (10 prim):< 10 µs
|
|
//!
|
|
//! The bench is intentionally feature-gated so the default workspace
|
|
//! build doesn't pull `criterion` in (it has a big-ish dep tree).
|
|
//!
|
|
//! Run with:
|
|
//! cargo bench -p wifi-densepose-sensing-server --bench mqtt_throughput
|
|
|
|
#![cfg(feature = "mqtt")]
|
|
|
|
use std::time::Duration;
|
|
|
|
use criterion::{black_box, criterion_group, criterion_main, BatchSize, Criterion};
|
|
|
|
use wifi_densepose_sensing_server::mqtt::{
|
|
config::PublishRates,
|
|
discovery::{DiscoveryBuilder, EntityKind},
|
|
privacy::decide,
|
|
state::{RateLimiter, StateEncoder, VitalsSnapshot},
|
|
};
|
|
use wifi_densepose_sensing_server::semantic::{PrimitiveConfig, RawSnapshot, SemanticBus};
|
|
|
|
fn builder() -> DiscoveryBuilder<'static> {
|
|
DiscoveryBuilder {
|
|
discovery_prefix: "homeassistant",
|
|
node_id: "aabbccddeeff",
|
|
node_friendly_name: Some("Bedroom"),
|
|
sw_version: "v0.7.0",
|
|
model: "ESP32-S3 CSI node",
|
|
via_device: Some("cognitum_seed_1"),
|
|
}
|
|
}
|
|
|
|
fn snap() -> VitalsSnapshot {
|
|
VitalsSnapshot {
|
|
node_id: "aabbccddeeff".into(),
|
|
timestamp_ms: 1779_512_400_000,
|
|
presence: true,
|
|
fall_detected: false,
|
|
motion: 0.35,
|
|
motion_energy: 1234.5,
|
|
presence_score: 0.91,
|
|
breathing_rate_bpm: Some(14.2),
|
|
heartrate_bpm: Some(68.2),
|
|
n_persons: 1,
|
|
rssi_dbm: Some(-52.0),
|
|
vital_confidence: 0.87,
|
|
}
|
|
}
|
|
|
|
fn raw_snap() -> RawSnapshot {
|
|
RawSnapshot {
|
|
node_id: "aabbccddeeff".into(),
|
|
since_start: Duration::from_secs(120),
|
|
timestamp_ms: 1779_512_400_000,
|
|
presence: true,
|
|
fall_detected: false,
|
|
motion: 0.35,
|
|
motion_energy: 1234.5,
|
|
breathing_rate_bpm: Some(14.2),
|
|
heart_rate_bpm: Some(68.2),
|
|
n_persons: 1,
|
|
rssi_dbm: Some(-52.0),
|
|
vital_confidence: 0.87,
|
|
active_zones: vec!["bathroom".into()],
|
|
bed_zones: vec!["bedroom".into()],
|
|
local_seconds_since_midnight: 2 * 3600,
|
|
}
|
|
}
|
|
|
|
fn rates() -> PublishRates {
|
|
PublishRates::default()
|
|
}
|
|
|
|
fn bench_discovery_payload(c: &mut Criterion) {
|
|
let b = builder();
|
|
c.bench_function("discovery::build_presence", |bench| {
|
|
bench.iter(|| {
|
|
let cfg = b.build(black_box(EntityKind::Presence));
|
|
black_box(serde_json::to_string(&cfg).unwrap())
|
|
});
|
|
});
|
|
c.bench_function("discovery::build_heart_rate", |bench| {
|
|
bench.iter(|| {
|
|
let cfg = b.build(black_box(EntityKind::HeartRate));
|
|
black_box(serde_json::to_string(&cfg).unwrap())
|
|
});
|
|
});
|
|
c.bench_function("discovery::build_fall_event", |bench| {
|
|
bench.iter(|| {
|
|
let cfg = b.build(black_box(EntityKind::FallDetected));
|
|
black_box(serde_json::to_string(&cfg).unwrap())
|
|
});
|
|
});
|
|
}
|
|
|
|
fn bench_state_encode(c: &mut Criterion) {
|
|
let b = builder();
|
|
let s = snap();
|
|
let enc = StateEncoder { builder: &b };
|
|
c.bench_function("state::numeric_heart_rate", |bench| {
|
|
bench.iter(|| {
|
|
black_box(enc.numeric(EntityKind::HeartRate, &s).unwrap())
|
|
});
|
|
});
|
|
c.bench_function("state::boolean_presence", |bench| {
|
|
bench.iter(|| {
|
|
black_box(enc.boolean(EntityKind::Presence, true).unwrap())
|
|
});
|
|
});
|
|
c.bench_function("state::event_fall", |bench| {
|
|
bench.iter(|| {
|
|
black_box(enc.event(EntityKind::FallDetected, "fall_detected", 0, Some(0.87)).unwrap())
|
|
});
|
|
});
|
|
}
|
|
|
|
fn bench_rate_limit(c: &mut Criterion) {
|
|
let r = rates();
|
|
c.bench_function("rate_limiter::allow_first", |bench| {
|
|
bench.iter_batched(
|
|
RateLimiter::new,
|
|
|mut rl| {
|
|
black_box(rl.allow(
|
|
black_box(EntityKind::HeartRate),
|
|
Duration::from_secs(0),
|
|
&r,
|
|
))
|
|
},
|
|
BatchSize::SmallInput,
|
|
);
|
|
});
|
|
c.bench_function("rate_limiter::allow_within_gap", |bench| {
|
|
bench.iter_batched(
|
|
|| {
|
|
let mut rl = RateLimiter::new();
|
|
rl.allow(EntityKind::HeartRate, Duration::from_secs(0), &r);
|
|
rl
|
|
},
|
|
|mut rl| {
|
|
black_box(rl.allow(
|
|
black_box(EntityKind::HeartRate),
|
|
Duration::from_secs(1),
|
|
&r,
|
|
))
|
|
},
|
|
BatchSize::SmallInput,
|
|
);
|
|
});
|
|
}
|
|
|
|
fn bench_privacy(c: &mut Criterion) {
|
|
c.bench_function("privacy::decide_hr_strip", |bench| {
|
|
bench.iter(|| black_box(decide(EntityKind::HeartRate, true)));
|
|
});
|
|
c.bench_function("privacy::decide_presence_keep", |bench| {
|
|
bench.iter(|| black_box(decide(EntityKind::Presence, true)));
|
|
});
|
|
}
|
|
|
|
fn bench_semantic_bus(c: &mut Criterion) {
|
|
c.bench_function("semantic::bus_tick_all_10_primitives", |bench| {
|
|
bench.iter_batched(
|
|
|| (SemanticBus::new(PrimitiveConfig::default()), raw_snap()),
|
|
|(mut bus, s)| black_box(bus.tick(&s)),
|
|
BatchSize::SmallInput,
|
|
);
|
|
});
|
|
}
|
|
|
|
criterion_group!(
|
|
benches,
|
|
bench_discovery_payload,
|
|
bench_state_encode,
|
|
bench_rate_limit,
|
|
bench_privacy,
|
|
bench_semantic_bus,
|
|
);
|
|
criterion_main!(benches);
|