1use std::env;
7use std::sync::{OnceLock, RwLock};
8
9use serde::{Deserialize, Serialize};
10use url::Url;
11
12use crate::config::ConfigManager;
13use crate::error::{Error, Result};
14
15const RC_HOST_PREFIX: &str = "RC_HOST_";
16const CUSTOM_HEADER_PREFIX: &str = "x-amz-";
17
18static GLOBAL_REQUEST_HEADERS: OnceLock<RwLock<Vec<RequestHeader>>> = OnceLock::new();
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct RequestHeader {
23 pub name: String,
24 pub value: String,
25}
26
27impl RequestHeader {
28 pub fn parse(value: &str) -> Result<Self> {
29 let (name, header_value) = value.split_once(':').ok_or_else(|| {
30 Error::Config(
31 "Header must use NAME:VALUE format, for example x-amz-meta-key:value".into(),
32 )
33 })?;
34
35 let name = name.trim().to_ascii_lowercase();
36 let header_value = header_value.trim().to_string();
37
38 if name.is_empty() {
39 return Err(Error::Config("Header name must not be empty".into()));
40 }
41
42 if header_value.is_empty() {
43 return Err(Error::Config("Header value must not be empty".into()));
44 }
45
46 if !name.starts_with(CUSTOM_HEADER_PREFIX) {
47 return Err(Error::Config(
48 "Only x-amz-* custom request headers are supported".into(),
49 ));
50 }
51
52 if !name
53 .bytes()
54 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
55 {
56 return Err(Error::Config(format!("Invalid header name '{name}'")));
57 }
58
59 if !header_value.is_ascii() || header_value.bytes().any(|b| matches!(b, b'\r' | b'\n')) {
60 return Err(Error::Config(format!("Invalid value for header '{name}'")));
61 }
62
63 Ok(Self {
64 name,
65 value: header_value,
66 })
67 }
68}
69
70pub fn set_global_request_headers(headers: Vec<RequestHeader>) {
72 let storage = GLOBAL_REQUEST_HEADERS.get_or_init(|| RwLock::new(Vec::new()));
73 let mut guard = storage
74 .write()
75 .expect("global request header lock should not be poisoned");
76 *guard = headers;
77}
78
79pub fn global_request_headers() -> Vec<RequestHeader> {
81 let Some(storage) = GLOBAL_REQUEST_HEADERS.get() else {
82 return Vec::new();
83 };
84
85 storage
86 .read()
87 .expect("global request header lock should not be poisoned")
88 .clone()
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct RetryConfig {
94 #[serde(default = "default_max_attempts")]
96 pub max_attempts: u32,
97
98 #[serde(default = "default_initial_backoff")]
100 pub initial_backoff_ms: u64,
101
102 #[serde(default = "default_max_backoff")]
104 pub max_backoff_ms: u64,
105}
106
107fn default_max_attempts() -> u32 {
108 3
109}
110
111fn default_initial_backoff() -> u64 {
112 100
113}
114
115fn default_max_backoff() -> u64 {
116 10000
117}
118
119impl Default for RetryConfig {
120 fn default() -> Self {
121 Self {
122 max_attempts: default_max_attempts(),
123 initial_backoff_ms: default_initial_backoff(),
124 max_backoff_ms: default_max_backoff(),
125 }
126 }
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct TimeoutConfig {
132 #[serde(default = "default_connect_timeout")]
134 pub connect_ms: u64,
135
136 #[serde(default = "default_read_timeout")]
138 pub read_ms: u64,
139}
140
141fn default_connect_timeout() -> u64 {
142 5000
143}
144
145fn default_read_timeout() -> u64 {
146 30000
147}
148
149impl Default for TimeoutConfig {
150 fn default() -> Self {
151 Self {
152 connect_ms: default_connect_timeout(),
153 read_ms: default_read_timeout(),
154 }
155 }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct Alias {
161 pub name: String,
163
164 pub endpoint: String,
166
167 pub access_key: String,
169
170 pub secret_key: String,
172
173 #[serde(default)]
175 pub anonymous: bool,
176
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub client_cert: Option<String>,
180
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub client_key: Option<String>,
184
185 #[serde(default = "default_region")]
187 pub region: String,
188
189 #[serde(default = "default_signature")]
191 pub signature: String,
192
193 #[serde(default = "default_bucket_lookup")]
195 pub bucket_lookup: String,
196
197 #[serde(default)]
199 pub insecure: bool,
200
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub ca_bundle: Option<String>,
204
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub retry: Option<RetryConfig>,
208
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub timeout: Option<TimeoutConfig>,
212}
213
214pub fn validate_alias_endpoint(value: &str) -> Result<()> {
216 if value.contains('{') || value.contains('}') {
217 return Err(Error::Config(
218 "Endpoint must be a single S3 service URL; RustFS volume expansion patterns are not supported".into(),
219 ));
220 }
221
222 let url = Url::parse(value)
223 .map_err(|e| Error::Config(format!("Endpoint must be a valid URL: {e}")))?;
224
225 if !url.username().is_empty() || url.password().is_some() {
226 return Err(Error::Config(
227 "Endpoint must not include credentials; pass access key and secret key as separate arguments".into(),
228 ));
229 }
230
231 validate_http_endpoint_url(&url, "Endpoint")
232}
233
234fn default_region() -> String {
235 "us-east-1".to_string()
236}
237
238fn default_signature() -> String {
239 "v4".to_string()
240}
241
242fn default_bucket_lookup() -> String {
243 "auto".to_string()
244}
245
246impl Alias {
247 pub fn new(
249 name: impl Into<String>,
250 endpoint: impl Into<String>,
251 access_key: impl Into<String>,
252 secret_key: impl Into<String>,
253 ) -> Self {
254 Self {
255 name: name.into(),
256 endpoint: endpoint.into(),
257 access_key: access_key.into(),
258 secret_key: secret_key.into(),
259 anonymous: false,
260 client_cert: None,
261 client_key: None,
262 region: default_region(),
263 signature: default_signature(),
264 bucket_lookup: default_bucket_lookup(),
265 insecure: false,
266 ca_bundle: None,
267 retry: None,
268 timeout: None,
269 }
270 }
271
272 pub fn retry_config(&self) -> RetryConfig {
274 self.retry.clone().unwrap_or_default()
275 }
276
277 pub fn timeout_config(&self) -> TimeoutConfig {
279 self.timeout.clone().unwrap_or_default()
280 }
281}
282
283fn env_alias_var_name(name: &str) -> String {
284 format!("{RC_HOST_PREFIX}{name}")
285}
286
287fn env_alias(name: &str) -> Result<Option<Alias>> {
288 let var_name = env_alias_var_name(name);
289 let Some(value) = env::var_os(&var_name) else {
290 return Ok(None);
291 };
292
293 let value = value
294 .into_string()
295 .map_err(|_| Error::Config(format!("{var_name} must be valid UTF-8")))?;
296 parse_env_alias(name, &value).map(Some)
297}
298
299fn env_aliases() -> Result<Vec<Alias>> {
300 let mut vars = Vec::new();
301
302 for (key, value) in env::vars_os() {
303 let Ok(key) = key.into_string() else {
304 continue;
305 };
306
307 if !key.starts_with(RC_HOST_PREFIX) {
308 continue;
309 }
310
311 let value = value
312 .into_string()
313 .map_err(|_| Error::Config(format!("{key} must be valid UTF-8")))?;
314 vars.push((key, value));
315 }
316
317 env_aliases_from_vars(vars)
318}
319
320fn env_aliases_from_vars<I, K, V>(vars: I) -> Result<Vec<Alias>>
321where
322 I: IntoIterator<Item = (K, V)>,
323 K: AsRef<str>,
324 V: AsRef<str>,
325{
326 let mut aliases = Vec::new();
327
328 for (key, value) in vars {
329 let key = key.as_ref();
330 let Some(alias_name) = key.strip_prefix(RC_HOST_PREFIX) else {
331 continue;
332 };
333
334 if alias_name.is_empty() {
335 return Err(Error::Config("RC_HOST_ must include an alias name".into()));
336 }
337
338 aliases.push(parse_env_alias(alias_name, value.as_ref())?);
339 }
340
341 aliases.sort_by(|a, b| a.name.cmp(&b.name));
342 Ok(aliases)
343}
344
345fn parse_env_alias(name: &str, value: &str) -> Result<Alias> {
346 let var_name = env_alias_var_name(name);
347 let mut url = Url::parse(value)
348 .map_err(|e| Error::Config(format!("{var_name} must be a valid URL: {e}")))?;
349
350 validate_http_endpoint_url(&url, &var_name)?;
351
352 let access_key = url.username();
353 let Some(secret_key) = url.password() else {
354 return Err(Error::Config(format!(
355 "{var_name} must include access key and secret key credentials"
356 )));
357 };
358
359 if access_key.is_empty() || secret_key.is_empty() {
360 return Err(Error::Config(format!(
361 "{var_name} must include non-empty access key and secret key credentials"
362 )));
363 }
364
365 let access_key = decode_env_alias_credential(access_key, &var_name, "access key")?;
366 let secret_key = decode_env_alias_credential(secret_key, &var_name, "secret key")?;
367
368 url.set_username("").map_err(|()| {
369 Error::Config(format!("{var_name} credentials cannot be removed from URL"))
370 })?;
371 url.set_password(None).map_err(|()| {
372 Error::Config(format!("{var_name} credentials cannot be removed from URL"))
373 })?;
374
375 let endpoint = url.as_str().trim_end_matches('/').to_string();
376 Ok(Alias::new(name, endpoint, access_key, secret_key))
377}
378
379fn validate_http_endpoint_url(url: &Url, label: &str) -> Result<()> {
380 if !matches!(url.scheme(), "http" | "https") {
381 return Err(Error::Config(format!(
382 "{label} must use an http or https URL"
383 )));
384 }
385
386 if url.host_str().is_none() {
387 return Err(Error::Config(format!("{label} must include a host")));
388 }
389
390 if !matches!(url.path(), "" | "/") || url.query().is_some() || url.fragment().is_some() {
391 return Err(Error::Config(format!(
392 "{label} must not include a non-root path, query, or fragment"
393 )));
394 }
395
396 Ok(())
397}
398
399fn decode_env_alias_credential(value: &str, var_name: &str, field: &str) -> Result<String> {
400 if has_invalid_percent_encoding(value) {
401 return Err(Error::Config(format!(
402 "{var_name} contains invalid percent-encoding in {field}"
403 )));
404 }
405
406 urlencoding::decode(value)
407 .map(|decoded| decoded.into_owned())
408 .map_err(|e| {
409 Error::Config(format!(
410 "{var_name} contains invalid percent-encoding in {field}: {e}"
411 ))
412 })
413}
414
415fn has_invalid_percent_encoding(value: &str) -> bool {
416 let bytes = value.as_bytes();
417 let mut index = 0;
418
419 while index < bytes.len() {
420 if bytes[index] != b'%' {
421 index += 1;
422 continue;
423 }
424
425 if index + 2 >= bytes.len()
426 || !bytes[index + 1].is_ascii_hexdigit()
427 || !bytes[index + 2].is_ascii_hexdigit()
428 {
429 return true;
430 }
431
432 index += 3;
433 }
434
435 false
436}
437
438fn merge_env_aliases(mut aliases: Vec<Alias>, env_aliases: Vec<Alias>) -> Vec<Alias> {
439 for env_alias in env_aliases {
440 aliases.retain(|alias| alias.name != env_alias.name);
441 aliases.push(env_alias);
442 }
443
444 aliases
445}
446
447pub struct AliasManager {
449 config_manager: ConfigManager,
450}
451
452impl AliasManager {
453 pub fn with_config_manager(config_manager: ConfigManager) -> Self {
455 Self { config_manager }
456 }
457
458 pub fn new() -> Result<Self> {
460 let config_manager = ConfigManager::new()?;
461 Ok(Self { config_manager })
462 }
463
464 pub fn list(&self) -> Result<Vec<Alias>> {
466 let config = self.config_manager.load()?;
467 let env_aliases = env_aliases()?;
468 Ok(merge_env_aliases(config.aliases, env_aliases))
469 }
470
471 pub fn get(&self, name: &str) -> Result<Alias> {
473 if let Some(alias) = env_alias(name)? {
474 return Ok(alias);
475 }
476
477 let config = self.config_manager.load()?;
478 config
479 .aliases
480 .into_iter()
481 .find(|a| a.name == name)
482 .ok_or_else(|| Error::AliasNotFound(name.to_string()))
483 }
484
485 pub fn set(&self, alias: Alias) -> Result<()> {
487 let mut config = self.config_manager.load()?;
488
489 config.aliases.retain(|a| a.name != alias.name);
491 config.aliases.push(alias);
492
493 self.config_manager.save(&config)
494 }
495
496 pub fn import(&self, aliases: Vec<Alias>, replace: bool) -> Result<()> {
501 let mut names = std::collections::HashSet::new();
502 for alias in &aliases {
503 if !names.insert(alias.name.clone()) {
504 return Err(Error::Config(format!(
505 "Import contains duplicate alias '{}'",
506 alias.name
507 )));
508 }
509 }
510
511 let mut config = self.config_manager.load()?;
512 if !replace {
513 let mut conflicts = config
514 .aliases
515 .iter()
516 .filter(|alias| names.contains(&alias.name))
517 .map(|alias| alias.name.clone())
518 .collect::<Vec<_>>();
519 conflicts.sort();
520 if !conflicts.is_empty() {
521 return Err(Error::Config(format!(
522 "Aliases already exist: {}. Retry with --replace to overwrite them",
523 conflicts.join(", ")
524 )));
525 }
526 }
527
528 config.aliases.retain(|alias| !names.contains(&alias.name));
529 config.aliases.extend(aliases);
530 config
531 .aliases
532 .sort_by(|left, right| left.name.cmp(&right.name));
533 self.config_manager.save(&config)
534 }
535
536 pub fn remove(&self, name: &str) -> Result<()> {
538 let mut config = self.config_manager.load()?;
539 let original_len = config.aliases.len();
540
541 config.aliases.retain(|a| a.name != name);
542
543 if config.aliases.len() == original_len {
544 return Err(Error::AliasNotFound(name.to_string()));
545 }
546
547 self.config_manager.save(&config)
548 }
549
550 pub fn exists(&self, name: &str) -> Result<bool> {
552 if env_alias(name)?.is_some() {
553 return Ok(true);
554 }
555
556 let config = self.config_manager.load()?;
557 Ok(config.aliases.iter().any(|a| a.name == name))
558 }
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use tempfile::TempDir;
565
566 fn temp_alias_manager() -> (AliasManager, TempDir) {
567 let temp_dir = TempDir::new().unwrap();
568 let config_path = temp_dir.path().join("config.toml");
569 let config_manager = ConfigManager::with_path(config_path);
570 let alias_manager = AliasManager::with_config_manager(config_manager);
571 (alias_manager, temp_dir)
572 }
573
574 #[test]
575 fn test_alias_new() {
576 let alias = Alias::new("test", "http://localhost:9000", "access", "secret");
577 assert_eq!(alias.name, "test");
578 assert_eq!(alias.endpoint, "http://localhost:9000");
579 assert_eq!(alias.region, "us-east-1");
580 assert_eq!(alias.signature, "v4");
581 assert_eq!(alias.bucket_lookup, "auto");
582 assert!(!alias.insecure);
583 }
584
585 #[test]
586 fn test_alias_manager_set_and_get() {
587 let (manager, _temp_dir) = temp_alias_manager();
588
589 let alias = Alias::new("local", "http://localhost:9000", "accesskey", "secretkey");
590 manager.set(alias).unwrap();
591
592 let retrieved = manager.get("local").unwrap();
593 assert_eq!(retrieved.name, "local");
594 assert_eq!(retrieved.endpoint, "http://localhost:9000");
595 }
596
597 #[test]
598 fn test_alias_manager_list() {
599 let (manager, _temp_dir) = temp_alias_manager();
600
601 manager
602 .set(Alias::new("a", "http://a:9000", "a", "a"))
603 .unwrap();
604 manager
605 .set(Alias::new("b", "http://b:9000", "b", "b"))
606 .unwrap();
607
608 let aliases = manager.list().unwrap();
609 assert_eq!(aliases.len(), 2);
610 }
611
612 #[test]
613 fn test_alias_manager_remove() {
614 let (manager, _temp_dir) = temp_alias_manager();
615
616 manager
617 .set(Alias::new("test", "http://localhost:9000", "a", "b"))
618 .unwrap();
619 assert!(manager.exists("test").unwrap());
620
621 manager.remove("test").unwrap();
622 assert!(!manager.exists("test").unwrap());
623 }
624
625 #[test]
626 fn test_alias_manager_remove_not_found() {
627 let (manager, _temp_dir) = temp_alias_manager();
628
629 let result = manager.remove("nonexistent");
630 assert!(result.is_err());
631 assert!(matches!(result.unwrap_err(), Error::AliasNotFound(_)));
632 }
633
634 #[test]
635 fn test_alias_manager_get_not_found() {
636 let (manager, _temp_dir) = temp_alias_manager();
637
638 let result = manager.get("nonexistent");
639 assert!(result.is_err());
640 assert!(matches!(result.unwrap_err(), Error::AliasNotFound(_)));
641 }
642
643 #[test]
644 fn test_alias_update_existing() {
645 let (manager, _temp_dir) = temp_alias_manager();
646
647 manager
648 .set(Alias::new("test", "http://old:9000", "a", "b"))
649 .unwrap();
650 manager
651 .set(Alias::new("test", "http://new:9000", "c", "d"))
652 .unwrap();
653
654 let aliases = manager.list().unwrap();
655 assert_eq!(aliases.len(), 1);
656 assert_eq!(aliases[0].endpoint, "http://new:9000");
657 }
658
659 #[test]
660 fn import_rejects_all_conflicts_before_writing() {
661 let (manager, _temp_dir) = temp_alias_manager();
662 manager
663 .set(Alias::new("existing", "http://old:9000", "a", "b"))
664 .unwrap();
665
666 let result = manager.import(
667 vec![
668 Alias::new("new", "http://new:9000", "", ""),
669 Alias::new("existing", "http://replacement:9000", "", ""),
670 ],
671 false,
672 );
673
674 assert!(matches!(result, Err(Error::Config(_))));
675 assert!(matches!(manager.get("new"), Err(Error::AliasNotFound(_))));
676 assert_eq!(manager.get("existing").unwrap().endpoint, "http://old:9000");
677 }
678
679 #[test]
680 fn import_replaces_conflicts_in_one_write() {
681 let (manager, _temp_dir) = temp_alias_manager();
682 manager
683 .set(Alias::new("existing", "http://old:9000", "a", "b"))
684 .unwrap();
685
686 manager
687 .import(
688 vec![
689 Alias::new("new", "http://new:9000", "", ""),
690 Alias::new("existing", "http://replacement:9000", "", ""),
691 ],
692 true,
693 )
694 .unwrap();
695
696 assert_eq!(manager.list().unwrap().len(), 2);
697 assert_eq!(
698 manager.get("existing").unwrap().endpoint,
699 "http://replacement:9000"
700 );
701 }
702
703 #[test]
704 fn import_rejects_duplicate_names_before_writing() {
705 let (manager, _temp_dir) = temp_alias_manager();
706
707 let result = manager.import(
708 vec![
709 Alias::new("duplicate", "http://one:9000", "", ""),
710 Alias::new("duplicate", "http://two:9000", "", ""),
711 ],
712 true,
713 );
714
715 assert!(matches!(result, Err(Error::Config(_))));
716 assert!(manager.list().unwrap().is_empty());
717 }
718
719 #[test]
720 fn test_parse_rc_host_alias() {
721 let alias =
722 parse_env_alias("myalias", "https://ACCESS_KEY:SECRET_KEY@rustfs.local:9000").unwrap();
723
724 assert_eq!(alias.name, "myalias");
725 assert_eq!(alias.endpoint, "https://rustfs.local:9000");
726 assert_eq!(alias.access_key, "ACCESS_KEY");
727 assert_eq!(alias.secret_key, "SECRET_KEY");
728 assert_eq!(alias.region, "us-east-1");
729 assert_eq!(alias.bucket_lookup, "auto");
730 }
731
732 #[test]
733 fn test_validate_alias_endpoint_rejects_volume_expansion_endpoint() {
734 let result = validate_alias_endpoint("http://rustfs-node{1...32}:9000");
735
736 assert!(result.is_err());
737 assert!(
738 result
739 .unwrap_err()
740 .to_string()
741 .contains("RustFS volume expansion patterns are not supported")
742 );
743 }
744
745 #[test]
746 fn test_validate_alias_endpoint_rejects_missing_scheme() {
747 let result = validate_alias_endpoint("localhost:9000");
748
749 assert!(result.is_err());
750 assert!(
751 result
752 .unwrap_err()
753 .to_string()
754 .contains("Endpoint must use an http or https URL")
755 );
756 }
757
758 #[test]
759 fn test_validate_alias_endpoint_rejects_non_http_scheme() {
760 let result = validate_alias_endpoint("ftp://localhost:9000");
761
762 assert!(result.is_err());
763 assert!(
764 result
765 .unwrap_err()
766 .to_string()
767 .contains("Endpoint must use an http or https URL")
768 );
769 }
770
771 #[test]
772 fn test_validate_alias_endpoint_rejects_embedded_credentials() {
773 let result = validate_alias_endpoint("http://access:secret@localhost:9000");
774
775 assert!(result.is_err());
776 assert!(
777 result
778 .unwrap_err()
779 .to_string()
780 .contains("Endpoint must not include credentials")
781 );
782 }
783
784 #[test]
785 fn test_validate_alias_endpoint_rejects_path_query_and_fragment() {
786 for endpoint in [
787 "http://localhost:9000/api",
788 "http://localhost:9000?region=us-east-1",
789 "http://localhost:9000#alias",
790 ] {
791 let result = validate_alias_endpoint(endpoint);
792
793 assert!(result.is_err(), "endpoint should be rejected: {endpoint}");
794 assert!(
795 result
796 .unwrap_err()
797 .to_string()
798 .contains("Endpoint must not include a non-root path, query, or fragment")
799 );
800 }
801 }
802
803 #[test]
804 fn test_validate_alias_endpoint_accepts_http_url_with_host() {
805 validate_alias_endpoint("http://localhost:9000").unwrap();
806 validate_alias_endpoint("https://s3.amazonaws.com").unwrap();
807 }
808
809 #[test]
810 fn test_parse_rc_host_alias_decodes_credentials() {
811 let alias =
812 parse_env_alias("encoded", "https://ACCESS%2FKEY:SECRET%40KEY@rustfs.local").unwrap();
813
814 assert_eq!(alias.access_key, "ACCESS/KEY");
815 assert_eq!(alias.secret_key, "SECRET@KEY");
816 }
817
818 #[test]
819 fn test_parse_rc_host_alias_requires_credentials() {
820 let result = parse_env_alias("missing", "https://rustfs.local");
821
822 assert!(result.is_err());
823 assert!(matches!(result.unwrap_err(), Error::Config(_)));
824 }
825
826 #[test]
827 fn test_parse_rc_host_alias_rejects_invalid_percent_encoding() {
828 let result = parse_env_alias("invalid", "https://ACCESS_KEY:SECRET%ZZ@rustfs.local");
829
830 assert!(result.is_err());
831 let error = result.unwrap_err().to_string();
832 assert!(error.contains("invalid percent-encoding in secret key"));
833 assert!(!error.contains("SECRET"));
834 }
835
836 #[test]
837 fn test_parse_rc_host_alias_rejects_non_utf8_percent_encoded_secret_key() {
838 let result = parse_env_alias("invalid", "https://ACCESS_KEY:SECRET%FF@rustfs.local");
839
840 assert!(result.is_err());
841 let error = result.unwrap_err().to_string();
842 assert!(error.contains("invalid percent-encoding in secret key"));
843 assert!(!error.contains("ACCESS_KEY"));
844 assert!(!error.contains("SECRET"));
845 }
846
847 #[test]
848 fn test_parse_rc_host_alias_rejects_invalid_access_key_percent_encoding() {
849 let result = parse_env_alias("invalid", "https://ACCESS%ZZKEY:SECRET_KEY@rustfs.local");
850
851 assert!(result.is_err());
852 let error = result.unwrap_err().to_string();
853 assert!(error.contains("invalid percent-encoding in access key"));
854 assert!(!error.contains("ACCESS"));
855 assert!(!error.contains("SECRET_KEY"));
856 }
857
858 #[test]
859 fn test_env_aliases_from_vars_filters_rc_host_prefix() {
860 let aliases = env_aliases_from_vars(vec![
861 (
862 "RC_HOST_second".to_string(),
863 "https://key2:secret2@second.local".to_string(),
864 ),
865 ("UNRELATED".to_string(), "ignored".to_string()),
866 (
867 "RC_HOST_first".to_string(),
868 "https://key1:secret1@first.local".to_string(),
869 ),
870 ])
871 .unwrap();
872
873 assert_eq!(aliases.len(), 2);
874 assert_eq!(aliases[0].name, "first");
875 assert_eq!(aliases[1].name, "second");
876 }
877
878 #[test]
879 fn test_merge_env_aliases_overrides_config_alias() {
880 let config_alias = Alias::new("local", "http://old:9000", "old", "old");
881 let env_alias = parse_env_alias("local", "https://new:secret@new.local").unwrap();
882
883 let aliases = merge_env_aliases(vec![config_alias], vec![env_alias]);
884
885 assert_eq!(aliases.len(), 1);
886 assert_eq!(aliases[0].endpoint, "https://new.local");
887 assert_eq!(aliases[0].access_key, "new");
888 }
889}