praxis_policy_session_valkey/
config.rs1use serde::Deserialize;
11
12use crate::error::BuildError;
13
14fn default_key_prefix() -> String {
17 "taint:v1".to_owned()
18}
19
20fn default_connect_timeout_ms() -> u64 {
24 250
25}
26fn default_command_timeout_ms() -> u64 {
27 500
28}
29
30#[derive(Debug, Clone, Deserialize)]
35pub struct ValkeyConfig {
36 pub endpoint: String,
38
39 #[serde(default)]
42 pub tls: bool,
43
44 #[serde(default)]
46 pub username: Option<String>,
47
48 #[serde(default)]
51 pub password: Option<String>,
52
53 #[serde(default = "default_key_prefix")]
55 pub key_prefix: String,
56
57 #[serde(default)]
60 pub ttl_seconds: Option<u64>,
61
62 #[serde(default)]
65 pub max_session_lifetime_seconds: Option<u64>,
66
67 #[serde(default = "default_connect_timeout_ms")]
69 pub connect_timeout_ms: u64,
70
71 #[serde(default = "default_command_timeout_ms")]
73 pub command_timeout_ms: u64,
74 }
80
81impl ValkeyConfig {
82 pub fn from_value(value: &serde_yaml::Value) -> Result<Self, BuildError> {
89 let cfg: ValkeyConfig =
90 serde_yaml::from_value(value.clone()).map_err(|e| BuildError::Config(e.to_string()))?;
91 cfg.validate()?;
92 Ok(cfg)
93 }
94
95 fn validate(&self) -> Result<(), BuildError> {
103 if self.tls && self.endpoint.starts_with("redis://") {
108 return Err(BuildError::Config(format!(
109 "`tls: true` conflicts with the plaintext `redis://` scheme in endpoint '{}'; \
110 use a `rediss://` URL or a bare host:port",
111 redact_endpoint(&self.endpoint)
112 )));
113 }
114
115 if !self.tls_enabled() && !endpoint_is_localhost(&self.endpoint) {
116 return Err(BuildError::TlsRequired(redact_endpoint(&self.endpoint)));
117 }
118
119 let endpoint_is_url =
127 self.endpoint.starts_with("redis://") || self.endpoint.starts_with("rediss://");
128 if endpoint_is_url && (self.username.is_some() || self.password.is_some()) {
129 return Err(BuildError::Config(format!(
130 "endpoint '{}' is a full URL; put credentials in the URL userinfo \
131 (rediss://user:pass@host) or use a bare host:port — the separate \
132 `username`/`password` fields are ignored for URL endpoints",
133 redact_endpoint(&self.endpoint)
134 )));
135 }
136
137 if self.username.is_some() && self.password.is_none() {
140 return Err(BuildError::Config(
141 "`username` is set without a `password`; supply a `password` for the ACL \
142 user, or remove `username` to connect as the default user"
143 .to_owned(),
144 ));
145 }
146
147 self.connection_url()?;
150
151 if let Some(ttl) = self.ttl_seconds
158 && i64::try_from(ttl).is_err()
159 {
160 return Err(BuildError::Config(format!(
161 "`ttl_seconds` of {ttl} exceeds the maximum valkey accepts ({}); a TTL that \
162 large cannot be expressed as an expiry and would delete the session key \
163 immediately",
164 i64::MAX
165 )));
166 }
167
168 if let (Some(ttl), Some(life)) = (self.ttl_seconds, self.max_session_lifetime_seconds)
169 && ttl < life
170 {
171 tracing::warn!(
172 alarm = "session_store_ttl_unsound",
173 ttl_seconds = ttl,
174 max_session_lifetime_seconds = life,
175 "valkey session_store TTL is shorter than the declared max session lifetime; \
176 accumulated taint can silently expire (downgrade-by-waiting)"
177 );
178 }
179 Ok(())
180 }
181
182 pub fn tls_enabled(&self) -> bool {
184 self.tls || self.endpoint.starts_with("rediss://")
185 }
186
187 pub fn connection_url(&self) -> Result<String, BuildError> {
201 if self.endpoint.starts_with("redis://") || self.endpoint.starts_with("rediss://") {
202 let url = url::Url::parse(&self.endpoint).map_err(|e| {
206 BuildError::Config(format!(
207 "invalid endpoint URL '{}': {e}",
208 redact_endpoint(&self.endpoint)
209 ))
210 })?;
211 return Ok(url.to_string());
212 }
213
214 let scheme = if self.tls_enabled() {
215 "rediss"
216 } else {
217 "redis"
218 };
219 let mut url = url::Url::parse(&format!("{scheme}://{}", self.endpoint)).map_err(|e| {
220 BuildError::Config(format!(
221 "invalid endpoint '{}': {e}",
222 redact_endpoint(&self.endpoint)
223 ))
224 })?;
225 if self.username.is_some() || self.password.is_some() {
229 url.set_username(self.username.as_deref().unwrap_or(""))
232 .map_err(|()| BuildError::Config("endpoint cannot carry credentials".to_owned()))?;
233 if let Some(password) = &self.password {
234 url.set_password(Some(password)).map_err(|()| {
235 BuildError::Config("endpoint cannot carry credentials".to_owned())
236 })?;
237 }
238 }
239 Ok(url.to_string())
240 }
241}
242
243fn redact_endpoint(endpoint: &str) -> String {
246 if let Some((scheme, after)) = endpoint.split_once("://") {
247 if let Some((_userinfo, host)) = after.rsplit_once('@') {
248 return format!("{scheme}://***@{host}");
249 }
250 return endpoint.to_owned();
251 }
252 if let Some((_userinfo, host)) = endpoint.rsplit_once('@') {
254 return format!("***@{host}");
255 }
256 endpoint.to_owned()
257}
258
259fn endpoint_is_localhost(endpoint: &str) -> bool {
262 let no_scheme = endpoint
263 .strip_prefix("rediss://")
264 .or_else(|| endpoint.strip_prefix("redis://"))
265 .unwrap_or(endpoint);
266 let host_port = no_scheme.rsplit('@').next().unwrap_or(no_scheme);
268 if host_port.starts_with("[::1]") {
270 return true;
271 }
272 let host = host_port.split(':').next().unwrap_or(host_port);
273 matches!(host, "localhost" | "127.0.0.1" | "::1")
274}
275
276#[cfg(test)]
277#[allow(clippy::expect_used, clippy::unwrap_used, reason = "tests")]
278mod tests {
279 use super::*;
280
281 fn parse(yaml: &str) -> Result<ValkeyConfig, BuildError> {
282 let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
283 ValkeyConfig::from_value(&v)
284 }
285
286 #[test]
287 fn localhost_without_tls_is_allowed() {
288 let cfg = parse("kind: valkey\nendpoint: localhost:6379\n").unwrap();
289 assert_eq!(cfg.key_prefix, "taint:v1");
290 assert_eq!(cfg.connect_timeout_ms, 250);
291 assert_eq!(cfg.command_timeout_ms, 500);
292 assert!(
293 cfg.connection_url()
294 .unwrap()
295 .starts_with("redis://localhost:6379")
296 );
297 }
298
299 #[test]
304 fn ttl_seconds_beyond_i64_is_rejected() {
305 let err =
306 parse("kind: valkey\nendpoint: localhost:6379\nttl_seconds: 18446744073709551615\n")
307 .unwrap_err();
308 let msg = format!("{err:?}");
309 assert!(
310 msg.contains("ttl_seconds"),
311 "error should name the offending field: {msg}"
312 );
313 }
314
315 #[test]
316 fn ttl_seconds_at_the_i64_boundary_is_accepted() {
317 let yaml = format!(
318 "kind: valkey\nendpoint: localhost:6379\nttl_seconds: {}\n",
319 i64::MAX
320 );
321 let cfg = parse(&yaml).expect("a TTL that fits in i64 must be accepted");
322 assert_eq!(cfg.ttl_seconds, Some(i64::MAX as u64));
323 }
324
325 #[test]
326 fn non_localhost_without_tls_is_rejected() {
327 let err = parse("kind: valkey\nendpoint: valkey.prod.internal:6379\n").unwrap_err();
328 assert!(matches!(err, BuildError::TlsRequired(_)), "got {err:?}");
329 }
330
331 #[test]
332 fn non_localhost_with_tls_uses_rediss_scheme() {
333 let cfg = parse("kind: valkey\nendpoint: valkey.prod.internal:6379\ntls: true\n").unwrap();
334 assert!(cfg.tls_enabled());
335 assert!(
336 cfg.connection_url()
337 .unwrap()
338 .starts_with("rediss://valkey.prod.internal:6379")
339 );
340 }
341
342 #[test]
343 fn rediss_scheme_implies_tls() {
344 let cfg = parse("kind: valkey\nendpoint: rediss://valkey.prod.internal:6379\n").unwrap();
345 assert!(cfg.tls_enabled());
346 assert!(cfg.connection_url().unwrap().starts_with("rediss://"));
347 }
348
349 #[test]
353 fn tls_true_with_plaintext_scheme_is_rejected() {
354 let err = parse("kind: valkey\nendpoint: redis://valkey.prod.internal:6379\ntls: true\n")
355 .unwrap_err();
356 assert!(matches!(err, BuildError::Config(_)), "got {err:?}");
357 }
358
359 #[test]
360 fn credentials_are_percent_encoded_in_url() {
361 let cfg = parse(
364 "kind: valkey\nendpoint: valkey.prod.internal:6379\ntls: true\nusername: gw\npassword: \"p@ss:w/rd\"\n",
365 )
366 .unwrap();
367 let url = cfg.connection_url().unwrap();
368 assert!(url.starts_with("rediss://gw:"), "url: {url}");
369 assert!(url.contains("@valkey.prod.internal:6379"), "url: {url}");
370 assert!(
372 url.contains("p%40ss"),
373 "password '@' must be encoded: {url}"
374 );
375 }
376
377 #[test]
380 fn username_without_password_is_rejected() {
381 let err = parse(
382 "kind: valkey\nendpoint: valkey.prod.internal:6379\ntls: true\nusername: gateway\n",
383 )
384 .unwrap_err();
385 assert!(matches!(err, BuildError::Config(_)), "got {err:?}");
386 }
387
388 #[test]
391 fn url_endpoint_with_separate_credentials_is_rejected() {
392 let err = parse(
393 "kind: valkey\nendpoint: rediss://valkey.prod.internal:6379\nusername: gw\npassword: s3cret\n",
394 )
395 .unwrap_err();
396 assert!(matches!(err, BuildError::Config(_)), "got {err:?}");
397 }
398
399 #[test]
402 fn password_without_username_uses_default_user() {
403 let cfg = parse("kind: valkey\nendpoint: localhost:6379\npassword: s3cret\n").unwrap();
404 let url = cfg.connection_url().unwrap();
405 assert!(
406 url.starts_with("redis://:s3cret@localhost:6379"),
407 "url: {url}"
408 );
409 }
410
411 #[test]
412 fn missing_endpoint_is_config_error() {
413 let err = parse("kind: valkey\n").unwrap_err();
414 assert!(matches!(err, BuildError::Config(_)), "got {err:?}");
415 }
416
417 #[test]
418 fn ipv6_loopback_without_tls_is_allowed() {
419 let cfg = parse("kind: valkey\nendpoint: \"[::1]:6379\"\n").unwrap();
420 assert!(!cfg.tls_enabled());
421 }
422
423 #[test]
424 fn redact_endpoint_strips_userinfo() {
425 assert_eq!(
426 redact_endpoint("rediss://user:secret@host:6379"),
427 "rediss://***@host:6379"
428 );
429 assert_eq!(redact_endpoint("host:6379"), "host:6379");
430 }
431
432 #[test]
434 fn tls_required_error_redacts_credentials() {
435 let err =
437 parse("kind: valkey\nendpoint: redis://user:topsecret@prod.host:6379\n").unwrap_err();
438 let msg = format!("{err}");
439 assert!(
440 !msg.contains("topsecret"),
441 "error leaked credentials: {msg}"
442 );
443 }
444}