use serde::Deserialize;
use crate::error::BuildError;
fn default_key_prefix() -> String {
"taint:v1".to_string()
}
fn default_connect_timeout_ms() -> u64 {
250
}
fn default_command_timeout_ms() -> u64 {
500
}
#[derive(Debug, Clone, Deserialize)]
pub struct ValkeyConfig {
pub endpoint: String,
#[serde(default)]
pub tls: bool,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password: Option<String>,
#[serde(default = "default_key_prefix")]
pub key_prefix: String,
#[serde(default)]
pub ttl_seconds: Option<u64>,
#[serde(default)]
pub max_session_lifetime_seconds: Option<u64>,
#[serde(default = "default_connect_timeout_ms")]
pub connect_timeout_ms: u64,
#[serde(default = "default_command_timeout_ms")]
pub command_timeout_ms: u64,
}
impl ValkeyConfig {
pub fn from_value(value: &serde_yaml::Value) -> Result<Self, BuildError> {
let cfg: ValkeyConfig =
serde_yaml::from_value(value.clone()).map_err(|e| BuildError::Config(e.to_string()))?;
cfg.validate()?;
Ok(cfg)
}
fn validate(&self) -> Result<(), BuildError> {
if self.tls && self.endpoint.starts_with("redis://") {
return Err(BuildError::Config(format!(
"`tls: true` conflicts with the plaintext `redis://` scheme in endpoint '{}'; \
use a `rediss://` URL or a bare host:port",
redact_endpoint(&self.endpoint)
)));
}
if !self.tls_enabled() && !endpoint_is_localhost(&self.endpoint) {
return Err(BuildError::TlsRequired(redact_endpoint(&self.endpoint)));
}
let endpoint_is_url =
self.endpoint.starts_with("redis://") || self.endpoint.starts_with("rediss://");
if endpoint_is_url && (self.username.is_some() || self.password.is_some()) {
return Err(BuildError::Config(format!(
"endpoint '{}' is a full URL; put credentials in the URL userinfo \
(rediss://user:pass@host) or use a bare host:port — the separate \
`username`/`password` fields are ignored for URL endpoints",
redact_endpoint(&self.endpoint)
)));
}
if self.username.is_some() && self.password.is_none() {
return Err(BuildError::Config(
"`username` is set without a `password`; supply a `password` for the ACL \
user, or remove `username` to connect as the default user"
.to_string(),
));
}
self.connection_url()?;
if let (Some(ttl), Some(life)) = (self.ttl_seconds, self.max_session_lifetime_seconds) {
if ttl < life {
tracing::warn!(
alarm = "session_store_ttl_unsound",
ttl_seconds = ttl,
max_session_lifetime_seconds = life,
"valkey session_store TTL is shorter than the declared max session lifetime; \
accumulated taint can silently expire (downgrade-by-waiting)"
);
}
}
Ok(())
}
pub fn tls_enabled(&self) -> bool {
self.tls || self.endpoint.starts_with("rediss://")
}
pub fn connection_url(&self) -> Result<String, BuildError> {
if self.endpoint.starts_with("redis://") || self.endpoint.starts_with("rediss://") {
let url = url::Url::parse(&self.endpoint).map_err(|e| {
BuildError::Config(format!(
"invalid endpoint URL '{}': {e}",
redact_endpoint(&self.endpoint)
))
})?;
return Ok(url.to_string());
}
let scheme = if self.tls_enabled() {
"rediss"
} else {
"redis"
};
let mut url = url::Url::parse(&format!("{scheme}://{}", self.endpoint)).map_err(|e| {
BuildError::Config(format!(
"invalid endpoint '{}': {e}",
redact_endpoint(&self.endpoint)
))
})?;
if self.username.is_some() || self.password.is_some() {
url.set_username(self.username.as_deref().unwrap_or(""))
.map_err(|_| BuildError::Config("endpoint cannot carry credentials".to_string()))?;
if let Some(password) = &self.password {
url.set_password(Some(password)).map_err(|_| {
BuildError::Config("endpoint cannot carry credentials".to_string())
})?;
}
}
Ok(url.to_string())
}
}
fn redact_endpoint(endpoint: &str) -> String {
if let Some(scheme_end) = endpoint.find("://") {
let (scheme, after) = (&endpoint[..scheme_end], &endpoint[scheme_end + 3..]);
if let Some(at) = after.rfind('@') {
return format!("{scheme}://***@{}", &after[at + 1..]);
}
return endpoint.to_string();
}
if let Some(at) = endpoint.rfind('@') {
return format!("***@{}", &endpoint[at + 1..]);
}
endpoint.to_string()
}
fn endpoint_is_localhost(endpoint: &str) -> bool {
let no_scheme = endpoint
.strip_prefix("rediss://")
.or_else(|| endpoint.strip_prefix("redis://"))
.unwrap_or(endpoint);
let host_port = no_scheme.rsplit('@').next().unwrap_or(no_scheme);
if host_port.starts_with("[::1]") {
return true;
}
let host = host_port.split(':').next().unwrap_or(host_port);
matches!(host, "localhost" | "127.0.0.1" | "::1")
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(yaml: &str) -> Result<ValkeyConfig, BuildError> {
let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
ValkeyConfig::from_value(&v)
}
#[test]
fn localhost_without_tls_is_allowed() {
let cfg = parse("kind: valkey\nendpoint: localhost:6379\n").unwrap();
assert_eq!(cfg.key_prefix, "taint:v1");
assert_eq!(cfg.connect_timeout_ms, 250);
assert_eq!(cfg.command_timeout_ms, 500);
assert!(cfg
.connection_url()
.unwrap()
.starts_with("redis://localhost:6379"));
}
#[test]
fn non_localhost_without_tls_is_rejected() {
let err = parse("kind: valkey\nendpoint: valkey.prod.internal:6379\n").unwrap_err();
assert!(matches!(err, BuildError::TlsRequired(_)), "got {err:?}");
}
#[test]
fn non_localhost_with_tls_uses_rediss_scheme() {
let cfg = parse("kind: valkey\nendpoint: valkey.prod.internal:6379\ntls: true\n").unwrap();
assert!(cfg.tls_enabled());
assert!(cfg
.connection_url()
.unwrap()
.starts_with("rediss://valkey.prod.internal:6379"));
}
#[test]
fn rediss_scheme_implies_tls() {
let cfg = parse("kind: valkey\nendpoint: rediss://valkey.prod.internal:6379\n").unwrap();
assert!(cfg.tls_enabled());
assert!(cfg.connection_url().unwrap().starts_with("rediss://"));
}
#[test]
fn tls_true_with_plaintext_scheme_is_rejected() {
let err = parse("kind: valkey\nendpoint: redis://valkey.prod.internal:6379\ntls: true\n")
.unwrap_err();
assert!(matches!(err, BuildError::Config(_)), "got {err:?}");
}
#[test]
fn credentials_are_percent_encoded_in_url() {
let cfg = parse(
"kind: valkey\nendpoint: valkey.prod.internal:6379\ntls: true\nusername: gw\npassword: \"p@ss:w/rd\"\n",
)
.unwrap();
let url = cfg.connection_url().unwrap();
assert!(url.starts_with("rediss://gw:"), "url: {url}");
assert!(url.contains("@valkey.prod.internal:6379"), "url: {url}");
assert!(
url.contains("p%40ss"),
"password '@' must be encoded: {url}"
);
}
#[test]
fn username_without_password_is_rejected() {
let err = parse(
"kind: valkey\nendpoint: valkey.prod.internal:6379\ntls: true\nusername: gateway\n",
)
.unwrap_err();
assert!(matches!(err, BuildError::Config(_)), "got {err:?}");
}
#[test]
fn url_endpoint_with_separate_credentials_is_rejected() {
let err = parse(
"kind: valkey\nendpoint: rediss://valkey.prod.internal:6379\nusername: gw\npassword: s3cret\n",
)
.unwrap_err();
assert!(matches!(err, BuildError::Config(_)), "got {err:?}");
}
#[test]
fn password_without_username_uses_default_user() {
let cfg = parse("kind: valkey\nendpoint: localhost:6379\npassword: s3cret\n").unwrap();
let url = cfg.connection_url().unwrap();
assert!(
url.starts_with("redis://:s3cret@localhost:6379"),
"url: {url}"
);
}
#[test]
fn missing_endpoint_is_config_error() {
let err = parse("kind: valkey\n").unwrap_err();
assert!(matches!(err, BuildError::Config(_)), "got {err:?}");
}
#[test]
fn ipv6_loopback_without_tls_is_allowed() {
let cfg = parse("kind: valkey\nendpoint: \"[::1]:6379\"\n").unwrap();
assert!(!cfg.tls_enabled());
}
#[test]
fn redact_endpoint_strips_userinfo() {
assert_eq!(
redact_endpoint("rediss://user:secret@host:6379"),
"rediss://***@host:6379"
);
assert_eq!(redact_endpoint("host:6379"), "host:6379");
}
#[test]
fn tls_required_error_redacts_credentials() {
let err =
parse("kind: valkey\nendpoint: redis://user:topsecret@prod.host:6379\n").unwrap_err();
let msg = format!("{err}");
assert!(
!msg.contains("topsecret"),
"error leaked credentials: {msg}"
);
}
}