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}
21
22impl Default for RedisConfig {
23    fn default() -> Self {
24        RedisConfig {
25            host: "127.0.0.1".into(),
26            port: 6379,
27            username: String::new(),
28            password: String::new(),
29            database: 0,
30            max_connections: 10,
31            connect_timeout: Duration::from_secs(5),
32            command_timeout: Duration::from_secs(10),
33        }
34    }
35}
36
37impl RedisConfig {
38    /// Parse `redis://[[user]:password@]host[:port][/db]`.
39    ///
40    /// The password-only form `redis://:secret@host` is the one almost every
41    /// deployment uses, so it is handled first-class rather than as a special
42    /// case of a username.
43    pub fn from_url(url: &str) -> Result<Self> {
44        let rest = url
45            .strip_prefix("redis://")
46            .or_else(|| url.strip_prefix("rediss://"))
47            .ok_or_else(|| {
48                Error::msg(format!(
49                    "`{url}` is not a Redis URL. Expected redis://[:password@]host:port[/db]"
50                ))
51            })?;
52
53        if url.starts_with("rediss://") {
54            return Err(Error::msg(
55                "rustlavel-cache speaks plain RESP over TCP; `rediss://` (TLS) is not supported yet. \
56                 Terminate TLS with stunnel or a sidecar, or use redis://.",
57            ));
58        }
59
60        let mut config = RedisConfig::default();
61
62        // Strip the query string first so a `?` inside it is never read as part
63        // of the database number.
64        let (rest, query) = match rest.split_once('?') {
65            Some((rest, query)) => (rest, Some(query)),
66            None => (rest, None),
67        };
68
69        // The *last* `@` separates credentials from the host, which is what
70        // makes an `@` inside a password unambiguous.
71        let (credentials, host_part) = match rest.rsplit_once('@') {
72            Some((credentials, host)) => (Some(credentials), host),
73            None => (None, rest),
74        };
75
76        if let Some(credentials) = credentials {
77            let (username, password) = match credentials.split_once(':') {
78                Some((username, password)) => (username, password),
79                // `redis://secret@host` — a bare credential is a password.
80                None => ("", credentials),
81            };
82            config.username = decode(username);
83            config.password = decode(password);
84        }
85
86        let (host, database) = match host_part.split_once('/') {
87            Some((host, database)) => (host, database),
88            None => (host_part, ""),
89        };
90
91        if !database.is_empty() {
92            config.database = database.parse().map_err(|_| {
93                Error::msg(format!("`{database}` is not a Redis database number"))
94            })?;
95        }
96
97        if !host.is_empty() {
98            let (name, port) = match host.rsplit_once(':') {
99                Some((name, port)) => (name, Some(port)),
100                None => (host, None),
101            };
102            if !name.is_empty() {
103                config.host = name.to_string();
104            }
105            if let Some(port) = port {
106                config.port = port
107                    .parse()
108                    .map_err(|_| Error::msg(format!("`{port}` is not a valid port number")))?;
109            }
110        }
111
112        for (key, value) in query.into_iter().flat_map(|q| q.split('&')).filter_map(|p| p.split_once('='))
113        {
114            match key {
115                "max_connections" => {
116                    config.max_connections = value.parse().unwrap_or(config.max_connections).max(1);
117                }
118                "connect_timeout" => {
119                    if let Ok(seconds) = value.parse() {
120                        config.connect_timeout = Duration::from_secs(seconds);
121                    }
122                }
123                "command_timeout" => {
124                    if let Ok(seconds) = value.parse() {
125                        config.command_timeout = Duration::from_secs(seconds);
126                    }
127                }
128                _ => {}
129            }
130        }
131
132        Ok(config)
133    }
134
135    /// Read from the application config, falling back to `REDIS_URL`.
136    pub fn from_app_config(config: &Config) -> Result<Self> {
137        let url = config.string("cache.url", "");
138        if !url.is_empty() {
139            return RedisConfig::from_url(&url);
140        }
141        if let Ok(url) = std::env::var("REDIS_URL")
142            && !url.is_empty()
143        {
144            return RedisConfig::from_url(&url);
145        }
146        Ok(RedisConfig::default())
147    }
148
149    pub fn address(&self) -> String {
150        format!("{}:{}", self.host, self.port)
151    }
152
153    /// The URL with the password removed, for logs and error messages.
154    ///
155    /// Every message this crate produces about a connection uses this, so a
156    /// stack trace on a shared screen never leaks a credential.
157    pub fn redacted_url(&self) -> String {
158        let credentials = match (self.username.is_empty(), self.password.is_empty()) {
159            (true, true) => String::new(),
160            (true, false) => ":***@".to_string(),
161            (false, _) => format!("{}:***@", self.username),
162        };
163        format!("redis://{credentials}{}:{}/{}", self.host, self.port, self.database)
164    }
165}
166
167/// Percent-decode a credential, so a password containing `@`, `/` or `:` can
168/// be written into a URL at all.
169fn decode(value: &str) -> String {
170    if !value.contains('%') {
171        return value.to_string();
172    }
173
174    let bytes = value.as_bytes();
175    let mut out = Vec::with_capacity(bytes.len());
176    let mut index = 0;
177    while index < bytes.len() {
178        if bytes[index] == b'%'
179            && index + 2 < bytes.len()
180            && let Ok(byte) = u8::from_str_radix(&value[index + 1..index + 3], 16)
181        {
182            out.push(byte);
183            index += 3;
184            continue;
185        }
186        out.push(bytes[index]);
187        index += 1;
188    }
189    String::from_utf8_lossy(&out).into_owned()
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn parses_the_password_only_form_every_deployment_uses() {
198        let config = RedisConfig::from_url("redis://:hunter2@cache.internal:6380/3").unwrap();
199
200        assert_eq!(config.username, "");
201        assert_eq!(config.password, "hunter2");
202        assert_eq!(config.host, "cache.internal");
203        assert_eq!(config.port, 6380);
204        assert_eq!(config.database, 3);
205    }
206
207    #[test]
208    fn parses_an_acl_username_and_password() {
209        let config = RedisConfig::from_url("redis://ada:hunter2@localhost").unwrap();
210
211        assert_eq!(config.username, "ada");
212        assert_eq!(config.password, "hunter2");
213        assert_eq!(config.port, 6379);
214        assert_eq!(config.database, 0);
215    }
216
217    #[test]
218    fn falls_back_to_defaults_for_every_missing_part() {
219        let config = RedisConfig::from_url("redis://127.0.0.1:6379").unwrap();
220
221        assert_eq!(config.host, "127.0.0.1");
222        assert_eq!(config.port, 6379);
223        assert_eq!(config.database, 0);
224        assert!(config.password.is_empty());
225    }
226
227    #[test]
228    fn a_bare_credential_is_read_as_a_password_not_a_username() {
229        let config = RedisConfig::from_url("redis://hunter2@host").unwrap();
230
231        assert!(config.username.is_empty());
232        assert_eq!(config.password, "hunter2");
233    }
234
235    #[test]
236    fn a_password_may_contain_an_at_sign_or_a_slash() {
237        let config = RedisConfig::from_url("redis://:p%40ss%2Fword@host/1").unwrap();
238
239        assert_eq!(config.password, "p@ss/word");
240        assert_eq!(config.host, "host");
241        assert_eq!(config.database, 1);
242    }
243
244    #[test]
245    fn reads_pool_settings_from_the_query_string() {
246        let config =
247            RedisConfig::from_url("redis://host/0?max_connections=25&connect_timeout=2").unwrap();
248
249        assert_eq!(config.max_connections, 25);
250        assert_eq!(config.connect_timeout, Duration::from_secs(2));
251    }
252
253    #[test]
254    fn rejects_a_url_with_the_wrong_scheme() {
255        let error = RedisConfig::from_url("memcached://host").unwrap_err();
256        assert!(error.to_string().contains("not a Redis URL"));
257    }
258
259    #[test]
260    fn refuses_tls_urls_out_loud_rather_than_connecting_in_the_clear() {
261        let error = RedisConfig::from_url("rediss://host").unwrap_err();
262        assert!(error.to_string().contains("TLS"), "got: {error}");
263    }
264
265    #[test]
266    fn rejects_a_database_that_is_not_a_number() {
267        assert!(RedisConfig::from_url("redis://host/not-a-db").is_err());
268        assert!(RedisConfig::from_url("redis://host:not-a-port").is_err());
269    }
270
271    #[test]
272    fn never_prints_the_password() {
273        let config = RedisConfig::from_url("redis://ada:hunter2@host:6380/2").unwrap();
274        let shown = config.redacted_url();
275
276        assert!(!shown.contains("hunter2"));
277        assert!(shown.contains("ada"));
278        assert!(shown.contains("host:6380/2"));
279    }
280}