#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum RtspSecurityPolicy {
#[default]
PreferTls,
AllowInsecure,
RequireTls,
}
impl std::fmt::Display for RtspSecurityPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PreferTls => f.write_str("PreferTls"),
Self::AllowInsecure => f.write_str("AllowInsecure"),
Self::RequireTls => f.write_str("RequireTls"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum CustomPipelinePolicy {
#[default]
Reject,
AllowTrusted,
}
impl std::fmt::Display for CustomPipelinePolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Reject => f.write_str("Reject"),
Self::AllowTrusted => f.write_str("AllowTrusted"),
}
}
}
pub fn redact_url(url: &str) -> String {
let Some(scheme_end) = url.find("://") else {
return redact_authority(url);
};
let authority_start = scheme_end + 3;
let rest = &url[authority_start..];
let path_start = rest.find('/').unwrap_or(rest.len());
let authority_section = &rest[..path_start];
if let Some(at_pos) = authority_section.rfind('@') {
let after_at = &rest[at_pos..]; format!("{}://***{}", &url[..scheme_end], after_at)
} else {
url.to_string()
}
}
fn redact_authority(s: &str) -> String {
let path_start = s.find('/').unwrap_or(s.len());
let authority = &s[..path_start];
if let Some(at_pos) = authority.rfind('@') {
format!("***{}", &s[at_pos..])
} else {
s.to_string()
}
}
const MAX_ERROR_LEN: usize = 512;
pub fn sanitize_error_string(s: &str) -> String {
let mut out = String::with_capacity(s.len().min(MAX_ERROR_LEN));
for ch in s.chars() {
if out.len() >= MAX_ERROR_LEN {
out.push_str("...[truncated]");
break;
}
if ch == ' ' || (!ch.is_control() && !ch.is_ascii_control()) {
out.push(ch);
} else {
out.push(' ');
}
}
redact_secret_patterns(&mut out);
out
}
fn redact_secret_patterns(s: &mut String) {
let patterns = [
"password=",
"passwd=",
"token=",
"secret=",
"key=",
"auth=",
"authorization:",
"bearer ",
];
for pat in &patterns {
let mut search_from = 0;
loop {
let lower = s.to_lowercase();
if search_from >= lower.len() {
break;
}
let Some(rel_idx) = lower[search_from..].find(pat) else {
break;
};
let abs_idx = search_from + rel_idx;
let value_start = abs_idx + pat.len();
let value_end = s[value_start..]
.find([' ', '&', ';', ',', '\'', '"'])
.map(|p| value_start + p)
.unwrap_or(s.len());
if value_end > value_start {
s.replace_range(value_start..value_end, "***");
search_from = value_start + 3;
} else {
search_from = value_start;
}
}
}
}
pub fn redact_urls_in_string(s: &str) -> String {
let mut result = s.to_string();
for scheme in &["rtsp://", "rtsps://", "http://", "https://"] {
let mut search_from = 0;
while let Some(offset) = result[search_from..].find(scheme) {
let start = search_from + offset;
let url_end = result[start..]
.find(|c: char| c.is_whitespace() || c == '\'' || c == '"' || c == '>' || c == ')')
.map(|p| start + p)
.unwrap_or(result.len());
let url = &result[start..url_end];
let redacted = redact_url(url);
let redacted_len = redacted.len();
result.replace_range(start..url_end, &redacted);
search_from = start + redacted_len;
}
}
result
}
pub fn promote_rtsp_to_tls(url: &str) -> String {
if url.starts_with("rtsps://") {
url.to_string()
} else if let Some(rest) = url.strip_prefix("rtsp://") {
format!("rtsps://{rest}")
} else {
format!("rtsps://{url}")
}
}
pub fn is_insecure_rtsp(url: &str) -> bool {
url.starts_with("rtsp://")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn security_policy_default_is_prefer_tls() {
assert_eq!(RtspSecurityPolicy::default(), RtspSecurityPolicy::PreferTls);
}
#[test]
fn security_policy_display() {
assert_eq!(RtspSecurityPolicy::PreferTls.to_string(), "PreferTls");
assert_eq!(
RtspSecurityPolicy::AllowInsecure.to_string(),
"AllowInsecure"
);
assert_eq!(RtspSecurityPolicy::RequireTls.to_string(), "RequireTls");
}
#[test]
fn custom_pipeline_policy_default_is_reject() {
assert_eq!(
CustomPipelinePolicy::default(),
CustomPipelinePolicy::Reject
);
}
#[test]
fn redact_url_with_credentials() {
assert_eq!(
redact_url("rtsp://admin:secret@192.168.1.1:554/stream"),
"rtsp://***@192.168.1.1:554/stream"
);
}
#[test]
fn redact_url_without_credentials() {
assert_eq!(
redact_url("rtsp://192.168.1.1:554/stream"),
"rtsp://192.168.1.1:554/stream"
);
}
#[test]
fn redact_url_rtsps_with_credentials() {
assert_eq!(
redact_url("rtsps://user:p%40ss@cam.example.com/live"),
"rtsps://***@cam.example.com/live"
);
}
#[test]
fn redact_url_no_scheme() {
assert_eq!(redact_url("user:pass@host/path"), "***@host/path");
}
#[test]
fn redact_url_empty() {
assert_eq!(redact_url(""), "");
}
#[test]
fn redact_url_user_only_no_password() {
assert_eq!(
redact_url("rtsp://tokenuser@host/path"),
"rtsp://***@host/path"
);
}
#[test]
fn redact_url_at_in_path_ignored() {
assert_eq!(
redact_url("rtsp://host/path@weird"),
"rtsp://host/path@weird"
);
}
#[test]
fn sanitize_strips_control_chars() {
let dirty = "error\x00\x07\ndetail\r\ntab\there";
let clean = sanitize_error_string(dirty);
assert!(!clean.contains('\x00'));
assert!(!clean.contains('\x07'));
assert!(!clean.contains('\n'));
assert!(!clean.contains('\r'));
}
#[test]
fn sanitize_truncates_long_strings() {
let long = "a".repeat(1000);
let clean = sanitize_error_string(&long);
assert!(clean.len() < 600); }
#[test]
fn sanitize_redacts_password_pattern() {
let s = "connection failed password=hunter2 at host";
let clean = sanitize_error_string(s);
assert!(!clean.contains("hunter2"));
assert!(clean.contains("password=***"));
}
#[test]
fn sanitize_redacts_token_pattern() {
let s = "error token=abc123secret detail";
let clean = sanitize_error_string(s);
assert!(!clean.contains("abc123secret"));
assert!(clean.contains("token=***"));
}
#[test]
fn sanitize_preserves_useful_context() {
let s = "connection refused: host 192.168.1.1 port 554";
let clean = sanitize_error_string(s);
assert_eq!(clean, s);
}
#[test]
fn promote_rtsp_upgrades_to_rtsps() {
assert_eq!(
promote_rtsp_to_tls("rtsp://cam/stream"),
"rtsps://cam/stream"
);
}
#[test]
fn promote_rtsp_keeps_rtsps() {
assert_eq!(
promote_rtsp_to_tls("rtsps://cam/stream"),
"rtsps://cam/stream"
);
}
#[test]
fn promote_rtsp_no_scheme() {
assert_eq!(promote_rtsp_to_tls("cam/stream"), "rtsps://cam/stream");
}
#[test]
fn insecure_rtsp_detection() {
assert!(is_insecure_rtsp("rtsp://host/path"));
assert!(!is_insecure_rtsp("rtsps://host/path"));
assert!(!is_insecure_rtsp("http://host/path"));
}
#[test]
fn redact_urls_in_error_string() {
let s = "failed to connect to rtsp://admin:pass@cam/stream reason timeout";
let clean = redact_urls_in_string(s);
assert!(!clean.contains("admin:pass"));
assert!(clean.contains("rtsp://***@cam/stream"));
}
#[test]
fn redact_urls_no_urls() {
let s = "plain error message";
assert_eq!(redact_urls_in_string(s), s);
}
#[test]
fn redact_multiple_secrets_in_one_string() {
let s = "password=abc token=xyz secret=qqq";
let clean = sanitize_error_string(s);
assert!(!clean.contains("abc"));
assert!(!clean.contains("xyz"));
assert!(!clean.contains("qqq"));
assert!(clean.contains("password=***"));
assert!(clean.contains("token=***"));
assert!(clean.contains("secret=***"));
}
#[test]
fn redact_repeated_same_key() {
let s = "token=first&token=second&token=third";
let clean = sanitize_error_string(s);
assert!(!clean.contains("first"));
assert!(!clean.contains("second"));
assert!(!clean.contains("third"));
assert_eq!(clean.matches("token=***").count(), 3);
}
#[test]
fn redact_mixed_delimiters() {
let s = "password=a1 token=b2&secret=c3;auth=d4,key=e5'passwd=f6\"bearer g7";
let clean = sanitize_error_string(s);
for secret in &["a1", "b2", "c3", "d4", "e5", "f6", "g7"] {
assert!(!clean.contains(secret), "secret {secret} leaked");
}
}
#[test]
fn redact_no_panic_on_adversarial_strings() {
let _ = sanitize_error_string("password= next");
let _ = sanitize_error_string("password=");
let _ = sanitize_error_string("password=password=nested");
let _ = sanitize_error_string("token=&&&");
let long_val = format!("secret={}", "x".repeat(2000));
let clean = sanitize_error_string(&long_val);
assert!(!clean.contains(&"x".repeat(100)));
let _ = sanitize_error_string("token=日本語テスト done");
let _ = sanitize_error_string("key=key=key=");
}
#[test]
fn redact_case_insensitive() {
let s = "PASSWORD=upper Token=Mixed SECRET=LOUD";
let clean = sanitize_error_string(s);
assert!(!clean.contains("upper"));
assert!(!clean.contains("Mixed"));
assert!(!clean.contains("LOUD"));
}
}