Skip to main content

rc_core/
alias.rs

1//! Alias management
2//!
3//! Aliases are named references to S3-compatible storage endpoints,
4//! including connection details and credentials.
5
6use 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/// Custom S3 request header applied to remote operations.
21#[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
70/// Set process-wide custom request headers for this CLI invocation.
71pub 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
79/// Get process-wide custom request headers for this CLI invocation.
80pub 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/// Retry configuration for an alias
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct RetryConfig {
94    /// Maximum number of retry attempts
95    #[serde(default = "default_max_attempts")]
96    pub max_attempts: u32,
97
98    /// Initial backoff duration in milliseconds
99    #[serde(default = "default_initial_backoff")]
100    pub initial_backoff_ms: u64,
101
102    /// Maximum backoff duration in milliseconds
103    #[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/// Timeout configuration for an alias
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct TimeoutConfig {
132    /// Connection timeout in milliseconds
133    #[serde(default = "default_connect_timeout")]
134    pub connect_ms: u64,
135
136    /// Read timeout in milliseconds
137    #[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/// An alias represents a named S3-compatible storage endpoint
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct Alias {
161    /// Unique name for this alias
162    pub name: String,
163
164    /// S3 endpoint URL
165    pub endpoint: String,
166
167    /// Access key ID. Empty when anonymous authentication is enabled.
168    pub access_key: String,
169
170    /// Secret access key. Empty when anonymous authentication is enabled.
171    pub secret_key: String,
172
173    /// Send requests without SigV4 authentication.
174    #[serde(default)]
175    pub anonymous: bool,
176
177    /// Path to PEM client certificate for mTLS.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub client_cert: Option<String>,
180
181    /// Path to PEM client private key for mTLS.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub client_key: Option<String>,
184
185    /// AWS region
186    #[serde(default = "default_region")]
187    pub region: String,
188
189    /// Signature version: "v4" or "v2"
190    #[serde(default = "default_signature")]
191    pub signature: String,
192
193    /// Bucket lookup style: "auto", "path", or "dns"
194    #[serde(default = "default_bucket_lookup")]
195    pub bucket_lookup: String,
196
197    /// Allow insecure TLS connections
198    #[serde(default)]
199    pub insecure: bool,
200
201    /// Path to custom CA bundle
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub ca_bundle: Option<String>,
204
205    /// Retry configuration
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub retry: Option<RetryConfig>,
208
209    /// Timeout configuration
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub timeout: Option<TimeoutConfig>,
212}
213
214/// Validate that an alias endpoint is a usable HTTP(S) URL.
215pub 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    /// Create a new alias with required fields
248    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    /// Get the effective retry configuration
273    pub fn retry_config(&self) -> RetryConfig {
274        self.retry.clone().unwrap_or_default()
275    }
276
277    /// Get the effective timeout configuration
278    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    Ok(())
391}
392
393fn decode_env_alias_credential(value: &str, var_name: &str, field: &str) -> Result<String> {
394    if has_invalid_percent_encoding(value) {
395        return Err(Error::Config(format!(
396            "{var_name} contains invalid percent-encoding in {field}"
397        )));
398    }
399
400    urlencoding::decode(value)
401        .map(|decoded| decoded.into_owned())
402        .map_err(|e| {
403            Error::Config(format!(
404                "{var_name} contains invalid percent-encoding in {field}: {e}"
405            ))
406        })
407}
408
409fn has_invalid_percent_encoding(value: &str) -> bool {
410    let bytes = value.as_bytes();
411    let mut index = 0;
412
413    while index < bytes.len() {
414        if bytes[index] != b'%' {
415            index += 1;
416            continue;
417        }
418
419        if index + 2 >= bytes.len()
420            || !bytes[index + 1].is_ascii_hexdigit()
421            || !bytes[index + 2].is_ascii_hexdigit()
422        {
423            return true;
424        }
425
426        index += 3;
427    }
428
429    false
430}
431
432fn merge_env_aliases(mut aliases: Vec<Alias>, env_aliases: Vec<Alias>) -> Vec<Alias> {
433    for env_alias in env_aliases {
434        aliases.retain(|alias| alias.name != env_alias.name);
435        aliases.push(env_alias);
436    }
437
438    aliases
439}
440
441/// Manager for alias operations
442pub struct AliasManager {
443    config_manager: ConfigManager,
444}
445
446impl AliasManager {
447    /// Create a new AliasManager with a specific ConfigManager
448    pub fn with_config_manager(config_manager: ConfigManager) -> Self {
449        Self { config_manager }
450    }
451
452    /// Create a new AliasManager using the default config location
453    pub fn new() -> Result<Self> {
454        let config_manager = ConfigManager::new()?;
455        Ok(Self { config_manager })
456    }
457
458    /// List all configured aliases
459    pub fn list(&self) -> Result<Vec<Alias>> {
460        let config = self.config_manager.load()?;
461        let env_aliases = env_aliases()?;
462        Ok(merge_env_aliases(config.aliases, env_aliases))
463    }
464
465    /// Get an alias by name
466    pub fn get(&self, name: &str) -> Result<Alias> {
467        if let Some(alias) = env_alias(name)? {
468            return Ok(alias);
469        }
470
471        let config = self.config_manager.load()?;
472        config
473            .aliases
474            .into_iter()
475            .find(|a| a.name == name)
476            .ok_or_else(|| Error::AliasNotFound(name.to_string()))
477    }
478
479    /// Add or update an alias
480    pub fn set(&self, alias: Alias) -> Result<()> {
481        let mut config = self.config_manager.load()?;
482
483        // Remove existing alias with same name
484        config.aliases.retain(|a| a.name != alias.name);
485        config.aliases.push(alias);
486
487        self.config_manager.save(&config)
488    }
489
490    /// Remove an alias
491    pub fn remove(&self, name: &str) -> Result<()> {
492        let mut config = self.config_manager.load()?;
493        let original_len = config.aliases.len();
494
495        config.aliases.retain(|a| a.name != name);
496
497        if config.aliases.len() == original_len {
498            return Err(Error::AliasNotFound(name.to_string()));
499        }
500
501        self.config_manager.save(&config)
502    }
503
504    /// Check if an alias exists
505    pub fn exists(&self, name: &str) -> Result<bool> {
506        if env_alias(name)?.is_some() {
507            return Ok(true);
508        }
509
510        let config = self.config_manager.load()?;
511        Ok(config.aliases.iter().any(|a| a.name == name))
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use tempfile::TempDir;
519
520    fn temp_alias_manager() -> (AliasManager, TempDir) {
521        let temp_dir = TempDir::new().unwrap();
522        let config_path = temp_dir.path().join("config.toml");
523        let config_manager = ConfigManager::with_path(config_path);
524        let alias_manager = AliasManager::with_config_manager(config_manager);
525        (alias_manager, temp_dir)
526    }
527
528    #[test]
529    fn test_alias_new() {
530        let alias = Alias::new("test", "http://localhost:9000", "access", "secret");
531        assert_eq!(alias.name, "test");
532        assert_eq!(alias.endpoint, "http://localhost:9000");
533        assert_eq!(alias.region, "us-east-1");
534        assert_eq!(alias.signature, "v4");
535        assert_eq!(alias.bucket_lookup, "auto");
536        assert!(!alias.insecure);
537    }
538
539    #[test]
540    fn test_alias_manager_set_and_get() {
541        let (manager, _temp_dir) = temp_alias_manager();
542
543        let alias = Alias::new("local", "http://localhost:9000", "accesskey", "secretkey");
544        manager.set(alias).unwrap();
545
546        let retrieved = manager.get("local").unwrap();
547        assert_eq!(retrieved.name, "local");
548        assert_eq!(retrieved.endpoint, "http://localhost:9000");
549    }
550
551    #[test]
552    fn test_alias_manager_list() {
553        let (manager, _temp_dir) = temp_alias_manager();
554
555        manager
556            .set(Alias::new("a", "http://a:9000", "a", "a"))
557            .unwrap();
558        manager
559            .set(Alias::new("b", "http://b:9000", "b", "b"))
560            .unwrap();
561
562        let aliases = manager.list().unwrap();
563        assert_eq!(aliases.len(), 2);
564    }
565
566    #[test]
567    fn test_alias_manager_remove() {
568        let (manager, _temp_dir) = temp_alias_manager();
569
570        manager
571            .set(Alias::new("test", "http://localhost:9000", "a", "b"))
572            .unwrap();
573        assert!(manager.exists("test").unwrap());
574
575        manager.remove("test").unwrap();
576        assert!(!manager.exists("test").unwrap());
577    }
578
579    #[test]
580    fn test_alias_manager_remove_not_found() {
581        let (manager, _temp_dir) = temp_alias_manager();
582
583        let result = manager.remove("nonexistent");
584        assert!(result.is_err());
585        assert!(matches!(result.unwrap_err(), Error::AliasNotFound(_)));
586    }
587
588    #[test]
589    fn test_alias_manager_get_not_found() {
590        let (manager, _temp_dir) = temp_alias_manager();
591
592        let result = manager.get("nonexistent");
593        assert!(result.is_err());
594        assert!(matches!(result.unwrap_err(), Error::AliasNotFound(_)));
595    }
596
597    #[test]
598    fn test_alias_update_existing() {
599        let (manager, _temp_dir) = temp_alias_manager();
600
601        manager
602            .set(Alias::new("test", "http://old:9000", "a", "b"))
603            .unwrap();
604        manager
605            .set(Alias::new("test", "http://new:9000", "c", "d"))
606            .unwrap();
607
608        let aliases = manager.list().unwrap();
609        assert_eq!(aliases.len(), 1);
610        assert_eq!(aliases[0].endpoint, "http://new:9000");
611    }
612
613    #[test]
614    fn test_parse_rc_host_alias() {
615        let alias =
616            parse_env_alias("myalias", "https://ACCESS_KEY:SECRET_KEY@rustfs.local:9000").unwrap();
617
618        assert_eq!(alias.name, "myalias");
619        assert_eq!(alias.endpoint, "https://rustfs.local:9000");
620        assert_eq!(alias.access_key, "ACCESS_KEY");
621        assert_eq!(alias.secret_key, "SECRET_KEY");
622        assert_eq!(alias.region, "us-east-1");
623        assert_eq!(alias.bucket_lookup, "auto");
624    }
625
626    #[test]
627    fn test_validate_alias_endpoint_rejects_volume_expansion_endpoint() {
628        let result = validate_alias_endpoint("http://rustfs-node{1...32}:9000");
629
630        assert!(result.is_err());
631        assert!(
632            result
633                .unwrap_err()
634                .to_string()
635                .contains("RustFS volume expansion patterns are not supported")
636        );
637    }
638
639    #[test]
640    fn test_validate_alias_endpoint_rejects_missing_scheme() {
641        let result = validate_alias_endpoint("localhost:9000");
642
643        assert!(result.is_err());
644        assert!(
645            result
646                .unwrap_err()
647                .to_string()
648                .contains("Endpoint must use an http or https URL")
649        );
650    }
651
652    #[test]
653    fn test_validate_alias_endpoint_rejects_non_http_scheme() {
654        let result = validate_alias_endpoint("ftp://localhost:9000");
655
656        assert!(result.is_err());
657        assert!(
658            result
659                .unwrap_err()
660                .to_string()
661                .contains("Endpoint must use an http or https URL")
662        );
663    }
664
665    #[test]
666    fn test_validate_alias_endpoint_rejects_embedded_credentials() {
667        let result = validate_alias_endpoint("http://access:secret@localhost:9000");
668
669        assert!(result.is_err());
670        assert!(
671            result
672                .unwrap_err()
673                .to_string()
674                .contains("Endpoint must not include credentials")
675        );
676    }
677
678    #[test]
679    fn test_validate_alias_endpoint_accepts_http_url_with_host() {
680        validate_alias_endpoint("http://localhost:9000").unwrap();
681        validate_alias_endpoint("https://s3.amazonaws.com").unwrap();
682    }
683
684    #[test]
685    fn test_parse_rc_host_alias_decodes_credentials() {
686        let alias =
687            parse_env_alias("encoded", "https://ACCESS%2FKEY:SECRET%40KEY@rustfs.local").unwrap();
688
689        assert_eq!(alias.access_key, "ACCESS/KEY");
690        assert_eq!(alias.secret_key, "SECRET@KEY");
691    }
692
693    #[test]
694    fn test_parse_rc_host_alias_requires_credentials() {
695        let result = parse_env_alias("missing", "https://rustfs.local");
696
697        assert!(result.is_err());
698        assert!(matches!(result.unwrap_err(), Error::Config(_)));
699    }
700
701    #[test]
702    fn test_parse_rc_host_alias_rejects_invalid_percent_encoding() {
703        let result = parse_env_alias("invalid", "https://ACCESS_KEY:SECRET%ZZ@rustfs.local");
704
705        assert!(result.is_err());
706        let error = result.unwrap_err().to_string();
707        assert!(error.contains("invalid percent-encoding in secret key"));
708        assert!(!error.contains("SECRET"));
709    }
710
711    #[test]
712    fn test_parse_rc_host_alias_rejects_non_utf8_percent_encoded_secret_key() {
713        let result = parse_env_alias("invalid", "https://ACCESS_KEY:SECRET%FF@rustfs.local");
714
715        assert!(result.is_err());
716        let error = result.unwrap_err().to_string();
717        assert!(error.contains("invalid percent-encoding in secret key"));
718        assert!(!error.contains("ACCESS_KEY"));
719        assert!(!error.contains("SECRET"));
720    }
721
722    #[test]
723    fn test_parse_rc_host_alias_rejects_invalid_access_key_percent_encoding() {
724        let result = parse_env_alias("invalid", "https://ACCESS%ZZKEY:SECRET_KEY@rustfs.local");
725
726        assert!(result.is_err());
727        let error = result.unwrap_err().to_string();
728        assert!(error.contains("invalid percent-encoding in access key"));
729        assert!(!error.contains("ACCESS"));
730        assert!(!error.contains("SECRET_KEY"));
731    }
732
733    #[test]
734    fn test_env_aliases_from_vars_filters_rc_host_prefix() {
735        let aliases = env_aliases_from_vars(vec![
736            (
737                "RC_HOST_second".to_string(),
738                "https://key2:secret2@second.local".to_string(),
739            ),
740            ("UNRELATED".to_string(), "ignored".to_string()),
741            (
742                "RC_HOST_first".to_string(),
743                "https://key1:secret1@first.local".to_string(),
744            ),
745        ])
746        .unwrap();
747
748        assert_eq!(aliases.len(), 2);
749        assert_eq!(aliases[0].name, "first");
750        assert_eq!(aliases[1].name, "second");
751    }
752
753    #[test]
754    fn test_merge_env_aliases_overrides_config_alias() {
755        let config_alias = Alias::new("local", "http://old:9000", "old", "old");
756        let env_alias = parse_env_alias("local", "https://new:secret@new.local").unwrap();
757
758        let aliases = merge_env_aliases(vec![config_alias], vec![env_alias]);
759
760        assert_eq!(aliases.len(), 1);
761        assert_eq!(aliases[0].endpoint, "https://new.local");
762        assert_eq!(aliases[0].access_key, "new");
763    }
764}