cpex_session_valkey/
config.rs1use serde::Deserialize;
13
14use crate::error::BuildError;
15
16fn default_key_prefix() -> String {
19 "taint:v1".to_string()
20}
21
22fn default_connect_timeout_ms() -> u64 {
26 250
27}
28fn default_command_timeout_ms() -> u64 {
29 500
30}
31
32#[derive(Debug, Clone, Deserialize)]
37pub struct ValkeyConfig {
38 pub endpoint: String,
40
41 #[serde(default)]
44 pub tls: bool,
45
46 #[serde(default)]
48 pub username: Option<String>,
49
50 #[serde(default)]
53 pub password: Option<String>,
54
55 #[serde(default = "default_key_prefix")]
57 pub key_prefix: String,
58
59 #[serde(default)]
62 pub ttl_seconds: Option<u64>,
63
64 #[serde(default)]
67 pub max_session_lifetime_seconds: Option<u64>,
68
69 #[serde(default = "default_connect_timeout_ms")]
71 pub connect_timeout_ms: u64,
72
73 #[serde(default = "default_command_timeout_ms")]
75 pub command_timeout_ms: u64,
76 }
82
83impl ValkeyConfig {
84 pub fn from_value(value: &serde_yaml::Value) -> Result<Self, BuildError> {
86 let cfg: ValkeyConfig =
87 serde_yaml::from_value(value.clone()).map_err(|e| BuildError::Config(e.to_string()))?;
88 cfg.validate()?;
89 Ok(cfg)
90 }
91
92 fn validate(&self) -> Result<(), BuildError> {
100 if self.tls && self.endpoint.starts_with("redis://") {
105 return Err(BuildError::Config(format!(
106 "`tls: true` conflicts with the plaintext `redis://` scheme in endpoint '{}'; \
107 use a `rediss://` URL or a bare host:port",
108 redact_endpoint(&self.endpoint)
109 )));
110 }
111
112 if !self.tls_enabled() && !endpoint_is_localhost(&self.endpoint) {
113 return Err(BuildError::TlsRequired(redact_endpoint(&self.endpoint)));
114 }
115
116 let endpoint_is_url =
124 self.endpoint.starts_with("redis://") || self.endpoint.starts_with("rediss://");
125 if endpoint_is_url && (self.username.is_some() || self.password.is_some()) {
126 return Err(BuildError::Config(format!(
127 "endpoint '{}' is a full URL; put credentials in the URL userinfo \
128 (rediss://user:pass@host) or use a bare host:port — the separate \
129 `username`/`password` fields are ignored for URL endpoints",
130 redact_endpoint(&self.endpoint)
131 )));
132 }
133
134 if self.username.is_some() && self.password.is_none() {
137 return Err(BuildError::Config(
138 "`username` is set without a `password`; supply a `password` for the ACL \
139 user, or remove `username` to connect as the default user"
140 .to_string(),
141 ));
142 }
143
144 self.connection_url()?;
147
148 if let (Some(ttl), Some(life)) = (self.ttl_seconds, self.max_session_lifetime_seconds) {
149 if ttl < life {
150 tracing::warn!(
151 alarm = "session_store_ttl_unsound",
152 ttl_seconds = ttl,
153 max_session_lifetime_seconds = life,
154 "valkey session_store TTL is shorter than the declared max session lifetime; \
155 accumulated taint can silently expire (downgrade-by-waiting)"
156 );
157 }
158 }
159 Ok(())
160 }
161
162 pub fn tls_enabled(&self) -> bool {
164 self.tls || self.endpoint.starts_with("rediss://")
165 }
166
167 pub fn connection_url(&self) -> Result<String, BuildError> {
176 if self.endpoint.starts_with("redis://") || self.endpoint.starts_with("rediss://") {
177 let url = url::Url::parse(&self.endpoint).map_err(|e| {
181 BuildError::Config(format!(
182 "invalid endpoint URL '{}': {e}",
183 redact_endpoint(&self.endpoint)
184 ))
185 })?;
186 return Ok(url.to_string());
187 }
188
189 let scheme = if self.tls_enabled() {
190 "rediss"
191 } else {
192 "redis"
193 };
194 let mut url = url::Url::parse(&format!("{scheme}://{}", self.endpoint)).map_err(|e| {
195 BuildError::Config(format!(
196 "invalid endpoint '{}': {e}",
197 redact_endpoint(&self.endpoint)
198 ))
199 })?;
200 if self.username.is_some() || self.password.is_some() {
204 url.set_username(self.username.as_deref().unwrap_or(""))
207 .map_err(|_| BuildError::Config("endpoint cannot carry credentials".to_string()))?;
208 if let Some(password) = &self.password {
209 url.set_password(Some(password)).map_err(|_| {
210 BuildError::Config("endpoint cannot carry credentials".to_string())
211 })?;
212 }
213 }
214 Ok(url.to_string())
215 }
216}
217
218fn redact_endpoint(endpoint: &str) -> String {
221 if let Some(scheme_end) = endpoint.find("://") {
222 let (scheme, after) = (&endpoint[..scheme_end], &endpoint[scheme_end + 3..]);
223 if let Some(at) = after.rfind('@') {
224 return format!("{scheme}://***@{}", &after[at + 1..]);
225 }
226 return endpoint.to_string();
227 }
228 if let Some(at) = endpoint.rfind('@') {
230 return format!("***@{}", &endpoint[at + 1..]);
231 }
232 endpoint.to_string()
233}
234
235fn endpoint_is_localhost(endpoint: &str) -> bool {
238 let no_scheme = endpoint
239 .strip_prefix("rediss://")
240 .or_else(|| endpoint.strip_prefix("redis://"))
241 .unwrap_or(endpoint);
242 let host_port = no_scheme.rsplit('@').next().unwrap_or(no_scheme);
244 if host_port.starts_with("[::1]") {
246 return true;
247 }
248 let host = host_port.split(':').next().unwrap_or(host_port);
249 matches!(host, "localhost" | "127.0.0.1" | "::1")
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 fn parse(yaml: &str) -> Result<ValkeyConfig, BuildError> {
257 let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
258 ValkeyConfig::from_value(&v)
259 }
260
261 #[test]
262 fn localhost_without_tls_is_allowed() {
263 let cfg = parse("kind: valkey\nendpoint: localhost:6379\n").unwrap();
264 assert_eq!(cfg.key_prefix, "taint:v1");
265 assert_eq!(cfg.connect_timeout_ms, 250);
266 assert_eq!(cfg.command_timeout_ms, 500);
267 assert!(cfg
268 .connection_url()
269 .unwrap()
270 .starts_with("redis://localhost:6379"));
271 }
272
273 #[test]
274 fn non_localhost_without_tls_is_rejected() {
275 let err = parse("kind: valkey\nendpoint: valkey.prod.internal:6379\n").unwrap_err();
276 assert!(matches!(err, BuildError::TlsRequired(_)), "got {err:?}");
277 }
278
279 #[test]
280 fn non_localhost_with_tls_uses_rediss_scheme() {
281 let cfg = parse("kind: valkey\nendpoint: valkey.prod.internal:6379\ntls: true\n").unwrap();
282 assert!(cfg.tls_enabled());
283 assert!(cfg
284 .connection_url()
285 .unwrap()
286 .starts_with("rediss://valkey.prod.internal:6379"));
287 }
288
289 #[test]
290 fn rediss_scheme_implies_tls() {
291 let cfg = parse("kind: valkey\nendpoint: rediss://valkey.prod.internal:6379\n").unwrap();
292 assert!(cfg.tls_enabled());
293 assert!(cfg.connection_url().unwrap().starts_with("rediss://"));
294 }
295
296 #[test]
300 fn tls_true_with_plaintext_scheme_is_rejected() {
301 let err = parse("kind: valkey\nendpoint: redis://valkey.prod.internal:6379\ntls: true\n")
302 .unwrap_err();
303 assert!(matches!(err, BuildError::Config(_)), "got {err:?}");
304 }
305
306 #[test]
307 fn credentials_are_percent_encoded_in_url() {
308 let cfg = parse(
311 "kind: valkey\nendpoint: valkey.prod.internal:6379\ntls: true\nusername: gw\npassword: \"p@ss:w/rd\"\n",
312 )
313 .unwrap();
314 let url = cfg.connection_url().unwrap();
315 assert!(url.starts_with("rediss://gw:"), "url: {url}");
316 assert!(url.contains("@valkey.prod.internal:6379"), "url: {url}");
317 assert!(
319 url.contains("p%40ss"),
320 "password '@' must be encoded: {url}"
321 );
322 }
323
324 #[test]
327 fn username_without_password_is_rejected() {
328 let err = parse(
329 "kind: valkey\nendpoint: valkey.prod.internal:6379\ntls: true\nusername: gateway\n",
330 )
331 .unwrap_err();
332 assert!(matches!(err, BuildError::Config(_)), "got {err:?}");
333 }
334
335 #[test]
338 fn url_endpoint_with_separate_credentials_is_rejected() {
339 let err = parse(
340 "kind: valkey\nendpoint: rediss://valkey.prod.internal:6379\nusername: gw\npassword: s3cret\n",
341 )
342 .unwrap_err();
343 assert!(matches!(err, BuildError::Config(_)), "got {err:?}");
344 }
345
346 #[test]
349 fn password_without_username_uses_default_user() {
350 let cfg = parse("kind: valkey\nendpoint: localhost:6379\npassword: s3cret\n").unwrap();
351 let url = cfg.connection_url().unwrap();
352 assert!(
353 url.starts_with("redis://:s3cret@localhost:6379"),
354 "url: {url}"
355 );
356 }
357
358 #[test]
359 fn missing_endpoint_is_config_error() {
360 let err = parse("kind: valkey\n").unwrap_err();
361 assert!(matches!(err, BuildError::Config(_)), "got {err:?}");
362 }
363
364 #[test]
365 fn ipv6_loopback_without_tls_is_allowed() {
366 let cfg = parse("kind: valkey\nendpoint: \"[::1]:6379\"\n").unwrap();
367 assert!(!cfg.tls_enabled());
368 }
369
370 #[test]
371 fn redact_endpoint_strips_userinfo() {
372 assert_eq!(
373 redact_endpoint("rediss://user:secret@host:6379"),
374 "rediss://***@host:6379"
375 );
376 assert_eq!(redact_endpoint("host:6379"), "host:6379");
377 }
378
379 #[test]
381 fn tls_required_error_redacts_credentials() {
382 let err =
384 parse("kind: valkey\nendpoint: redis://user:topsecret@prod.host:6379\n").unwrap_err();
385 let msg = format!("{err}");
386 assert!(
387 !msg.contains("topsecret"),
388 "error leaked credentials: {msg}"
389 );
390 }
391}