use std::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 super::read_var(var) {
Some(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
}
},
None => default,
}
}
#[must_use]
fn encode_userinfo(raw: &str) -> String {
let mut encoded = String::with_capacity(raw.len());
for byte in raw.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
encoded.push(byte as char);
}
_ => encoded.push_str(&format!("%{byte:02X}")),
}
}
encoded
}
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(&encode_userinfo(username));
}
if let Some(password) = &self.password {
url.push(':');
url.push_str(&encode_userinfo(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 = super::read_var("REDIS_PORT")
.and_then(|s| s.trim().parse::<u16>().ok())
.unwrap_or(6379);
let database = super::read_var("REDIS_DB")
.and_then(|s| s.trim().parse::<u8>().ok())
.unwrap_or(0);
let username = super::read_var("REDIS_USER");
let password = super::read_var("REDIS_PASSWORD");
let timeout = parse_timeout_secs("REDIS_TIMEOUT", 30);
let connect_timeout = parse_timeout_secs("REDIS_CONNECT_TIMEOUT", 5);
Self {
host: super::read_var("REDIS_HOST")
.map(|host| host.trim().to_string())
.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 {
std::env::set_var(name, value);
}
}
fn remove_var(name: &str) {
#[allow(unused_unsafe)]
unsafe {
std::env::remove_var(name);
}
}
#[test]
fn test_a_blank_credential_reads_as_unset() {
let _guard = match ENV_MUTEX.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
remove_var("REDIS_HOST");
remove_var("REDIS_PORT");
remove_var("REDIS_DB");
remove_var("REDIS_TIMEOUT");
remove_var("REDIS_CONNECT_TIMEOUT");
for blank in ["", " "] {
set_var("REDIS_USER", blank);
set_var("REDIS_PASSWORD", blank);
let config = RedisConfig::default();
assert_eq!(config.username, None, "user {blank:?} must read as unset");
assert_eq!(
config.password, None,
"password {blank:?} must read as unset"
);
assert!(
!config.url().contains('@'),
"a password-less server must get a URL with no userinfo, got {}",
config.url()
);
}
remove_var("REDIS_USER");
remove_var("REDIS_PASSWORD");
}
#[test]
fn test_a_real_credential_reaches_the_url_unchanged() {
let _guard = match ENV_MUTEX.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
remove_var("REDIS_HOST");
remove_var("REDIS_PORT");
remove_var("REDIS_DB");
remove_var("REDIS_TIMEOUT");
remove_var("REDIS_CONNECT_TIMEOUT");
remove_var("REDIS_USER");
set_var("REDIS_PASSWORD", "s3cr:t@pass");
assert_eq!(
RedisConfig::default().password,
Some("s3cr:t@pass".to_string()),
"a real password must reach the config untouched"
);
set_var("REDIS_PASSWORD", "s3cret");
let config = RedisConfig::default();
assert!(
config.url().contains("s3cret"),
"the password must reach the URL, got {}",
config.url()
);
remove_var("REDIS_PASSWORD");
}
#[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)"
);
}
fn credentials_of(url: &str) -> Option<(Option<String>, Option<String>)> {
let client = redis::Client::open(url).ok()?;
let settings = client.get_connection_info().redis_settings();
Some((
settings.username().map(ToString::to_string),
settings.password().map(ToString::to_string),
))
}
#[test]
fn test_a_punctuated_credential_round_trips_through_the_url() {
for password in [
"s3cr:t@pass",
"p/secret",
"with#hash",
"with?query",
"with space",
"100%pure",
"everything: /#?@% and more",
] {
let config = RedisConfig {
host: "localhost".to_string(),
port: 6379,
username: Some(password.to_string()),
password: Some(password.to_string()),
database: 0,
timeout: 30,
connect_timeout: 5,
};
assert_eq!(
credentials_of(&config.url()),
Some((Some(password.to_string()), Some(password.to_string()))),
"{password:?} did not survive the URL: {}",
config.url()
);
}
}
#[test]
fn test_a_slash_in_a_credential_leaves_the_database_alone() {
let config = RedisConfig {
host: "localhost".to_string(),
port: 6379,
username: None,
password: Some("p/7/secret".to_string()),
database: 3,
timeout: 30,
connect_timeout: 5,
};
let client = match redis::Client::open(config.url()) {
Ok(client) => client,
Err(error) => panic!("the URL must parse: {error}, url {}", config.url()),
};
let info = client.get_connection_info();
assert_eq!(
info.redis_settings().db(),
3,
"the configured database must survive"
);
assert_eq!(info.redis_settings().password(), Some("p/7/secret"));
}
#[test]
fn test_an_unreserved_credential_is_untouched() {
let config = RedisConfig {
host: "redis.internal".to_string(),
port: 6380,
username: Some("admin".to_string()),
password: Some("s3cret-pass.word_v2~1".to_string()),
database: 2,
timeout: 30,
connect_timeout: 5,
};
assert_eq!(
config.url(),
"redis://admin:s3cret-pass.word_v2~1@redis.internal:6380/2"
);
}
#[test]
fn test_the_encoder_escapes_only_the_reserved() {
assert_eq!(encode_userinfo("aZ09-._~"), "aZ09-._~");
assert_eq!(encode_userinfo("a/b"), "a%2Fb");
assert_eq!(encode_userinfo("a@b"), "a%40b");
assert_eq!(encode_userinfo("a:b"), "a%3Ab");
assert_eq!(encode_userinfo("a b"), "a%20b");
assert_eq!(encode_userinfo("%40"), "%2540");
assert_eq!(encode_userinfo("ñ"), "%C3%B1");
}
#[test]
fn test_the_redaction_covers_the_encoded_form() {
let config = RedisConfig {
host: "localhost".to_string(),
port: 6379,
username: Some("adm@in".to_string()),
password: Some("p@ss/word".to_string()),
database: 0,
timeout: 30,
connect_timeout: 5,
};
let redacted = crate::infrastructure::config::redact_userinfo(&config.url());
assert_eq!(
redacted, "redis://***@localhost:6379",
"nothing of either credential may survive"
);
}
#[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_eq!(
credentials_of(&config.url()),
Some((Some("user".to_string()), Some(pw.to_string()))),
"the URL must parse back to the credential"
);
}
}
#[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);
}
}