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 = "default_region")]
175 pub region: String,
176
177 #[serde(default = "default_signature")]
179 pub signature: String,
180
181 #[serde(default = "default_bucket_lookup")]
183 pub bucket_lookup: String,
184
185 #[serde(default)]
187 pub insecure: bool,
188
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub ca_bundle: Option<String>,
192
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub retry: Option<RetryConfig>,
196
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub timeout: Option<TimeoutConfig>,
200}
201
202pub fn validate_alias_endpoint(value: &str) -> Result<()> {
204 if value.contains('{') || value.contains('}') {
205 return Err(Error::Config(
206 "Endpoint must be a single S3 service URL; RustFS volume expansion patterns are not supported".into(),
207 ));
208 }
209
210 let url = Url::parse(value)
211 .map_err(|e| Error::Config(format!("Endpoint must be a valid URL: {e}")))?;
212
213 if !url.username().is_empty() || url.password().is_some() {
214 return Err(Error::Config(
215 "Endpoint must not include credentials; pass access key and secret key as separate arguments".into(),
216 ));
217 }
218
219 validate_http_endpoint_url(&url, "Endpoint")
220}
221
222fn default_region() -> String {
223 "us-east-1".to_string()
224}
225
226fn default_signature() -> String {
227 "v4".to_string()
228}
229
230fn default_bucket_lookup() -> String {
231 "auto".to_string()
232}
233
234impl Alias {
235 pub fn new(
237 name: impl Into<String>,
238 endpoint: impl Into<String>,
239 access_key: impl Into<String>,
240 secret_key: impl Into<String>,
241 ) -> Self {
242 Self {
243 name: name.into(),
244 endpoint: endpoint.into(),
245 access_key: access_key.into(),
246 secret_key: secret_key.into(),
247 region: default_region(),
248 signature: default_signature(),
249 bucket_lookup: default_bucket_lookup(),
250 insecure: false,
251 ca_bundle: None,
252 retry: None,
253 timeout: None,
254 }
255 }
256
257 pub fn retry_config(&self) -> RetryConfig {
259 self.retry.clone().unwrap_or_default()
260 }
261
262 pub fn timeout_config(&self) -> TimeoutConfig {
264 self.timeout.clone().unwrap_or_default()
265 }
266}
267
268fn env_alias_var_name(name: &str) -> String {
269 format!("{RC_HOST_PREFIX}{name}")
270}
271
272fn env_alias(name: &str) -> Result<Option<Alias>> {
273 let var_name = env_alias_var_name(name);
274 let Some(value) = env::var_os(&var_name) else {
275 return Ok(None);
276 };
277
278 let value = value
279 .into_string()
280 .map_err(|_| Error::Config(format!("{var_name} must be valid UTF-8")))?;
281 parse_env_alias(name, &value).map(Some)
282}
283
284fn env_aliases() -> Result<Vec<Alias>> {
285 let mut vars = Vec::new();
286
287 for (key, value) in env::vars_os() {
288 let Ok(key) = key.into_string() else {
289 continue;
290 };
291
292 if !key.starts_with(RC_HOST_PREFIX) {
293 continue;
294 }
295
296 let value = value
297 .into_string()
298 .map_err(|_| Error::Config(format!("{key} must be valid UTF-8")))?;
299 vars.push((key, value));
300 }
301
302 env_aliases_from_vars(vars)
303}
304
305fn env_aliases_from_vars<I, K, V>(vars: I) -> Result<Vec<Alias>>
306where
307 I: IntoIterator<Item = (K, V)>,
308 K: AsRef<str>,
309 V: AsRef<str>,
310{
311 let mut aliases = Vec::new();
312
313 for (key, value) in vars {
314 let key = key.as_ref();
315 let Some(alias_name) = key.strip_prefix(RC_HOST_PREFIX) else {
316 continue;
317 };
318
319 if alias_name.is_empty() {
320 return Err(Error::Config("RC_HOST_ must include an alias name".into()));
321 }
322
323 aliases.push(parse_env_alias(alias_name, value.as_ref())?);
324 }
325
326 aliases.sort_by(|a, b| a.name.cmp(&b.name));
327 Ok(aliases)
328}
329
330fn parse_env_alias(name: &str, value: &str) -> Result<Alias> {
331 let var_name = env_alias_var_name(name);
332 let mut url = Url::parse(value)
333 .map_err(|e| Error::Config(format!("{var_name} must be a valid URL: {e}")))?;
334
335 validate_http_endpoint_url(&url, &var_name)?;
336
337 let access_key = url.username();
338 let Some(secret_key) = url.password() else {
339 return Err(Error::Config(format!(
340 "{var_name} must include access key and secret key credentials"
341 )));
342 };
343
344 if access_key.is_empty() || secret_key.is_empty() {
345 return Err(Error::Config(format!(
346 "{var_name} must include non-empty access key and secret key credentials"
347 )));
348 }
349
350 let access_key = decode_env_alias_credential(access_key, &var_name, "access key")?;
351 let secret_key = decode_env_alias_credential(secret_key, &var_name, "secret key")?;
352
353 url.set_username("").map_err(|()| {
354 Error::Config(format!("{var_name} credentials cannot be removed from URL"))
355 })?;
356 url.set_password(None).map_err(|()| {
357 Error::Config(format!("{var_name} credentials cannot be removed from URL"))
358 })?;
359
360 let endpoint = url.as_str().trim_end_matches('/').to_string();
361 Ok(Alias::new(name, endpoint, access_key, secret_key))
362}
363
364fn validate_http_endpoint_url(url: &Url, label: &str) -> Result<()> {
365 if !matches!(url.scheme(), "http" | "https") {
366 return Err(Error::Config(format!(
367 "{label} must use an http or https URL"
368 )));
369 }
370
371 if url.host_str().is_none() {
372 return Err(Error::Config(format!("{label} must include a host")));
373 }
374
375 Ok(())
376}
377
378fn decode_env_alias_credential(value: &str, var_name: &str, field: &str) -> Result<String> {
379 if has_invalid_percent_encoding(value) {
380 return Err(Error::Config(format!(
381 "{var_name} contains invalid percent-encoding in {field}"
382 )));
383 }
384
385 urlencoding::decode(value)
386 .map(|decoded| decoded.into_owned())
387 .map_err(|e| {
388 Error::Config(format!(
389 "{var_name} contains invalid percent-encoding in {field}: {e}"
390 ))
391 })
392}
393
394fn has_invalid_percent_encoding(value: &str) -> bool {
395 let bytes = value.as_bytes();
396 let mut index = 0;
397
398 while index < bytes.len() {
399 if bytes[index] != b'%' {
400 index += 1;
401 continue;
402 }
403
404 if index + 2 >= bytes.len()
405 || !bytes[index + 1].is_ascii_hexdigit()
406 || !bytes[index + 2].is_ascii_hexdigit()
407 {
408 return true;
409 }
410
411 index += 3;
412 }
413
414 false
415}
416
417fn merge_env_aliases(mut aliases: Vec<Alias>, env_aliases: Vec<Alias>) -> Vec<Alias> {
418 for env_alias in env_aliases {
419 aliases.retain(|alias| alias.name != env_alias.name);
420 aliases.push(env_alias);
421 }
422
423 aliases
424}
425
426pub struct AliasManager {
428 config_manager: ConfigManager,
429}
430
431impl AliasManager {
432 pub fn with_config_manager(config_manager: ConfigManager) -> Self {
434 Self { config_manager }
435 }
436
437 pub fn new() -> Result<Self> {
439 let config_manager = ConfigManager::new()?;
440 Ok(Self { config_manager })
441 }
442
443 pub fn list(&self) -> Result<Vec<Alias>> {
445 let config = self.config_manager.load()?;
446 let env_aliases = env_aliases()?;
447 Ok(merge_env_aliases(config.aliases, env_aliases))
448 }
449
450 pub fn get(&self, name: &str) -> Result<Alias> {
452 if let Some(alias) = env_alias(name)? {
453 return Ok(alias);
454 }
455
456 let config = self.config_manager.load()?;
457 config
458 .aliases
459 .into_iter()
460 .find(|a| a.name == name)
461 .ok_or_else(|| Error::AliasNotFound(name.to_string()))
462 }
463
464 pub fn set(&self, alias: Alias) -> Result<()> {
466 let mut config = self.config_manager.load()?;
467
468 config.aliases.retain(|a| a.name != alias.name);
470 config.aliases.push(alias);
471
472 self.config_manager.save(&config)
473 }
474
475 pub fn remove(&self, name: &str) -> Result<()> {
477 let mut config = self.config_manager.load()?;
478 let original_len = config.aliases.len();
479
480 config.aliases.retain(|a| a.name != name);
481
482 if config.aliases.len() == original_len {
483 return Err(Error::AliasNotFound(name.to_string()));
484 }
485
486 self.config_manager.save(&config)
487 }
488
489 pub fn exists(&self, name: &str) -> Result<bool> {
491 if env_alias(name)?.is_some() {
492 return Ok(true);
493 }
494
495 let config = self.config_manager.load()?;
496 Ok(config.aliases.iter().any(|a| a.name == name))
497 }
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use tempfile::TempDir;
504
505 fn temp_alias_manager() -> (AliasManager, TempDir) {
506 let temp_dir = TempDir::new().unwrap();
507 let config_path = temp_dir.path().join("config.toml");
508 let config_manager = ConfigManager::with_path(config_path);
509 let alias_manager = AliasManager::with_config_manager(config_manager);
510 (alias_manager, temp_dir)
511 }
512
513 #[test]
514 fn test_alias_new() {
515 let alias = Alias::new("test", "http://localhost:9000", "access", "secret");
516 assert_eq!(alias.name, "test");
517 assert_eq!(alias.endpoint, "http://localhost:9000");
518 assert_eq!(alias.region, "us-east-1");
519 assert_eq!(alias.signature, "v4");
520 assert_eq!(alias.bucket_lookup, "auto");
521 assert!(!alias.insecure);
522 }
523
524 #[test]
525 fn test_alias_manager_set_and_get() {
526 let (manager, _temp_dir) = temp_alias_manager();
527
528 let alias = Alias::new("local", "http://localhost:9000", "accesskey", "secretkey");
529 manager.set(alias).unwrap();
530
531 let retrieved = manager.get("local").unwrap();
532 assert_eq!(retrieved.name, "local");
533 assert_eq!(retrieved.endpoint, "http://localhost:9000");
534 }
535
536 #[test]
537 fn test_alias_manager_list() {
538 let (manager, _temp_dir) = temp_alias_manager();
539
540 manager
541 .set(Alias::new("a", "http://a:9000", "a", "a"))
542 .unwrap();
543 manager
544 .set(Alias::new("b", "http://b:9000", "b", "b"))
545 .unwrap();
546
547 let aliases = manager.list().unwrap();
548 assert_eq!(aliases.len(), 2);
549 }
550
551 #[test]
552 fn test_alias_manager_remove() {
553 let (manager, _temp_dir) = temp_alias_manager();
554
555 manager
556 .set(Alias::new("test", "http://localhost:9000", "a", "b"))
557 .unwrap();
558 assert!(manager.exists("test").unwrap());
559
560 manager.remove("test").unwrap();
561 assert!(!manager.exists("test").unwrap());
562 }
563
564 #[test]
565 fn test_alias_manager_remove_not_found() {
566 let (manager, _temp_dir) = temp_alias_manager();
567
568 let result = manager.remove("nonexistent");
569 assert!(result.is_err());
570 assert!(matches!(result.unwrap_err(), Error::AliasNotFound(_)));
571 }
572
573 #[test]
574 fn test_alias_manager_get_not_found() {
575 let (manager, _temp_dir) = temp_alias_manager();
576
577 let result = manager.get("nonexistent");
578 assert!(result.is_err());
579 assert!(matches!(result.unwrap_err(), Error::AliasNotFound(_)));
580 }
581
582 #[test]
583 fn test_alias_update_existing() {
584 let (manager, _temp_dir) = temp_alias_manager();
585
586 manager
587 .set(Alias::new("test", "http://old:9000", "a", "b"))
588 .unwrap();
589 manager
590 .set(Alias::new("test", "http://new:9000", "c", "d"))
591 .unwrap();
592
593 let aliases = manager.list().unwrap();
594 assert_eq!(aliases.len(), 1);
595 assert_eq!(aliases[0].endpoint, "http://new:9000");
596 }
597
598 #[test]
599 fn test_parse_rc_host_alias() {
600 let alias =
601 parse_env_alias("myalias", "https://ACCESS_KEY:SECRET_KEY@rustfs.local:9000").unwrap();
602
603 assert_eq!(alias.name, "myalias");
604 assert_eq!(alias.endpoint, "https://rustfs.local:9000");
605 assert_eq!(alias.access_key, "ACCESS_KEY");
606 assert_eq!(alias.secret_key, "SECRET_KEY");
607 assert_eq!(alias.region, "us-east-1");
608 assert_eq!(alias.bucket_lookup, "auto");
609 }
610
611 #[test]
612 fn test_validate_alias_endpoint_rejects_volume_expansion_endpoint() {
613 let result = validate_alias_endpoint("http://rustfs-node{1...32}:9000");
614
615 assert!(result.is_err());
616 assert!(
617 result
618 .unwrap_err()
619 .to_string()
620 .contains("RustFS volume expansion patterns are not supported")
621 );
622 }
623
624 #[test]
625 fn test_validate_alias_endpoint_rejects_missing_scheme() {
626 let result = validate_alias_endpoint("localhost:9000");
627
628 assert!(result.is_err());
629 assert!(
630 result
631 .unwrap_err()
632 .to_string()
633 .contains("Endpoint must use an http or https URL")
634 );
635 }
636
637 #[test]
638 fn test_validate_alias_endpoint_rejects_non_http_scheme() {
639 let result = validate_alias_endpoint("ftp://localhost:9000");
640
641 assert!(result.is_err());
642 assert!(
643 result
644 .unwrap_err()
645 .to_string()
646 .contains("Endpoint must use an http or https URL")
647 );
648 }
649
650 #[test]
651 fn test_validate_alias_endpoint_rejects_embedded_credentials() {
652 let result = validate_alias_endpoint("http://access:secret@localhost:9000");
653
654 assert!(result.is_err());
655 assert!(
656 result
657 .unwrap_err()
658 .to_string()
659 .contains("Endpoint must not include credentials")
660 );
661 }
662
663 #[test]
664 fn test_validate_alias_endpoint_accepts_http_url_with_host() {
665 validate_alias_endpoint("http://localhost:9000").unwrap();
666 validate_alias_endpoint("https://s3.amazonaws.com").unwrap();
667 }
668
669 #[test]
670 fn test_parse_rc_host_alias_decodes_credentials() {
671 let alias =
672 parse_env_alias("encoded", "https://ACCESS%2FKEY:SECRET%40KEY@rustfs.local").unwrap();
673
674 assert_eq!(alias.access_key, "ACCESS/KEY");
675 assert_eq!(alias.secret_key, "SECRET@KEY");
676 }
677
678 #[test]
679 fn test_parse_rc_host_alias_requires_credentials() {
680 let result = parse_env_alias("missing", "https://rustfs.local");
681
682 assert!(result.is_err());
683 assert!(matches!(result.unwrap_err(), Error::Config(_)));
684 }
685
686 #[test]
687 fn test_parse_rc_host_alias_rejects_invalid_percent_encoding() {
688 let result = parse_env_alias("invalid", "https://ACCESS_KEY:SECRET%ZZ@rustfs.local");
689
690 assert!(result.is_err());
691 let error = result.unwrap_err().to_string();
692 assert!(error.contains("invalid percent-encoding in secret key"));
693 assert!(!error.contains("SECRET"));
694 }
695
696 #[test]
697 fn test_parse_rc_host_alias_rejects_non_utf8_percent_encoded_secret_key() {
698 let result = parse_env_alias("invalid", "https://ACCESS_KEY:SECRET%FF@rustfs.local");
699
700 assert!(result.is_err());
701 let error = result.unwrap_err().to_string();
702 assert!(error.contains("invalid percent-encoding in secret key"));
703 assert!(!error.contains("ACCESS_KEY"));
704 assert!(!error.contains("SECRET"));
705 }
706
707 #[test]
708 fn test_parse_rc_host_alias_rejects_invalid_access_key_percent_encoding() {
709 let result = parse_env_alias("invalid", "https://ACCESS%ZZKEY:SECRET_KEY@rustfs.local");
710
711 assert!(result.is_err());
712 let error = result.unwrap_err().to_string();
713 assert!(error.contains("invalid percent-encoding in access key"));
714 assert!(!error.contains("ACCESS"));
715 assert!(!error.contains("SECRET_KEY"));
716 }
717
718 #[test]
719 fn test_env_aliases_from_vars_filters_rc_host_prefix() {
720 let aliases = env_aliases_from_vars(vec![
721 (
722 "RC_HOST_second".to_string(),
723 "https://key2:secret2@second.local".to_string(),
724 ),
725 ("UNRELATED".to_string(), "ignored".to_string()),
726 (
727 "RC_HOST_first".to_string(),
728 "https://key1:secret1@first.local".to_string(),
729 ),
730 ])
731 .unwrap();
732
733 assert_eq!(aliases.len(), 2);
734 assert_eq!(aliases[0].name, "first");
735 assert_eq!(aliases[1].name, "second");
736 }
737
738 #[test]
739 fn test_merge_env_aliases_overrides_config_alias() {
740 let config_alias = Alias::new("local", "http://old:9000", "old", "old");
741 let env_alias = parse_env_alias("local", "https://new:secret@new.local").unwrap();
742
743 let aliases = merge_env_aliases(vec![config_alias], vec![env_alias]);
744
745 assert_eq!(aliases.len(), 1);
746 assert_eq!(aliases[0].endpoint, "https://new.local");
747 assert_eq!(aliases[0].access_key, "new");
748 }
749}