use std::fmt; /// A wrapper type that redacts sensitive values when displayed or logged. /// Used for private keys, auth tokens, and session secrets. #[derive(Debug, Clone)] pub struct Redacted(String); impl Redacted { /// Create a new Redacted value. pub fn new(value: impl Into) -> Self { Self(value.into()) } /// Get the inner value. Use only when the value must be used programmatically /// (e.g., written to a file). Never pass this directly to logging. #[allow(dead_code)] pub fn into_inner(self) -> String { self.0 } /// Get a reference to the inner value. Use with caution. #[allow(dead_code)] pub fn inner(&self) -> &str { &self.0 } } impl fmt::Display for Redacted { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let len = self.0.len(); if len == 0 { write!(f, "[REDACTED]") } else { write!(f, "[REDACTED(len={})]", len) } } } impl From<&str> for Redacted { fn from(s: &str) -> Self { Self(s.to_string()) } } impl From for Redacted { fn from(s: String) -> Self { Self(s) } } impl PartialEq for Redacted { fn eq(&self, other: &Self) -> bool { self.0 == other.0 } } /// Redact a PEM certificate or key block for safe logging. /// Returns a string showing only the block type and line count. #[allow(dead_code)] pub fn redact_pem(pem: &str) -> String { let lines = pem.lines().count(); // Extract block type let block_type = pem .lines() .find(|l| l.starts_with("-----BEGIN ")) .map(|l| { l.strip_prefix("-----BEGIN ") .and_then(|s| s.strip_suffix(" -----")) .unwrap_or("UNKNOWN") }) .unwrap_or("UNKNOWN"); format!("[REDACTED-PEM type={} lines={}]", block_type, lines) } /// Redact a JSON configuration containing sensitive fields. /// Returns a display-safe representation. #[allow(dead_code)] pub fn redact_config_json(json: &str) -> String { let sensitive_keys = ["auth_token", "token", "private_key", "secret"]; let mut val = json.to_string(); for key in &sensitive_keys { let pat = format!("\"{}\":\"", key); while let Some(start) = val.find(&pat) { let after = &val[start + pat.len()..]; // Skip if already redacted if after.starts_with("[REDACTED]\"") { break; } let end = after .find('"') .map(|p| start + pat.len() + p + 1) .unwrap_or(val.len()); let replacement = format!("\"{}\":\"[REDACTED]\"", key); val.replace_range(start..end, &replacement); } } val } /// Check if a string looks like a private key and should be redacted. #[allow(dead_code)] pub fn is_sensitive(content: &str) -> bool { content.contains("-----BEGIN") && (content.contains("PRIVATE-KEY") || content.contains("PRIVATE KEY") || content.contains("EC PRIVATE-KEY") || content.contains("EC PRIVATE KEY") || content.contains("RSA PRIVATE-KEY") || content.contains("RSA PRIVATE KEY")) } /// Check if a string looks like an auth token and should be redacted. #[allow(dead_code)] pub fn is_auth_token(content: &str) -> bool { content.len() > 16 && content .chars() .all(|c| c.is_alphanumeric() || c == '-' || c == '_') } #[allow(dead_code)] pub fn is_connection_key(content: &str) -> bool { content.trim_start().starts_with("rtun2.") } #[cfg(test)] mod tests { use super::*; #[test] fn redacted_display_hides_value() { let secret = Redacted::new("my-secret-token-12345"); let display = format!("{}", secret); assert!(display.contains("REDACTED")); assert!(!display.contains("my-secret")); assert!(!display.contains("token")); // Should show length hints assert!(display.contains("len=")); } #[test] fn redacted_empty_shows_marker() { let empty = Redacted::new(""); assert_eq!(format!("{}", empty), "[REDACTED]"); } #[test] fn redacted_short_shows_length() { let short = Redacted::new("abc"); let display = format!("{}", short); assert!(display.contains("REDACTED")); assert!(!display.contains("abc")); } #[test] fn redacted_preserves_value_for_comparison() { let a = Redacted::new("same-value"); let b = Redacted::new("same-value"); assert_eq!(a, b); } #[test] fn redacted_inner_returns_value() { let secret = Redacted::new("real-value"); assert_eq!(secret.inner(), "real-value"); } #[test] fn redact_pem_hides_content() { let pem = "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBg...\n-----END PRIVATE KEY-----"; let redacted = redact_pem(pem); assert!(redacted.contains("REDACTED")); assert!(!redacted.contains("MIIEvg")); } #[test] fn redact_config_json_hides_tokens() { let json = r#"{"auth_token":"secret123","listen":"127.0.0.1:4180"}"#; let redacted = redact_config_json(json); assert!(redacted.contains("REDACTED")); assert!(!redacted.contains("secret123")); // Non-sensitive fields should remain assert!(redacted.contains("127.0.0.1:4180")); } #[test] fn is_sensitive_detects_private_key() { let pem = "-----BEGIN PRIVATE KEY-----\ndata"; assert!(is_sensitive(pem)); } #[test] fn is_sensitive_detects_ec_private_key() { let pem = "-----BEGIN EC PRIVATE KEY-----\ndata"; assert!(is_sensitive(pem)); } #[test] fn is_sensitive_false_for_certificate() { let pem = "-----BEGIN CERTIFICATE-----\ndata"; assert!(!is_sensitive(pem)); } #[test] fn is_auth_token_long_alphanumeric() { assert!(is_auth_token("abcdef1234567890ab")); } #[test] fn is_auth_token_short_is_false() { assert!(!is_auth_token("short")); } #[test] fn is_connection_key_detects_rtun_prefix() { assert!(is_connection_key("rtun2.abc")); assert!(!is_connection_key("abc")); assert!(!is_connection_key("rtun1.abc")); } #[test] fn tracing_logs_redact_secrets() { // Verify that tracing with a Redacted value doesn't leak secrets let secret = Redacted::new("sensitive-data"); // The display impl ensures the formatted output is redacted let display = format!("{}", secret); assert!(!display.contains("sensitive-data")); } #[test] fn from_str_creates_redacted() { let r: Redacted = "test-value".into(); assert_eq!(r.inner(), "test-value"); assert!(!format!("{}", r).contains("test-value")); } #[test] fn from_string_creates_redacted() { let s = String::from("another-value"); let r: Redacted = s.into(); assert!(!format!("{}", r).contains("another-value")); } }