1use std::env;
7
8use serde::{Deserialize, Serialize};
9use url::Url;
10
11use crate::config::ConfigManager;
12use crate::error::{Error, Result};
13
14const RC_HOST_PREFIX: &str = "RC_HOST_";
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct RetryConfig {
19 #[serde(default = "default_max_attempts")]
21 pub max_attempts: u32,
22
23 #[serde(default = "default_initial_backoff")]
25 pub initial_backoff_ms: u64,
26
27 #[serde(default = "default_max_backoff")]
29 pub max_backoff_ms: u64,
30}
31
32fn default_max_attempts() -> u32 {
33 3
34}
35
36fn default_initial_backoff() -> u64 {
37 100
38}
39
40fn default_max_backoff() -> u64 {
41 10000
42}
43
44impl Default for RetryConfig {
45 fn default() -> Self {
46 Self {
47 max_attempts: default_max_attempts(),
48 initial_backoff_ms: default_initial_backoff(),
49 max_backoff_ms: default_max_backoff(),
50 }
51 }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct TimeoutConfig {
57 #[serde(default = "default_connect_timeout")]
59 pub connect_ms: u64,
60
61 #[serde(default = "default_read_timeout")]
63 pub read_ms: u64,
64}
65
66fn default_connect_timeout() -> u64 {
67 5000
68}
69
70fn default_read_timeout() -> u64 {
71 30000
72}
73
74impl Default for TimeoutConfig {
75 fn default() -> Self {
76 Self {
77 connect_ms: default_connect_timeout(),
78 read_ms: default_read_timeout(),
79 }
80 }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct Alias {
86 pub name: String,
88
89 pub endpoint: String,
91
92 pub access_key: String,
94
95 pub secret_key: String,
97
98 #[serde(default = "default_region")]
100 pub region: String,
101
102 #[serde(default = "default_signature")]
104 pub signature: String,
105
106 #[serde(default = "default_bucket_lookup")]
108 pub bucket_lookup: String,
109
110 #[serde(default)]
112 pub insecure: bool,
113
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub ca_bundle: Option<String>,
117
118 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub retry: Option<RetryConfig>,
121
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub timeout: Option<TimeoutConfig>,
125}
126
127fn default_region() -> String {
128 "us-east-1".to_string()
129}
130
131fn default_signature() -> String {
132 "v4".to_string()
133}
134
135fn default_bucket_lookup() -> String {
136 "auto".to_string()
137}
138
139impl Alias {
140 pub fn new(
142 name: impl Into<String>,
143 endpoint: impl Into<String>,
144 access_key: impl Into<String>,
145 secret_key: impl Into<String>,
146 ) -> Self {
147 Self {
148 name: name.into(),
149 endpoint: endpoint.into(),
150 access_key: access_key.into(),
151 secret_key: secret_key.into(),
152 region: default_region(),
153 signature: default_signature(),
154 bucket_lookup: default_bucket_lookup(),
155 insecure: false,
156 ca_bundle: None,
157 retry: None,
158 timeout: None,
159 }
160 }
161
162 pub fn retry_config(&self) -> RetryConfig {
164 self.retry.clone().unwrap_or_default()
165 }
166
167 pub fn timeout_config(&self) -> TimeoutConfig {
169 self.timeout.clone().unwrap_or_default()
170 }
171}
172
173fn env_alias_var_name(name: &str) -> String {
174 format!("{RC_HOST_PREFIX}{name}")
175}
176
177fn env_alias(name: &str) -> Result<Option<Alias>> {
178 let var_name = env_alias_var_name(name);
179 let Some(value) = env::var_os(&var_name) else {
180 return Ok(None);
181 };
182
183 let value = value
184 .into_string()
185 .map_err(|_| Error::Config(format!("{var_name} must be valid UTF-8")))?;
186 parse_env_alias(name, &value).map(Some)
187}
188
189fn env_aliases() -> Result<Vec<Alias>> {
190 let mut vars = Vec::new();
191
192 for (key, value) in env::vars_os() {
193 let Ok(key) = key.into_string() else {
194 continue;
195 };
196
197 if !key.starts_with(RC_HOST_PREFIX) {
198 continue;
199 }
200
201 let value = value
202 .into_string()
203 .map_err(|_| Error::Config(format!("{key} must be valid UTF-8")))?;
204 vars.push((key, value));
205 }
206
207 env_aliases_from_vars(vars)
208}
209
210fn env_aliases_from_vars<I, K, V>(vars: I) -> Result<Vec<Alias>>
211where
212 I: IntoIterator<Item = (K, V)>,
213 K: AsRef<str>,
214 V: AsRef<str>,
215{
216 let mut aliases = Vec::new();
217
218 for (key, value) in vars {
219 let key = key.as_ref();
220 let Some(alias_name) = key.strip_prefix(RC_HOST_PREFIX) else {
221 continue;
222 };
223
224 if alias_name.is_empty() {
225 return Err(Error::Config("RC_HOST_ must include an alias name".into()));
226 }
227
228 aliases.push(parse_env_alias(alias_name, value.as_ref())?);
229 }
230
231 aliases.sort_by(|a, b| a.name.cmp(&b.name));
232 Ok(aliases)
233}
234
235fn parse_env_alias(name: &str, value: &str) -> Result<Alias> {
236 let var_name = env_alias_var_name(name);
237 let mut url = Url::parse(value)
238 .map_err(|e| Error::Config(format!("{var_name} must be a valid URL: {e}")))?;
239
240 if !matches!(url.scheme(), "http" | "https") {
241 return Err(Error::Config(format!(
242 "{var_name} must use an http or https URL"
243 )));
244 }
245
246 if url.host_str().is_none() {
247 return Err(Error::Config(format!("{var_name} must include a host")));
248 }
249
250 let access_key = url.username();
251 let Some(secret_key) = url.password() else {
252 return Err(Error::Config(format!(
253 "{var_name} must include access key and secret key credentials"
254 )));
255 };
256
257 if access_key.is_empty() || secret_key.is_empty() {
258 return Err(Error::Config(format!(
259 "{var_name} must include non-empty access key and secret key credentials"
260 )));
261 }
262
263 let access_key = decode_env_alias_credential(access_key, &var_name, "access key")?;
264 let secret_key = decode_env_alias_credential(secret_key, &var_name, "secret key")?;
265
266 url.set_username("").map_err(|()| {
267 Error::Config(format!("{var_name} credentials cannot be removed from URL"))
268 })?;
269 url.set_password(None).map_err(|()| {
270 Error::Config(format!("{var_name} credentials cannot be removed from URL"))
271 })?;
272
273 let endpoint = url.as_str().trim_end_matches('/').to_string();
274 Ok(Alias::new(name, endpoint, access_key, secret_key))
275}
276
277fn decode_env_alias_credential(value: &str, var_name: &str, field: &str) -> Result<String> {
278 urlencoding::decode(value)
279 .map(|decoded| decoded.into_owned())
280 .map_err(|e| {
281 Error::Config(format!(
282 "{var_name} contains invalid percent-encoding in {field}: {e}"
283 ))
284 })
285}
286
287fn merge_env_aliases(mut aliases: Vec<Alias>, env_aliases: Vec<Alias>) -> Vec<Alias> {
288 for env_alias in env_aliases {
289 aliases.retain(|alias| alias.name != env_alias.name);
290 aliases.push(env_alias);
291 }
292
293 aliases
294}
295
296pub struct AliasManager {
298 config_manager: ConfigManager,
299}
300
301impl AliasManager {
302 pub fn with_config_manager(config_manager: ConfigManager) -> Self {
304 Self { config_manager }
305 }
306
307 pub fn new() -> Result<Self> {
309 let config_manager = ConfigManager::new()?;
310 Ok(Self { config_manager })
311 }
312
313 pub fn list(&self) -> Result<Vec<Alias>> {
315 let config = self.config_manager.load()?;
316 let env_aliases = env_aliases()?;
317 Ok(merge_env_aliases(config.aliases, env_aliases))
318 }
319
320 pub fn get(&self, name: &str) -> Result<Alias> {
322 if let Some(alias) = env_alias(name)? {
323 return Ok(alias);
324 }
325
326 let config = self.config_manager.load()?;
327 config
328 .aliases
329 .into_iter()
330 .find(|a| a.name == name)
331 .ok_or_else(|| Error::AliasNotFound(name.to_string()))
332 }
333
334 pub fn set(&self, alias: Alias) -> Result<()> {
336 let mut config = self.config_manager.load()?;
337
338 config.aliases.retain(|a| a.name != alias.name);
340 config.aliases.push(alias);
341
342 self.config_manager.save(&config)
343 }
344
345 pub fn remove(&self, name: &str) -> Result<()> {
347 let mut config = self.config_manager.load()?;
348 let original_len = config.aliases.len();
349
350 config.aliases.retain(|a| a.name != name);
351
352 if config.aliases.len() == original_len {
353 return Err(Error::AliasNotFound(name.to_string()));
354 }
355
356 self.config_manager.save(&config)
357 }
358
359 pub fn exists(&self, name: &str) -> Result<bool> {
361 if env_alias(name)?.is_some() {
362 return Ok(true);
363 }
364
365 let config = self.config_manager.load()?;
366 Ok(config.aliases.iter().any(|a| a.name == name))
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use tempfile::TempDir;
374
375 fn temp_alias_manager() -> (AliasManager, TempDir) {
376 let temp_dir = TempDir::new().unwrap();
377 let config_path = temp_dir.path().join("config.toml");
378 let config_manager = ConfigManager::with_path(config_path);
379 let alias_manager = AliasManager::with_config_manager(config_manager);
380 (alias_manager, temp_dir)
381 }
382
383 #[test]
384 fn test_alias_new() {
385 let alias = Alias::new("test", "http://localhost:9000", "access", "secret");
386 assert_eq!(alias.name, "test");
387 assert_eq!(alias.endpoint, "http://localhost:9000");
388 assert_eq!(alias.region, "us-east-1");
389 assert_eq!(alias.signature, "v4");
390 assert_eq!(alias.bucket_lookup, "auto");
391 assert!(!alias.insecure);
392 }
393
394 #[test]
395 fn test_alias_manager_set_and_get() {
396 let (manager, _temp_dir) = temp_alias_manager();
397
398 let alias = Alias::new("local", "http://localhost:9000", "accesskey", "secretkey");
399 manager.set(alias).unwrap();
400
401 let retrieved = manager.get("local").unwrap();
402 assert_eq!(retrieved.name, "local");
403 assert_eq!(retrieved.endpoint, "http://localhost:9000");
404 }
405
406 #[test]
407 fn test_alias_manager_list() {
408 let (manager, _temp_dir) = temp_alias_manager();
409
410 manager
411 .set(Alias::new("a", "http://a:9000", "a", "a"))
412 .unwrap();
413 manager
414 .set(Alias::new("b", "http://b:9000", "b", "b"))
415 .unwrap();
416
417 let aliases = manager.list().unwrap();
418 assert_eq!(aliases.len(), 2);
419 }
420
421 #[test]
422 fn test_alias_manager_remove() {
423 let (manager, _temp_dir) = temp_alias_manager();
424
425 manager
426 .set(Alias::new("test", "http://localhost:9000", "a", "b"))
427 .unwrap();
428 assert!(manager.exists("test").unwrap());
429
430 manager.remove("test").unwrap();
431 assert!(!manager.exists("test").unwrap());
432 }
433
434 #[test]
435 fn test_alias_manager_remove_not_found() {
436 let (manager, _temp_dir) = temp_alias_manager();
437
438 let result = manager.remove("nonexistent");
439 assert!(result.is_err());
440 assert!(matches!(result.unwrap_err(), Error::AliasNotFound(_)));
441 }
442
443 #[test]
444 fn test_alias_manager_get_not_found() {
445 let (manager, _temp_dir) = temp_alias_manager();
446
447 let result = manager.get("nonexistent");
448 assert!(result.is_err());
449 assert!(matches!(result.unwrap_err(), Error::AliasNotFound(_)));
450 }
451
452 #[test]
453 fn test_alias_update_existing() {
454 let (manager, _temp_dir) = temp_alias_manager();
455
456 manager
457 .set(Alias::new("test", "http://old:9000", "a", "b"))
458 .unwrap();
459 manager
460 .set(Alias::new("test", "http://new:9000", "c", "d"))
461 .unwrap();
462
463 let aliases = manager.list().unwrap();
464 assert_eq!(aliases.len(), 1);
465 assert_eq!(aliases[0].endpoint, "http://new:9000");
466 }
467
468 #[test]
469 fn test_parse_rc_host_alias() {
470 let alias =
471 parse_env_alias("myalias", "https://ACCESS_KEY:SECRET_KEY@rustfs.local:9000").unwrap();
472
473 assert_eq!(alias.name, "myalias");
474 assert_eq!(alias.endpoint, "https://rustfs.local:9000");
475 assert_eq!(alias.access_key, "ACCESS_KEY");
476 assert_eq!(alias.secret_key, "SECRET_KEY");
477 assert_eq!(alias.region, "us-east-1");
478 assert_eq!(alias.bucket_lookup, "auto");
479 }
480
481 #[test]
482 fn test_parse_rc_host_alias_decodes_credentials() {
483 let alias =
484 parse_env_alias("encoded", "https://ACCESS%2FKEY:SECRET%40KEY@rustfs.local").unwrap();
485
486 assert_eq!(alias.access_key, "ACCESS/KEY");
487 assert_eq!(alias.secret_key, "SECRET@KEY");
488 }
489
490 #[test]
491 fn test_parse_rc_host_alias_requires_credentials() {
492 let result = parse_env_alias("missing", "https://rustfs.local");
493
494 assert!(result.is_err());
495 assert!(matches!(result.unwrap_err(), Error::Config(_)));
496 }
497
498 #[test]
499 fn test_env_aliases_from_vars_filters_rc_host_prefix() {
500 let aliases = env_aliases_from_vars(vec![
501 (
502 "RC_HOST_second".to_string(),
503 "https://key2:secret2@second.local".to_string(),
504 ),
505 ("UNRELATED".to_string(), "ignored".to_string()),
506 (
507 "RC_HOST_first".to_string(),
508 "https://key1:secret1@first.local".to_string(),
509 ),
510 ])
511 .unwrap();
512
513 assert_eq!(aliases.len(), 2);
514 assert_eq!(aliases[0].name, "first");
515 assert_eq!(aliases[1].name, "second");
516 }
517
518 #[test]
519 fn test_merge_env_aliases_overrides_config_alias() {
520 let config_alias = Alias::new("local", "http://old:9000", "old", "old");
521 let env_alias = parse_env_alias("local", "https://new:secret@new.local").unwrap();
522
523 let aliases = merge_env_aliases(vec![config_alias], vec![env_alias]);
524
525 assert_eq!(aliases.len(), 1);
526 assert_eq!(aliases[0].endpoint, "https://new.local");
527 assert_eq!(aliases[0].access_key, "new");
528 }
529}