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
168    pub access_key: String,
169
170    /// Secret access key
171    pub secret_key: String,
172
173    /// AWS region
174    #[serde(default = "default_region")]
175    pub region: String,
176
177    /// Signature version: "v4" or "v2"
178    #[serde(default = "default_signature")]
179    pub signature: String,
180
181    /// Bucket lookup style: "auto", "path", or "dns"
182    #[serde(default = "default_bucket_lookup")]
183    pub bucket_lookup: String,
184
185    /// Allow insecure TLS connections
186    #[serde(default)]
187    pub insecure: bool,
188
189    /// Path to custom CA bundle
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub ca_bundle: Option<String>,
192
193    /// Retry configuration
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub retry: Option<RetryConfig>,
196
197    /// Timeout configuration
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub timeout: Option<TimeoutConfig>,
200}
201
202/// Validate that an alias endpoint is a usable HTTP(S) URL.
203pub 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    /// Create a new alias with required fields
236    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    /// Get the effective retry configuration
258    pub fn retry_config(&self) -> RetryConfig {
259        self.retry.clone().unwrap_or_default()
260    }
261
262    /// Get the effective timeout configuration
263    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
426/// Manager for alias operations
427pub struct AliasManager {
428    config_manager: ConfigManager,
429}
430
431impl AliasManager {
432    /// Create a new AliasManager with a specific ConfigManager
433    pub fn with_config_manager(config_manager: ConfigManager) -> Self {
434        Self { config_manager }
435    }
436
437    /// Create a new AliasManager using the default config location
438    pub fn new() -> Result<Self> {
439        let config_manager = ConfigManager::new()?;
440        Ok(Self { config_manager })
441    }
442
443    /// List all configured aliases
444    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    /// Get an alias by name
451    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    /// Add or update an alias
465    pub fn set(&self, alias: Alias) -> Result<()> {
466        let mut config = self.config_manager.load()?;
467
468        // Remove existing alias with same name
469        config.aliases.retain(|a| a.name != alias.name);
470        config.aliases.push(alias);
471
472        self.config_manager.save(&config)
473    }
474
475    /// Remove an alias
476    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    /// Check if an alias exists
490    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}