1use 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 pub username: String,
12 pub password: String,
13 pub database: u32,
15 pub max_connections: usize,
17 pub connect_timeout: Duration,
18 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 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 let (rest, query) = match rest.split_once('?') {
65 Some((rest, query)) => (rest, Some(query)),
66 None => (rest, None),
67 };
68
69 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 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 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 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
167fn 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}