Skip to main content

cpex_session_valkey/
config.rs

1// Location: ./builtins/session/valkey/src/config.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Fred Araujo
5//
6// Parses and validates the `global.apl.session_store` block for the
7// Valkey backend. Deliberately minimal: a single endpoint, TLS,
8// auth, key prefix, optional sliding TTL, and fail-closed timeout/retry
9// knobs with committed safe defaults. Sentinel/Cluster fields are NOT
10// present — they are out of scope and would be dead config surface.
11
12use serde::Deserialize;
13
14use crate::error::BuildError;
15
16/// Default key prefix/namespace for the label keyspace. The `v1` segment
17/// lets a future value-schema change bump the namespace cleanly.
18fn default_key_prefix() -> String {
19    "taint:v1".to_string()
20}
21
22// Committed fail-closed defaults (see plan Key Technical Decisions). They
23// ship in code so behavior and tests are deterministic; operators tune
24// from this baseline.
25fn default_connect_timeout_ms() -> u64 {
26    250
27}
28fn default_command_timeout_ms() -> u64 {
29    500
30}
31
32/// Parsed `global.apl.session_store` config for `kind: valkey`.
33///
34/// Unknown keys (including `kind`, consumed by the factory dispatch) are
35/// ignored so the same block can carry the discriminator.
36#[derive(Debug, Clone, Deserialize)]
37pub struct ValkeyConfig {
38    /// Endpoint: a `redis://` / `rediss://` URL or a bare `host:port`.
39    pub endpoint: String,
40
41    /// Whether to use TLS. Implied `true` for a `rediss://` endpoint.
42    /// Required for any non-localhost endpoint (validated).
43    #[serde(default)]
44    pub tls: bool,
45
46    /// Optional ACL username (Valkey 6+ ACLs). Paired with `password`.
47    #[serde(default)]
48    pub username: Option<String>,
49
50    /// Optional auth password / ACL secret. Sourced from config/env by
51    /// the operator; never hard-coded.
52    #[serde(default)]
53    pub password: Option<String>,
54
55    /// Key prefix/namespace for label keys.
56    #[serde(default = "default_key_prefix")]
57    pub key_prefix: String,
58
59    /// Sliding TTL in seconds, refreshed on load and append. `None`
60    /// (default) means no expiry.
61    #[serde(default)]
62    pub ttl_seconds: Option<u64>,
63
64    /// Declared maximum session-identity lifetime, used only to emit the
65    /// TTL-soundness warning when `ttl_seconds` is shorter.
66    #[serde(default)]
67    pub max_session_lifetime_seconds: Option<u64>,
68
69    /// Connection acquisition timeout (ms).
70    #[serde(default = "default_connect_timeout_ms")]
71    pub connect_timeout_ms: u64,
72
73    /// Per-command response timeout (ms) — the fail-closed hot-path knob.
74    #[serde(default = "default_command_timeout_ms")]
75    pub command_timeout_ms: u64,
76    // NOTE: bounded retry + circuit-breaker are deliberately NOT implemented
77    // in v0 (deferred follow-up). The store fails closed on the first
78    // backend error, which is safe — it just fails faster. A `max_retries`
79    // knob is intentionally absent rather than present-but-dead, so config
80    // never advertises behavior the code doesn't have.
81}
82
83impl ValkeyConfig {
84    /// Parse from the YAML config block, then validate.
85    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    /// Enforce the non-negotiable invariants. TLS is mandatory off
93    /// localhost; a `tls: true` + plaintext `redis://` scheme is a
94    /// contradiction (would connect in cleartext); the connection URL
95    /// must build; the TTL-soundness warning is emitted here.
96    ///
97    /// All error text routes the endpoint through [`redact_endpoint`] so
98    /// embedded credentials never leak into errors or logs.
99    fn validate(&self) -> Result<(), BuildError> {
100        // A fully-formed plaintext `redis://` endpoint with `tls: true`
101        // is contradictory: tls_enabled() would say "secure" while the
102        // explicit scheme forces cleartext. Reject rather than silently
103        // connecting in the clear.
104        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        // Credential-consistency checks, rejected loud at config-load rather
117        // than silently mis-connecting on first request.
118        //
119        // 1. A full `redis://`/`rediss://` endpoint carries its own
120        //    credentials; the separate `username`/`password` fields are
121        //    ignored for URL endpoints (connection_url returns early). Setting
122        //    both is ambiguous — force credentials into one place.
123        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        // 2. A `username` with no `password` would silently connect as the
135        //    default user with the username dropped. Reject the ambiguity.
136        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        // Build the URL now so a malformed endpoint / unencodable
145        // credential fails at config-load, not on first request.
146        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    /// TLS is on when explicitly set or implied by a `rediss://` scheme.
163    pub fn tls_enabled(&self) -> bool {
164        self.tls || self.endpoint.starts_with("rediss://")
165    }
166
167    /// Build the `redis`/`rediss` connection URL deadpool consumes.
168    ///
169    /// Credentials are percent-encoded via the `url` crate (never naive
170    /// string interpolation), and the wire scheme always reflects
171    /// [`Self::tls_enabled`] so it cannot disagree with the validated TLS
172    /// intent. A fully-formed endpoint URL is parsed (and trusted for its
173    /// own embedded credentials); a bare `host:port` is assembled with
174    /// the configured scheme and any separate `username`/`password`.
175    pub fn connection_url(&self) -> Result<String, BuildError> {
176        if self.endpoint.starts_with("redis://") || self.endpoint.starts_with("rediss://") {
177            // Validate it parses; trust the operator's embedded scheme +
178            // credentials. (validate() has already rejected the
179            // tls:true + redis:// contradiction.)
180            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        // Apply credentials when either is present. `validate()` guarantees a
201        // `username` is always paired with a `password`; a lone `password`
202        // (default-user AUTH) stays valid and sets an empty username.
203        if self.username.is_some() || self.password.is_some() {
204            // set_username/set_password percent-encode and reject hosts
205            // that cannot carry userinfo (e.g. cannot-be-a-base URLs).
206            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
218/// Strip any `userinfo` (`user:pass@`) from an endpoint before it appears
219/// in an error message or log line, so credentials are never disclosed.
220fn 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    // Bare host:port may still carry userinfo if misconfigured.
229    if let Some(at) = endpoint.rfind('@') {
230        return format!("***@{}", &endpoint[at + 1..]);
231    }
232    endpoint.to_string()
233}
234
235/// Best-effort localhost check for the TLS-required rule. Strips scheme,
236/// credentials, and port, then matches the common loopback hosts.
237fn 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    // Drop any credentials before the host.
243    let host_port = no_scheme.rsplit('@').next().unwrap_or(no_scheme);
244    // Bracketed IPv6 loopback, e.g. [::1]:6379.
245    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    /// Regression for the TLS-bypass finding: `tls: true` with an explicit
297    /// plaintext `redis://` scheme must be rejected, not silently connect
298    /// in the clear.
299    #[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        // A password with URL-significant characters must be encoded, not
309        // interpolated raw (which would corrupt the URL).
310        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        // The raw special chars must NOT appear unencoded in the userinfo.
318        assert!(
319            url.contains("p%40ss"),
320            "password '@' must be encoded: {url}"
321        );
322    }
323
324    /// Nit 1: a `username` with no `password` is ambiguous (would silently
325    /// connect as the default user). Reject it at config-load.
326    #[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    /// Nit 2: a full URL endpoint carries its own credentials; separate
336    /// `username`/`password` fields are ignored, so supplying both is rejected.
337    #[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    /// A lone `password` (no username) is the default-user AUTH case and stays
347    /// valid, producing `redis://:pass@host` (empty username).
348    #[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    /// Credentials must never leak into the TLS-required error.
380    #[test]
381    fn tls_required_error_redacts_credentials() {
382        // rediss-less, non-localhost, with embedded creds, tls off → error.
383        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}