use crate::target::Vendor;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Hazard {
Destructive(String),
RequiresConfirmation,
Safe,
}
pub fn classify(vendor: Vendor, command: &str) -> Hazard {
match vendor {
Vendor::Tasmota => tasmota::classify(command),
Vendor::Shelly => shelly::classify(command),
}
}
mod tasmota {
use super::Hazard;
const DESTRUCTIVE: &[&str] = &[
"RESET", "UPGRADE", "UPLOAD", "OTAURL", "MODULE", "TEMPLATE", "GPIO", "GPIOS", "WEBGETCONFIG", "RESTORE", ];
const SAFE: &[&str] = &["STATUS", "STATE"];
fn subcommands(command: &str) -> Vec<String> {
let trimmed = command.trim();
let mut parts = trimmed.splitn(2, char::is_whitespace);
let head = parts.next().unwrap_or("").trim();
if head.eq_ignore_ascii_case("backlog") || head.eq_ignore_ascii_case("backlog0") {
let rest = parts.next().unwrap_or("");
return rest
.split(';')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
vec![trimmed.to_string()]
}
fn command_word(sub: &str) -> String {
sub.split(char::is_whitespace)
.next()
.unwrap_or("")
.to_ascii_uppercase()
}
fn is_relay_control(word: &str) -> bool {
match word.strip_prefix("POWER") {
Some("") => true,
Some(rest) => rest.parse::<u8>().is_ok(),
None => false,
}
}
fn is_safe_word(word: &str) -> bool {
SAFE.contains(&word) || is_relay_control(word)
}
pub fn classify(command: &str) -> Hazard {
let mut requires_confirmation = false;
for sub in subcommands(command) {
let word = command_word(&sub);
if DESTRUCTIVE.contains(&word.as_str()) {
return Hazard::Destructive(format!("`{}` is a destructive command", sub.trim()));
}
if !is_safe_word(&word) {
requires_confirmation = true;
}
}
if requires_confirmation {
Hazard::RequiresConfirmation
} else {
Hazard::Safe
}
}
}
mod shelly {
use super::Hazard;
fn method_suffix(method: &str) -> &str {
method.rsplit('.').next().unwrap_or(method)
}
fn destructive_reason(method: &str, suffix: &str) -> Option<String> {
if suffix.eq_ignore_ascii_case("FactoryReset") {
return Some(format!("`{method}` performs a factory reset"));
}
if suffix.eq_ignore_ascii_case("Reboot") {
return Some(format!("`{method}` reboots the device"));
}
if suffix.eq_ignore_ascii_case("SetConfig") {
return Some(format!("`{method}` writes device configuration"));
}
if method.eq_ignore_ascii_case("Shelly.Update") {
return Some(format!("`{method}` flashes new firmware"));
}
None
}
fn is_safe(method: &str, suffix: &str) -> bool {
suffix.eq_ignore_ascii_case("ListMethods")
|| suffix.to_ascii_uppercase().starts_with("GET")
|| method.eq_ignore_ascii_case("Switch.Set")
}
pub fn classify(command: &str) -> Hazard {
let method = command.trim();
let suffix = method_suffix(method);
if let Some(reason) = destructive_reason(method, suffix) {
return Hazard::Destructive(reason);
}
if is_safe(method, suffix) {
return Hazard::Safe;
}
Hazard::RequiresConfirmation
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tasmota_plain_reset_is_destructive() {
assert!(matches!(
classify(Vendor::Tasmota, "Reset 1"),
Hazard::Destructive(_)
));
}
#[test]
fn tasmota_status_is_safe() {
assert_eq!(classify(Vendor::Tasmota, "Status 0"), Hazard::Safe);
}
#[test]
fn tasmota_power_toggle_is_safe() {
assert_eq!(classify(Vendor::Tasmota, "Power TOGGLE"), Hazard::Safe);
}
#[test]
fn tasmota_config_write_requires_confirmation() {
assert_eq!(
classify(Vendor::Tasmota, "SetOption65 1"),
Hazard::RequiresConfirmation
);
}
#[test]
fn tasmota_destructive_hidden_in_backlog_is_caught() {
assert!(matches!(
classify(Vendor::Tasmota, "Backlog Power ON; Reset 1"),
Hazard::Destructive(_)
));
}
#[test]
fn tasmota_all_safe_backlog_is_safe() {
assert_eq!(
classify(Vendor::Tasmota, "Backlog Power1 ON; Power2 OFF"),
Hazard::Safe
);
}
#[test]
fn tasmota_upgrade_and_otaurl_are_destructive() {
assert!(matches!(
classify(Vendor::Tasmota, "Upgrade 1"),
Hazard::Destructive(_)
));
assert!(matches!(
classify(Vendor::Tasmota, "OtaUrl http://192.0.2.10/f.bin"),
Hazard::Destructive(_)
));
}
#[test]
fn tasmota_unknown_command_requires_confirmation() {
assert_eq!(
classify(Vendor::Tasmota, "Wifi 0"),
Hazard::RequiresConfirmation
);
}
#[test]
fn shelly_factory_reset_is_destructive() {
assert!(matches!(
classify(Vendor::Shelly, "Shelly.FactoryReset"),
Hazard::Destructive(_)
));
}
#[test]
fn shelly_set_config_is_destructive() {
assert!(matches!(
classify(Vendor::Shelly, "Sys.SetConfig"),
Hazard::Destructive(_)
));
}
#[test]
fn shelly_reboot_is_destructive() {
assert!(matches!(
classify(Vendor::Shelly, "Shelly.Reboot"),
Hazard::Destructive(_)
));
}
#[test]
fn shelly_update_is_destructive() {
assert!(matches!(
classify(Vendor::Shelly, "Shelly.Update"),
Hazard::Destructive(_)
));
}
#[test]
fn shelly_getters_are_safe() {
assert_eq!(classify(Vendor::Shelly, "Switch.GetStatus"), Hazard::Safe);
assert_eq!(
classify(Vendor::Shelly, "Shelly.GetDeviceInfo"),
Hazard::Safe
);
}
#[test]
fn shelly_switch_set_is_safe() {
assert_eq!(classify(Vendor::Shelly, "Switch.Set"), Hazard::Safe);
}
#[test]
fn shelly_unknown_method_requires_confirmation() {
assert_eq!(
classify(Vendor::Shelly, "Switch.Toggle"),
Hazard::RequiresConfirmation
);
}
#[test]
fn shelly_matching_is_case_insensitive() {
assert!(matches!(
classify(Vendor::Shelly, "shelly.factoryreset"),
Hazard::Destructive(_)
));
assert_eq!(classify(Vendor::Shelly, "switch.getstatus"), Hazard::Safe);
}
}