// SPDX-FileCopyrightText: 2026 Stan Grams // // SPDX-License-Identifier: GPL-2.0-or-later //! Shared configuration validation helpers used by both `trx-server` and //! `trx-client`. //! //! # Non-shared structs //! //! `GeneralConfig` is defined separately in each binary because the fields //! differ: //! //! - **Server** `GeneralConfig`: `callsign`, `log_level`, `latitude`, //! `longitude` //! - **Client** `GeneralConfig`: `callsign`, `log_level`, `website_url`, //! `website_name`, `ais_vessel_url_base` //! //! Only `callsign` and `log_level` overlap. Merging into a single struct //! would either bloat both binaries with unused fields or require a trait //! abstraction that adds complexity without clear benefit. use std::net::IpAddr; /// A socket a component intends to bind, and what it is for. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BoundSocket { pub addr: IpAddr, pub port: u16, /// Human-readable owner, e.g. `[listen]` or `[frontends.http]`. pub label: String, } impl BoundSocket { pub fn new(addr: IpAddr, port: u16, label: impl Into) -> Self { Self { addr, port, label: label.into(), } } } /// Reject two components trying to bind the same socket. /// /// A wildcard address (`0.0.0.0` / `::`) conflicts with any other address on /// the same port, since binding it claims every interface. Port 0 means "pick /// an ephemeral port" and never conflicts. pub fn check_socket_conflicts(sockets: &[BoundSocket]) -> Result<(), String> { for (i, a) in sockets.iter().enumerate() { if a.port == 0 { continue; } for b in &sockets[i + 1..] { if b.port != a.port { continue; } if a.addr == b.addr || a.addr.is_unspecified() || b.addr.is_unspecified() { return Err(format!( "{} and {} would both bind {}:{}", a.label, b.label, a.addr, a.port )); } } } Ok(()) } /// Validate that a log level string is one of the accepted values. /// /// Returns `Ok(())` when `level` is `None` (defaulting is handled elsewhere) /// or a recognised level name. pub fn validate_log_level(level: Option<&str>) -> Result<(), String> { if let Some(level) = level { match level { "trace" | "debug" | "info" | "warn" | "error" => {} _ => { return Err(format!( "[general].log_level '{}' is invalid (expected one of: trace, debug, info, warn, error)", level )) } } } Ok(()) } /// Validate that a list of authentication tokens contains no empty entries. /// /// `path` is a human-readable config path prefix used in the error message /// (e.g. `"[listen.auth].tokens"`). pub fn validate_tokens(path: &str, tokens: &[String]) -> Result<(), String> { if tokens.iter().any(|t| t.trim().is_empty()) { return Err(format!("{path} must not contain empty tokens")); } Ok(()) } #[cfg(test)] mod tests { use super::*; fn sock(addr: &str, port: u16, label: &str) -> BoundSocket { BoundSocket::new(addr.parse().unwrap(), port, label) } #[test] fn test_socket_conflicts_detects_exact_duplicate() { let err = check_socket_conflicts(&[ sock("127.0.0.1", 8080, "[frontends.http]"), sock("127.0.0.1", 8080, "[frontends.http_json]"), ]) .unwrap_err(); assert!(err.contains("127.0.0.1:8080"), "unexpected error: {err}"); } #[test] fn test_socket_conflicts_detects_wildcard_overlap() { assert!(check_socket_conflicts(&[ sock("0.0.0.0", 4530, "[listen]"), sock("127.0.0.1", 4530, "[audio]"), ]) .is_err()); } #[test] fn test_socket_conflicts_allows_distinct_addresses() { assert!(check_socket_conflicts(&[ sock("127.0.0.1", 4530, "[listen]"), sock("192.168.1.5", 4530, "[audio]"), ]) .is_ok()); } #[test] fn test_socket_conflicts_ignores_ephemeral_ports() { assert!(check_socket_conflicts(&[ sock("127.0.0.1", 0, "[frontends.http_json]"), sock("127.0.0.1", 0, "[frontends.http]"), ]) .is_ok()); } #[test] fn test_validate_log_level_none() { assert!(validate_log_level(None).is_ok()); } #[test] fn test_validate_log_level_valid() { for level in &["trace", "debug", "info", "warn", "error"] { assert!(validate_log_level(Some(level)).is_ok()); } } #[test] fn test_validate_log_level_invalid() { assert!(validate_log_level(Some("verbose")).is_err()); } #[test] fn test_validate_tokens_empty_list() { assert!(validate_tokens("[auth].tokens", &[]).is_ok()); } #[test] fn test_validate_tokens_valid() { let tokens = vec!["abc".to_string(), "def".to_string()]; assert!(validate_tokens("[auth].tokens", &tokens).is_ok()); } #[test] fn test_validate_tokens_rejects_empty() { let tokens = vec!["abc".to_string(), "".to_string()]; assert!(validate_tokens("[auth].tokens", &tokens).is_err()); } #[test] fn test_validate_tokens_rejects_whitespace_only() { let tokens = vec![" ".to_string()]; assert!(validate_tokens("[auth].tokens", &tokens).is_err()); } }