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;
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/// Retry configuration for an alias
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct RetryConfig {
19    /// Maximum number of retry attempts
20    #[serde(default = "default_max_attempts")]
21    pub max_attempts: u32,
22
23    /// Initial backoff duration in milliseconds
24    #[serde(default = "default_initial_backoff")]
25    pub initial_backoff_ms: u64,
26
27    /// Maximum backoff duration in milliseconds
28    #[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/// Timeout configuration for an alias
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct TimeoutConfig {
57    /// Connection timeout in milliseconds
58    #[serde(default = "default_connect_timeout")]
59    pub connect_ms: u64,
60
61    /// Read timeout in milliseconds
62    #[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/// An alias represents a named S3-compatible storage endpoint
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct Alias {
86    /// Unique name for this alias
87    pub name: String,
88
89    /// S3 endpoint URL
90    pub endpoint: String,
91
92    /// Access key ID
93    pub access_key: String,
94
95    /// Secret access key
96    pub secret_key: String,
97
98    /// AWS region
99    #[serde(default = "default_region")]
100    pub region: String,
101
102    /// Signature version: "v4" or "v2"
103    #[serde(default = "default_signature")]
104    pub signature: String,
105
106    /// Bucket lookup style: "auto", "path", or "dns"
107    #[serde(default = "default_bucket_lookup")]
108    pub bucket_lookup: String,
109
110    /// Allow insecure TLS connections
111    #[serde(default)]
112    pub insecure: bool,
113
114    /// Path to custom CA bundle
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub ca_bundle: Option<String>,
117
118    /// Retry configuration
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub retry: Option<RetryConfig>,
121
122    /// Timeout configuration
123    #[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    /// Create a new alias with required fields
141    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    /// Get the effective retry configuration
163    pub fn retry_config(&self) -> RetryConfig {
164        self.retry.clone().unwrap_or_default()
165    }
166
167    /// Get the effective timeout configuration
168    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
296/// Manager for alias operations
297pub struct AliasManager {
298    config_manager: ConfigManager,
299}
300
301impl AliasManager {
302    /// Create a new AliasManager with a specific ConfigManager
303    pub fn with_config_manager(config_manager: ConfigManager) -> Self {
304        Self { config_manager }
305    }
306
307    /// Create a new AliasManager using the default config location
308    pub fn new() -> Result<Self> {
309        let config_manager = ConfigManager::new()?;
310        Ok(Self { config_manager })
311    }
312
313    /// List all configured aliases
314    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    /// Get an alias by name
321    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    /// Add or update an alias
335    pub fn set(&self, alias: Alias) -> Result<()> {
336        let mut config = self.config_manager.load()?;
337
338        // Remove existing alias with same name
339        config.aliases.retain(|a| a.name != alias.name);
340        config.aliases.push(alias);
341
342        self.config_manager.save(&config)
343    }
344
345    /// Remove an alias
346    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    /// Check if an alias exists
360    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}