Skip to main content

rustlavel_cache/redis/
config.rs

1//! Redis connection settings, from a URL or from the application config.
2
3use rustlavel_core::{Config, Error, Result};
4use std::time::Duration;
5
6#[derive(Debug, Clone)]
7pub struct RedisConfig {
8    pub host: String,
9    pub port: u16,
10    /// Redis 6 ACL username. Empty means the legacy single-password mode.
11    pub username: String,
12    pub password: String,
13    /// The numbered database `SELECT`ed after connecting.
14    pub database: u32,
15    /// Connections the pool will open at once.
16    pub max_connections: usize,
17    pub connect_timeout: Duration,
18    /// How long one command may take before the connection is given up on.
19    pub command_timeout: Duration,
20    /// `redis` or `valkey` — whichever the URL said.
21    ///
22    /// Kept so every message about the connection echoes the name the operator
23    /// used. A Valkey deployment told it "cannot connect to redis://…" goes
24    /// looking for a Redis configuration that does not exist.
25    pub scheme: String,
26}
27
28impl Default for RedisConfig {
29    fn default() -> Self {
30        RedisConfig {
31            host: "127.0.0.1".into(),
32            port: 6379,
33            username: String::new(),
34            password: String::new(),
35            database: 0,
36            max_connections: 10,
37            connect_timeout: Duration::from_secs(5),
38            command_timeout: Duration::from_secs(10),
39            scheme: "redis".to_string(),
40        }
41    }
42}
43
44impl RedisConfig {
45    /// Parse `redis://[[user]:password@]host[:port][/db]`, or the same with
46    /// `valkey://`.
47    ///
48    /// **Valkey is the same wire protocol.** It is the Linux Foundation fork of
49    /// Redis 7.2, speaks RESP unchanged, and this client's whole test suite
50    /// passes against Valkey 8 as it stands. What a Valkey deployment needs is
51    /// not a second client but a client that answers to the name: its own
52    /// tooling writes `valkey://`, and an operator who pastes that URL should
53    /// not be told it is not a Redis URL.
54    ///
55    /// The password-only form `redis://:secret@host` is the one almost every
56    /// deployment uses, so it is handled first-class rather than as a special
57    /// case of a username.
58    pub fn from_url(url: &str) -> Result<Self> {
59        const PLAIN: [&str; 2] = ["redis://", "valkey://"];
60        const TLS: [&str; 2] = ["rediss://", "valkeys://"];
61
62        if TLS.iter().any(|scheme| url.starts_with(scheme)) {
63            return Err(Error::msg(
64                "rustlavel-cache speaks plain RESP over TCP; `rediss://` and `valkeys://` (TLS) \
65                 are not supported yet. Terminate TLS with stunnel or a sidecar, or use \
66                 redis:// or valkey://.",
67            ));
68        }
69
70        let (scheme, rest) = PLAIN
71            .iter()
72            .find_map(|scheme| url.strip_prefix(scheme).map(|rest| (*scheme, rest)))
73            .ok_or_else(|| {
74                Error::msg(format!(
75                    "`{url}` is not a Redis or Valkey URL. Expected \
76                     redis://[:password@]host:port[/db] or valkey://[:password@]host:port[/db]"
77                ))
78            })?;
79
80        let mut config = RedisConfig {
81            scheme: scheme.trim_end_matches("://").to_string(),
82            ..RedisConfig::default()
83        };
84
85        // Strip the query string first so a `?` inside it is never read as part
86        // of the database number.
87        let (rest, query) = match rest.split_once('?') {
88            Some((rest, query)) => (rest, Some(query)),
89            None => (rest, None),
90        };
91
92        // The *last* `@` separates credentials from the host, which is what
93        // makes an `@` inside a password unambiguous.
94        let (credentials, host_part) = match rest.rsplit_once('@') {
95            Some((credentials, host)) => (Some(credentials), host),
96            None => (None, rest),
97        };
98
99        if let Some(credentials) = credentials {
100            let (username, password) = match credentials.split_once(':') {
101                Some((username, password)) => (username, password),
102                // `redis://secret@host` — a bare credential is a password.
103                None => ("", credentials),
104            };
105            config.username = decode(username);
106            config.password = decode(password);
107        }
108
109        let (host, database) = match host_part.split_once('/') {
110            Some((host, database)) => (host, database),
111            None => (host_part, ""),
112        };
113
114        if !database.is_empty() {
115            config.database = database.parse().map_err(|_| {
116                Error::msg(format!("`{database}` is not a Redis database number"))
117            })?;
118        }
119
120        if !host.is_empty() {
121            let (name, port) = match host.rsplit_once(':') {
122                Some((name, port)) => (name, Some(port)),
123                None => (host, None),
124            };
125            if !name.is_empty() {
126                config.host = name.to_string();
127            }
128            if let Some(port) = port {
129                config.port = port
130                    .parse()
131                    .map_err(|_| Error::msg(format!("`{port}` is not a valid port number")))?;
132            }
133        }
134
135        for (key, value) in query.into_iter().flat_map(|q| q.split('&')).filter_map(|p| p.split_once('='))
136        {
137            match key {
138                "max_connections" => {
139                    config.max_connections = value.parse().unwrap_or(config.max_connections).max(1);
140                }
141                "connect_timeout" => {
142                    if let Ok(seconds) = value.parse() {
143                        config.connect_timeout = Duration::from_secs(seconds);
144                    }
145                }
146                "command_timeout" => {
147                    if let Ok(seconds) = value.parse() {
148                        config.command_timeout = Duration::from_secs(seconds);
149                    }
150                }
151                _ => {}
152            }
153        }
154
155        Ok(config)
156    }
157
158    /// Read from the application config, falling back to `REDIS_URL` and then
159    /// `VALKEY_URL`.
160    ///
161    /// Both names, because a Valkey deployment's own documentation and
162    /// tooling use the second, and an operator who sets the variable their
163    /// platform told them to set should find it read.
164    pub fn from_app_config(config: &Config) -> Result<Self> {
165        let url = config.string("cache.url", "");
166        if !url.is_empty() {
167            return RedisConfig::from_url(&url);
168        }
169        for variable in ["REDIS_URL", "VALKEY_URL"] {
170            if let Ok(url) = std::env::var(variable)
171                && !url.is_empty()
172            {
173                return RedisConfig::from_url(&url);
174            }
175        }
176        Ok(RedisConfig::default())
177    }
178
179    pub fn address(&self) -> String {
180        format!("{}:{}", self.host, self.port)
181    }
182
183    /// The URL with the password removed, for logs and error messages.
184    ///
185    /// Every message this crate produces about a connection uses this, so a
186    /// stack trace on a shared screen never leaks a credential.
187    pub fn redacted_url(&self) -> String {
188        let credentials = match (self.username.is_empty(), self.password.is_empty()) {
189            (true, true) => String::new(),
190            (true, false) => ":***@".to_string(),
191            (false, _) => format!("{}:***@", self.username),
192        };
193        format!("{}://{credentials}{}:{}/{}", self.scheme, self.host, self.port, self.database)
194    }
195}
196
197/// Percent-decode a credential, so a password containing `@`, `/` or `:` can
198/// be written into a URL at all.
199fn decode(value: &str) -> String {
200    if !value.contains('%') {
201        return value.to_string();
202    }
203
204    let bytes = value.as_bytes();
205    let mut out = Vec::with_capacity(bytes.len());
206    let mut index = 0;
207    while index < bytes.len() {
208        if bytes[index] == b'%'
209            && index + 2 < bytes.len()
210            && let Ok(byte) = u8::from_str_radix(&value[index + 1..index + 3], 16)
211        {
212            out.push(byte);
213            index += 3;
214            continue;
215        }
216        out.push(bytes[index]);
217        index += 1;
218    }
219    String::from_utf8_lossy(&out).into_owned()
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn parses_the_password_only_form_every_deployment_uses() {
228        let config = RedisConfig::from_url("redis://:hunter2@cache.internal:6380/3").unwrap();
229
230        assert_eq!(config.username, "");
231        assert_eq!(config.password, "hunter2");
232        assert_eq!(config.host, "cache.internal");
233        assert_eq!(config.port, 6380);
234        assert_eq!(config.database, 3);
235    }
236
237    /// Valkey's own tooling writes `valkey://`. An operator pasting the URL
238    /// their platform gave them must not be told it is not a Redis URL — it is
239    /// the same protocol, and the whole suite passes against Valkey 8.
240    #[test]
241    fn a_valkey_url_parses_exactly_as_the_redis_one_does() {
242        let redis = RedisConfig::from_url("redis://:hunter2@cache.internal:6380/3").unwrap();
243        let valkey = RedisConfig::from_url("valkey://:hunter2@cache.internal:6380/3").unwrap();
244
245        assert_eq!(valkey.host, redis.host);
246        assert_eq!(valkey.port, redis.port);
247        assert_eq!(valkey.password, redis.password);
248        assert_eq!(valkey.database, redis.database);
249    }
250
251    /// A Valkey deployment told "cannot connect to redis://…" goes looking for
252    /// a Redis configuration that does not exist.
253    #[test]
254    fn messages_echo_the_scheme_the_operator_wrote() {
255        let valkey = RedisConfig::from_url("valkey://:secret@cache:6380/2").unwrap();
256        assert_eq!(valkey.redacted_url(), "valkey://:***@cache:6380/2");
257
258        let redis = RedisConfig::from_url("redis://cache").unwrap();
259        assert!(redis.redacted_url().starts_with("redis://"));
260    }
261
262    /// Both TLS spellings are refused with the same honest message, and a
263    /// scheme that is neither names both accepted ones.
264    #[test]
265    fn the_refusals_name_valkey_too() {
266        let tls = RedisConfig::from_url("valkeys://cache").unwrap_err().to_string();
267        assert!(tls.contains("valkeys://"), "{tls}");
268        assert!(tls.contains("valkey://"), "the fix was not offered: {tls}");
269
270        let wrong = RedisConfig::from_url("memcached://cache").unwrap_err().to_string();
271        assert!(wrong.contains("Redis or Valkey"), "{wrong}");
272        assert!(wrong.contains("valkey://"), "{wrong}");
273    }
274
275    #[test]
276    fn parses_an_acl_username_and_password() {
277        let config = RedisConfig::from_url("redis://ada:hunter2@localhost").unwrap();
278
279        assert_eq!(config.username, "ada");
280        assert_eq!(config.password, "hunter2");
281        assert_eq!(config.port, 6379);
282        assert_eq!(config.database, 0);
283    }
284
285    #[test]
286    fn falls_back_to_defaults_for_every_missing_part() {
287        let config = RedisConfig::from_url("redis://127.0.0.1:6379").unwrap();
288
289        assert_eq!(config.host, "127.0.0.1");
290        assert_eq!(config.port, 6379);
291        assert_eq!(config.database, 0);
292        assert!(config.password.is_empty());
293    }
294
295    #[test]
296    fn a_bare_credential_is_read_as_a_password_not_a_username() {
297        let config = RedisConfig::from_url("redis://hunter2@host").unwrap();
298
299        assert!(config.username.is_empty());
300        assert_eq!(config.password, "hunter2");
301    }
302
303    #[test]
304    fn a_password_may_contain_an_at_sign_or_a_slash() {
305        let config = RedisConfig::from_url("redis://:p%40ss%2Fword@host/1").unwrap();
306
307        assert_eq!(config.password, "p@ss/word");
308        assert_eq!(config.host, "host");
309        assert_eq!(config.database, 1);
310    }
311
312    #[test]
313    fn reads_pool_settings_from_the_query_string() {
314        let config =
315            RedisConfig::from_url("redis://host/0?max_connections=25&connect_timeout=2").unwrap();
316
317        assert_eq!(config.max_connections, 25);
318        assert_eq!(config.connect_timeout, Duration::from_secs(2));
319    }
320
321    #[test]
322    fn rejects_a_url_with_the_wrong_scheme() {
323        let error = RedisConfig::from_url("memcached://host").unwrap_err();
324        assert!(error.to_string().contains("not a Redis or Valkey URL"), "{error}");
325    }
326
327    #[test]
328    fn refuses_tls_urls_out_loud_rather_than_connecting_in_the_clear() {
329        let error = RedisConfig::from_url("rediss://host").unwrap_err();
330        assert!(error.to_string().contains("TLS"), "got: {error}");
331    }
332
333    #[test]
334    fn rejects_a_database_that_is_not_a_number() {
335        assert!(RedisConfig::from_url("redis://host/not-a-db").is_err());
336        assert!(RedisConfig::from_url("redis://host:not-a-port").is_err());
337    }
338
339    #[test]
340    fn never_prints_the_password() {
341        let config = RedisConfig::from_url("redis://ada:hunter2@host:6380/2").unwrap();
342        let shown = config.redacted_url();
343
344        assert!(!shown.contains("hunter2"));
345        assert!(shown.contains("ada"));
346        assert!(shown.contains("host:6380/2"));
347    }
348}