use std::{env, fmt};
#[derive(Clone)]
pub struct RedisConfig {
pub host: String,
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
pub database: u8,
pub timeout: u64,
pub connect_timeout: u64,
}
fn parse_timeout_secs(var: &str, default: u64) -> u64 {
match env::var(var) {
Ok(raw) => match raw.parse::<u64>() {
Ok(v) if v >= 1 => v,
_ => {
tracing::warn!(
"invalid {} value {:?}; must be an integer >= 1, using default {}s",
var,
raw,
default
);
default
}
},
Err(_) => default,
}
}
impl RedisConfig {
pub(crate) fn url(&self) -> String {
let mut url = String::from("redis://");
if self.username.is_some() || self.password.is_some() {
if let Some(username) = &self.username {
url.push_str(username);
}
if let Some(password) = &self.password {
url.push(':');
url.push_str(password);
}
url.push('@');
}
url.push_str(&self.host);
url.push(':');
url.push_str(&self.port.to_string());
if self.database > 0 {
url.push('/');
url.push_str(&self.database.to_string());
};
url
}
}
impl Default for RedisConfig {
fn default() -> Self {
let port = env::var("REDIS_PORT")
.ok()
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(6379);
let database = env::var("REDIS_DB")
.ok()
.and_then(|s| s.parse::<u8>().ok())
.unwrap_or(0);
let username = env::var("REDIS_USER").ok();
let password = env::var("REDIS_PASSWORD").ok();
let timeout = parse_timeout_secs("REDIS_TIMEOUT", 30);
let connect_timeout = parse_timeout_secs("REDIS_CONNECT_TIMEOUT", 5);
Self {
host: env::var("REDIS_HOST").unwrap_or_else(|_| "localhost".to_string()),
port,
username,
password,
database,
timeout,
connect_timeout,
}
}
}
impl fmt::Display for RedisConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let creds = if self.username.is_some() || self.password.is_some() {
"***@"
} else {
""
};
write!(f, "redis://{}{}:{}", creds, self.host, self.port)?;
if self.database > 0 {
write!(f, "/{}", self.database)?;
}
write!(f, " (timeout: {}s)", self.timeout)
}
}
impl fmt::Debug for RedisConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use once_cell::sync::Lazy;
use std::sync::Mutex;
static ENV_MUTEX: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
fn set_var(name: &str, value: &str) {
#[allow(unused_unsafe)]
unsafe {
env::set_var(name, value);
}
}
fn remove_var(name: &str) {
#[allow(unused_unsafe)]
unsafe {
env::remove_var(name);
}
}
#[test]
fn test_default_values() {
let _guard = ENV_MUTEX.lock().unwrap();
remove_var("REDIS_HOST");
remove_var("REDIS_PORT");
remove_var("REDIS_USER");
remove_var("REDIS_PASSWORD");
remove_var("REDIS_DB");
remove_var("REDIS_TIMEOUT");
remove_var("REDIS_CONNECT_TIMEOUT");
let config = RedisConfig::default();
assert_eq!(config.host, "localhost");
assert_eq!(config.port, 6379);
assert_eq!(config.username, None);
assert_eq!(config.password, None);
assert_eq!(config.database, 0);
assert_eq!(config.timeout, 30);
assert_eq!(config.connect_timeout, 5);
}
#[test]
fn test_environment_variable_overrides() {
let _guard = ENV_MUTEX.lock().unwrap();
set_var("REDIS_HOST", "redis.example.com");
set_var("REDIS_PORT", "6380");
set_var("REDIS_USER", "testuser");
set_var("REDIS_PASSWORD", "testpass");
set_var("REDIS_DB", "2");
set_var("REDIS_TIMEOUT", "45");
set_var("REDIS_CONNECT_TIMEOUT", "7");
let config = RedisConfig::default();
assert_eq!(config.host, "redis.example.com");
assert_eq!(config.port, 6380);
assert_eq!(config.username, Some("testuser".to_string()));
assert_eq!(config.password, Some("testpass".to_string()));
assert_eq!(config.database, 2);
assert_eq!(config.timeout, 45);
assert_eq!(config.connect_timeout, 7);
remove_var("REDIS_HOST");
remove_var("REDIS_PORT");
remove_var("REDIS_USER");
remove_var("REDIS_PASSWORD");
remove_var("REDIS_DB");
remove_var("REDIS_TIMEOUT");
remove_var("REDIS_CONNECT_TIMEOUT");
}
#[test]
fn test_invalid_timeouts_fall_back_to_defaults() {
let _guard = ENV_MUTEX.lock().unwrap();
set_var("REDIS_TIMEOUT", "not_a_number");
set_var("REDIS_CONNECT_TIMEOUT", "also_bad");
let config = RedisConfig::default();
assert_eq!(config.timeout, 30);
assert_eq!(config.connect_timeout, 5);
remove_var("REDIS_TIMEOUT");
remove_var("REDIS_CONNECT_TIMEOUT");
}
#[test]
fn test_zero_timeouts_fall_back_to_defaults() {
let _guard = ENV_MUTEX.lock().unwrap();
set_var("REDIS_TIMEOUT", "0");
set_var("REDIS_CONNECT_TIMEOUT", "0");
let config = RedisConfig::default();
assert_eq!(config.timeout, 30);
assert_eq!(config.connect_timeout, 5);
remove_var("REDIS_TIMEOUT");
remove_var("REDIS_CONNECT_TIMEOUT");
}
#[test]
fn test_invalid_port_in_env() {
let _guard = ENV_MUTEX.lock().unwrap();
set_var("REDIS_PORT", "not_a_number");
let config = RedisConfig::default();
assert_eq!(config.port, 6379);
remove_var("REDIS_PORT");
}
#[test]
fn test_invalid_db_in_env() {
let _guard = ENV_MUTEX.lock().unwrap();
set_var("REDIS_DB", "not_a_number");
let config = RedisConfig::default();
assert_eq!(config.database, 0);
remove_var("REDIS_DB");
}
#[test]
fn test_display_without_credentials() {
let config = RedisConfig {
host: "localhost".to_string(),
port: 6379,
username: None,
password: None,
database: 0,
timeout: 30,
connect_timeout: 5,
};
assert_eq!(
format!("{}", config),
"redis://localhost:6379 (timeout: 30s)"
);
}
#[test]
fn test_display_with_username_only() {
let config = RedisConfig {
host: "localhost".to_string(),
port: 6379,
username: Some("testuser".to_string()),
password: None,
database: 0,
timeout: 30,
connect_timeout: 5,
};
assert_eq!(
format!("{}", config),
"redis://***@localhost:6379 (timeout: 30s)"
);
}
#[test]
fn test_display_with_password_only() {
let config = RedisConfig {
host: "localhost".to_string(),
port: 6379,
username: None,
password: Some("testpass".to_string()),
database: 0,
timeout: 30,
connect_timeout: 5,
};
assert_eq!(
format!("{}", config),
"redis://***@localhost:6379 (timeout: 30s)"
);
}
#[test]
fn test_display_with_full_credentials() {
let config = RedisConfig {
host: "localhost".to_string(),
port: 6379,
username: Some("testuser".to_string()),
password: Some("testpass".to_string()),
database: 0,
timeout: 30,
connect_timeout: 5,
};
assert_eq!(
format!("{}", config),
"redis://***@localhost:6379 (timeout: 30s)"
);
}
#[test]
fn test_display_with_non_default_database() {
let config = RedisConfig {
host: "localhost".to_string(),
port: 6379,
username: None,
password: None,
database: 3,
timeout: 30,
connect_timeout: 5,
};
assert_eq!(
format!("{}", config),
"redis://localhost:6379/3 (timeout: 30s)"
);
}
#[test]
fn test_display_full_configuration() {
let config = RedisConfig {
host: "redis.example.com".to_string(),
port: 6380,
username: Some("admin".to_string()),
password: Some("s3cret".to_string()),
database: 5,
timeout: 45,
connect_timeout: 5,
};
assert_eq!(
format!("{}", config),
"redis://***@redis.example.com:6380/5 (timeout: 45s)"
);
}
#[test]
fn test_display_and_debug_redact_delimiter_passwords() {
for pw in ["p/secret-pw", "p secret-pw", "p@secret-pw"] {
let config = RedisConfig {
host: "localhost".to_string(),
port: 6379,
username: Some("user".to_string()),
password: Some(pw.to_string()),
database: 0,
timeout: 30,
connect_timeout: 5,
};
let display = format!("{}", config);
let debug = format!("{:?}", config);
assert!(!display.contains(pw), "Display leaked {pw:?}: {display}");
assert!(!debug.contains(pw), "Debug leaked {pw:?}: {debug}");
assert!(display.contains("***@"));
assert!(config.url().contains(pw));
}
}
#[test]
fn test_display_and_debug_redact_password() {
let config = RedisConfig {
host: "localhost".to_string(),
port: 6379,
username: Some("admin".to_string()),
password: Some("s3ntinel-pw".to_string()),
database: 0,
timeout: 30,
connect_timeout: 5,
};
let display = format!("{}", config);
let debug = format!("{:?}", config);
assert!(!display.contains("s3ntinel-pw"));
assert!(!display.contains("admin"));
assert!(display.contains("***"));
assert!(!debug.contains("s3ntinel-pw"));
assert!(!debug.contains("admin"));
assert!(debug.contains("***"));
assert!(config.url().contains("s3ntinel-pw"));
assert!(config.url().contains("admin"));
}
#[test]
fn test_clone() {
let original = RedisConfig {
host: "redis.example.com".to_string(),
port: 6380,
username: Some("testuser".to_string()),
password: Some("testpass".to_string()),
database: 2,
timeout: 45,
connect_timeout: 8,
};
let cloned = original.clone();
assert_eq!(cloned.host, "redis.example.com");
assert_eq!(cloned.port, 6380);
assert_eq!(cloned.username, Some("testuser".to_string()));
assert_eq!(cloned.password, Some("testpass".to_string()));
assert_eq!(cloned.database, 2);
assert_eq!(cloned.timeout, 45);
assert_eq!(cloned.connect_timeout, 8);
}
}