use std::path::Path;
use super::config::{MqttConfig, MqttConfigError, TlsConfig};
pub const MAX_PUBLISH_BYTES: usize = 32 * 1024;
pub fn topic_segment_is_safe(s: &str) -> bool {
!s.is_empty()
&& !s.contains('+')
&& !s.contains('#')
&& !s.contains('\0')
&& !s.contains('/') }
pub fn path_is_safe(p: &Path) -> bool {
let s = match p.to_str() {
Some(s) => s,
None => return false, };
!s.contains('\0') && !s.contains('\n')
}
pub fn password_via_env_only(cli_password: Option<&str>) -> Result<(), MqttConfigError> {
if cli_password.is_some() {
return Err(MqttConfigError::EmptyHost); }
Ok(())
}
pub fn audit(cfg: &MqttConfig) -> Result<(), MqttConfigError> {
cfg.validate()?;
if std::env::var("RUVIEW_MQTT_STRICT_TLS").as_deref() == Ok("1")
&& matches!(cfg.tls, TlsConfig::Off)
&& !cfg.host.eq_ignore_ascii_case("localhost")
&& !cfg.host.starts_with("127.")
&& !cfg.host.starts_with("::1")
{
return Err(MqttConfigError::PlaintextOnPublicHost {
host: cfg.host.clone(),
});
}
if let Some(p) = &cfg.password { let _ = p; }
if let Some(client_id) = Some(&cfg.client_id) {
if !topic_segment_is_safe(client_id) {
return Err(MqttConfigError::EmptyHost); }
}
if !cfg.discovery_prefix.chars().all(|c| {
c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '/'
}) {
return Err(MqttConfigError::EmptyHost);
}
Ok(())
}
pub fn check_payload_size(payload: &[u8]) -> Result<(), usize> {
if payload.len() > MAX_PUBLISH_BYTES {
Err(payload.len())
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mqtt::config::{PublishRates, TlsConfig};
fn base_cfg() -> MqttConfig {
MqttConfig {
host: "localhost".into(),
port: 1883,
username: None,
password: None,
client_id: "test-client".into(),
discovery_prefix: "homeassistant".into(),
tls: TlsConfig::Off,
refresh_secs: 600,
rates: PublishRates::default(),
publish_pose: false,
privacy_mode: false,
}
}
#[test]
fn topic_segment_safe_normal() {
assert!(topic_segment_is_safe("wifi_densepose_aabbcc"));
assert!(topic_segment_is_safe("presence"));
assert!(topic_segment_is_safe("ESP32-S3.node-7"));
}
#[test]
fn topic_segment_rejects_wildcards() {
assert!(!topic_segment_is_safe("+"));
assert!(!topic_segment_is_safe("evil+segment"));
assert!(!topic_segment_is_safe("#"));
assert!(!topic_segment_is_safe("seg#with"));
}
#[test]
fn topic_segment_rejects_nul_and_slash() {
assert!(!topic_segment_is_safe("with\0nul"));
assert!(!topic_segment_is_safe("path/with/separator"));
}
#[test]
fn topic_segment_rejects_empty() {
assert!(!topic_segment_is_safe(""));
}
#[test]
fn path_safety_accepts_normal_paths() {
assert!(path_is_safe(Path::new("/etc/ssl/ca.pem")));
assert!(path_is_safe(Path::new("C:\\Users\\test\\client.pem")));
}
#[test]
fn path_safety_rejects_nul_and_newline() {
assert!(!path_is_safe(Path::new("with\nnewline")));
assert!(!path_is_safe(Path::new("with\0nul")));
}
#[test]
fn audit_accepts_clean_localhost_config() {
assert!(audit(&base_cfg()).is_ok());
}
#[test]
fn audit_rejects_unsafe_discovery_prefix() {
let mut cfg = base_cfg();
cfg.discovery_prefix = "evil prefix with space".into();
assert!(audit(&cfg).is_err());
}
#[test]
fn audit_rejects_unsafe_client_id() {
let mut cfg = base_cfg();
cfg.client_id = "client#with#hash".into();
assert!(audit(&cfg).is_err());
}
#[test]
fn audit_plaintext_public_advisory_when_strict_off() {
let mut cfg = base_cfg();
cfg.host = "broker.example.com".into();
std::env::remove_var("RUVIEW_MQTT_STRICT_TLS");
let err = audit(&cfg).unwrap_err();
assert!(!err.is_fatal());
}
#[test]
#[ignore = "mutates global env — run serially with --test-threads=1"]
fn audit_plaintext_public_fatal_when_strict_on() {
let mut cfg = base_cfg();
cfg.host = "broker.example.com".into();
std::env::set_var("RUVIEW_MQTT_STRICT_TLS", "1");
let err = audit(&cfg).unwrap_err();
assert!(matches!(err, MqttConfigError::PlaintextOnPublicHost { .. }));
std::env::remove_var("RUVIEW_MQTT_STRICT_TLS");
}
#[test]
fn payload_size_accepts_small_message() {
assert!(check_payload_size(&[0u8; 1024]).is_ok());
}
#[test]
fn payload_size_accepts_at_limit() {
assert!(check_payload_size(&vec![0u8; MAX_PUBLISH_BYTES]).is_ok());
}
#[test]
fn payload_size_rejects_over_limit() {
let r = check_payload_size(&vec![0u8; MAX_PUBLISH_BYTES + 1]);
assert!(r.is_err());
assert_eq!(r.unwrap_err(), MAX_PUBLISH_BYTES + 1);
}
#[test]
fn password_via_env_only_accepts_none() {
assert!(password_via_env_only(None).is_ok());
}
#[test]
fn password_via_env_only_rejects_inline() {
assert!(password_via_env_only(Some("secret")).is_err());
}
use proptest::prelude::*;
proptest! {
#[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);
}
#[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);
}
#[test]
fn topic_segment_always_rejects_empty(seed in any::<u64>()) {
let _ = seed; prop_assert!(!topic_segment_is_safe(""));
}
#[test]
fn payload_size_check_is_monotonic(
len in 0usize..=(MAX_PUBLISH_BYTES * 2)
) {
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());
}
}
#[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);
}
}
}