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 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 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 let (rest, query) = match rest.split_once('?') {
88 Some((rest, query)) => (rest, Some(query)),
89 None => (rest, None),
90 };
91
92 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 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 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 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
197fn 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 #[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 #[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 #[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}