garage_sdk/config/
data.rs

1use crate::error::{Error, Result};
2use std::path::Path;
3use std::time::Duration;
4use url::Url;
5
6const DEFAULT_MAX_FILE_SIZE: u64 = 100 * 1024 * 1024;
7const DEFAULT_DOWNLOAD_TIMEOUT_SECS: u64 = 30;
8pub(crate) const DEFAULT_MAX_BUFFERED_BYTES: u64 = 8 * 1024 * 1024;
9
10/// Configuration for the Garage uploader.
11///
12/// Use [`UploaderConfigBuilder`] to construct this.
13#[derive(Clone, Debug)]
14pub struct UploaderConfig {
15    /// S3-compatible endpoint URL (e.g., "<https://s3.example.com>")
16    pub endpoint: String,
17
18    /// S3 region (e.g., "garage" or "us-east-1")
19    pub region: String,
20
21    /// Target bucket name
22    pub bucket: String,
23
24    /// Public CDN base URL for constructing public URLs
25    pub public_base_url: String,
26
27    /// Optional prefix for all uploaded keys (e.g., "uploads/")
28    pub key_prefix: Option<String>,
29
30    /// AWS/Garage access key ID
31    pub access_key_id: String,
32
33    /// AWS/Garage secret access key
34    pub secret_access_key: String,
35
36    /// HTTP client timeout for downloads
37    pub download_timeout: Duration,
38
39    /// Maximum file size in bytes (default: 100MB)
40    pub max_file_size: u64,
41
42    /// Maximum size in bytes to buffer in memory when downloading (default: 8MB).
43    ///
44    /// Responses larger than this (or with unknown `Content-Length`) are streamed and
45    /// still capped by `max_file_size`.
46    pub max_buffered_bytes: u64,
47}
48
49/// Secret file names used by [`UploaderConfig::from_secret_dir_with_names`].
50#[derive(Clone, Debug)]
51pub struct SecretFileNames {
52    pub endpoint: String,
53    pub region: Option<String>,
54    pub bucket: String,
55    pub public_url: String,
56    pub key_prefix: Option<String>,
57    pub access_key_id: String,
58    pub secret_access_key: String,
59}
60
61impl Default for SecretFileNames {
62    fn default() -> Self {
63        Self {
64            endpoint: "endpoint".into(),
65            region: Some("region".into()),
66            bucket: "bucket".into(),
67            public_url: "public_url".into(),
68            key_prefix: Some("key_prefix".into()),
69            access_key_id: "access_key_id".into(),
70            secret_access_key: "secret_access_key".into(),
71        }
72    }
73}
74
75impl SecretFileNames {
76    /// Build secret filenames using a common prefix (e.g., "s3_").
77    pub fn with_prefix(prefix: &str) -> Self {
78        fn name(prefix: &str, value: &str) -> String {
79            format!("{}{}", prefix, value)
80        }
81
82        Self {
83            endpoint: name(prefix, "endpoint"),
84            region: Some(name(prefix, "region")),
85            bucket: name(prefix, "bucket"),
86            public_url: name(prefix, "public_url"),
87            key_prefix: Some(name(prefix, "key_prefix")),
88            access_key_id: name(prefix, "access_key_id"),
89            secret_access_key: name(prefix, "secret_access_key"),
90        }
91    }
92
93    /// Build secret filenames using a common suffix (e.g., "_secret").
94    pub fn with_suffix(suffix: &str) -> Self {
95        fn name(value: &str, suffix: &str) -> String {
96            format!("{}{}", value, suffix)
97        }
98
99        Self {
100            endpoint: name("endpoint", suffix),
101            region: Some(name("region", suffix)),
102            bucket: name("bucket", suffix),
103            public_url: name("public_url", suffix),
104            key_prefix: Some(name("key_prefix", suffix)),
105            access_key_id: name("access_key_id", suffix),
106            secret_access_key: name("secret_access_key", suffix),
107        }
108    }
109}
110
111/// Builder for custom secret file mappings.
112#[derive(Clone, Debug)]
113pub struct SecretFileNamesBuilder {
114    endpoint: Option<String>,
115    region: Option<String>,
116    bucket: Option<String>,
117    public_url: Option<String>,
118    key_prefix: Option<String>,
119    access_key_id: Option<String>,
120    secret_access_key: Option<String>,
121}
122
123impl Default for SecretFileNamesBuilder {
124    fn default() -> Self {
125        let defaults = SecretFileNames::default();
126        Self {
127            endpoint: Some(defaults.endpoint),
128            region: defaults.region,
129            bucket: Some(defaults.bucket),
130            public_url: Some(defaults.public_url),
131            key_prefix: defaults.key_prefix,
132            access_key_id: Some(defaults.access_key_id),
133            secret_access_key: Some(defaults.secret_access_key),
134        }
135    }
136}
137
138impl SecretFileNamesBuilder {
139    /// Start with default filenames.
140    pub fn new() -> Self {
141        Self::default()
142    }
143
144    /// Start with no filenames set.
145    pub fn empty() -> Self {
146        Self {
147            endpoint: None,
148            region: None,
149            bucket: None,
150            public_url: None,
151            key_prefix: None,
152            access_key_id: None,
153            secret_access_key: None,
154        }
155    }
156
157    /// Start with filenames using a common prefix.
158    pub fn from_prefix(prefix: &str) -> Self {
159        let names = SecretFileNames::with_prefix(prefix);
160        Self {
161            endpoint: Some(names.endpoint),
162            region: names.region,
163            bucket: Some(names.bucket),
164            public_url: Some(names.public_url),
165            key_prefix: names.key_prefix,
166            access_key_id: Some(names.access_key_id),
167            secret_access_key: Some(names.secret_access_key),
168        }
169    }
170
171    /// Start with filenames using a common suffix.
172    pub fn from_suffix(suffix: &str) -> Self {
173        let names = SecretFileNames::with_suffix(suffix);
174        Self {
175            endpoint: Some(names.endpoint),
176            region: names.region,
177            bucket: Some(names.bucket),
178            public_url: Some(names.public_url),
179            key_prefix: names.key_prefix,
180            access_key_id: Some(names.access_key_id),
181            secret_access_key: Some(names.secret_access_key),
182        }
183    }
184
185    pub fn endpoint(mut self, value: impl Into<String>) -> Self {
186        self.endpoint = Some(value.into());
187        self
188    }
189
190    pub fn region(mut self, value: Option<impl Into<String>>) -> Self {
191        self.region = value.map(Into::into);
192        self
193    }
194
195    pub fn bucket(mut self, value: impl Into<String>) -> Self {
196        self.bucket = Some(value.into());
197        self
198    }
199
200    pub fn public_url(mut self, value: impl Into<String>) -> Self {
201        self.public_url = Some(value.into());
202        self
203    }
204
205    pub fn key_prefix(mut self, value: Option<impl Into<String>>) -> Self {
206        self.key_prefix = value.map(Into::into);
207        self
208    }
209
210    pub fn access_key_id(mut self, value: impl Into<String>) -> Self {
211        self.access_key_id = Some(value.into());
212        self
213    }
214
215    pub fn secret_access_key(mut self, value: impl Into<String>) -> Self {
216        self.secret_access_key = Some(value.into());
217        self
218    }
219
220    pub fn with_prefix(mut self, prefix: &str) -> Self {
221        let names = SecretFileNames::with_prefix(prefix);
222        self.endpoint = Some(names.endpoint);
223        self.region = names.region;
224        self.bucket = Some(names.bucket);
225        self.public_url = Some(names.public_url);
226        self.key_prefix = names.key_prefix;
227        self.access_key_id = Some(names.access_key_id);
228        self.secret_access_key = Some(names.secret_access_key);
229        self
230    }
231
232    pub fn with_suffix(mut self, suffix: &str) -> Self {
233        let names = SecretFileNames::with_suffix(suffix);
234        self.endpoint = Some(names.endpoint);
235        self.region = names.region;
236        self.bucket = Some(names.bucket);
237        self.public_url = Some(names.public_url);
238        self.key_prefix = names.key_prefix;
239        self.access_key_id = Some(names.access_key_id);
240        self.secret_access_key = Some(names.secret_access_key);
241        self
242    }
243
244    /// Merge in filenames, only filling fields that are currently `None`.
245    pub fn merge_defaults(mut self, names: SecretFileNames) -> Self {
246        if self.endpoint.is_none() {
247            self.endpoint = Some(names.endpoint);
248        }
249
250        if self.region.is_none() {
251            self.region = names.region;
252        }
253
254        if self.bucket.is_none() {
255            self.bucket = Some(names.bucket);
256        }
257
258        if self.public_url.is_none() {
259            self.public_url = Some(names.public_url);
260        }
261
262        if self.key_prefix.is_none() {
263            self.key_prefix = names.key_prefix;
264        }
265
266        if self.access_key_id.is_none() {
267            self.access_key_id = Some(names.access_key_id);
268        }
269
270        if self.secret_access_key.is_none() {
271            self.secret_access_key = Some(names.secret_access_key);
272        }
273
274        self
275    }
276
277    pub fn build(self) -> Result<SecretFileNames> {
278        Ok(SecretFileNames {
279            endpoint: self.endpoint.ok_or_else(|| Error::Config {
280                message: "endpoint filename is required".into(),
281            })?,
282            region: self.region,
283            bucket: self.bucket.ok_or_else(|| Error::Config {
284                message: "bucket filename is required".into(),
285            })?,
286            public_url: self.public_url.ok_or_else(|| Error::Config {
287                message: "public_url filename is required".into(),
288            })?,
289            key_prefix: self.key_prefix,
290            access_key_id: self.access_key_id.ok_or_else(|| Error::Config {
291                message: "access_key_id filename is required".into(),
292            })?,
293            secret_access_key: self.secret_access_key.ok_or_else(|| Error::Config {
294                message: "secret_access_key filename is required".into(),
295            })?,
296        })
297    }
298}
299
300impl UploaderConfig {
301    /// Create a new configuration builder.
302    pub fn builder() -> UploaderConfigBuilder {
303        UploaderConfigBuilder::default()
304    }
305
306    /// Validate the configuration.
307    pub(crate) fn validate(&self) -> Result<()> {
308        if self.endpoint.is_empty() {
309            return Err(Error::Config {
310                message: "endpoint is required".into(),
311            });
312        }
313
314        Url::parse(&self.endpoint).map_err(|e| Error::Config {
315            message: format!("invalid endpoint URL: {}", e),
316        })?;
317
318        if self.bucket.is_empty() {
319            return Err(Error::Config {
320                message: "bucket is required".into(),
321            });
322        }
323
324        if self.public_base_url.is_empty() {
325            return Err(Error::Config {
326                message: "public_base_url is required".into(),
327            });
328        }
329
330        Url::parse(&self.public_base_url).map_err(|e| Error::Config {
331            message: format!("invalid public_base_url: {}", e),
332        })?;
333
334        if self.access_key_id.is_empty() {
335            return Err(Error::Config {
336                message: "access_key_id is required".into(),
337            });
338        }
339
340        if self.secret_access_key.is_empty() {
341            return Err(Error::Config {
342                message: "secret_access_key is required".into(),
343            });
344        }
345
346        if self.max_buffered_bytes == 0 {
347            return Err(Error::Config {
348                message: "max_buffered_bytes must be greater than zero".into(),
349            });
350        }
351
352        if self.max_buffered_bytes > self.max_file_size {
353            return Err(Error::Config {
354                message: "max_buffered_bytes must not exceed max_file_size".into(),
355            });
356        }
357
358        Ok(())
359    }
360
361    /// Create a configuration from environment variables.
362    ///
363    /// Reads the following environment variables:
364    /// - `GARAGE_ENDPOINT` or `S3_ENDPOINT`
365    /// - `GARAGE_REGION` or `S3_REGION` (optional, defaults to "garage")
366    /// - `GARAGE_BUCKET` or `S3_BUCKET`
367    /// - `GARAGE_PUBLIC_URL` or `S3_PUBLIC_URL`
368    /// - `GARAGE_KEY_PREFIX` or `S3_KEY_PREFIX` (optional)
369    /// - `AWS_ACCESS_KEY_ID`
370    /// - `AWS_SECRET_ACCESS_KEY`
371    pub fn from_env() -> Result<Self> {
372        fn get_env(primary: &str, fallback: &str) -> Option<String> {
373            std::env::var(primary)
374                .ok()
375                .or_else(|| std::env::var(fallback).ok())
376        }
377
378        fn require_env(primary: &str, fallback: &str) -> Result<String> {
379            get_env(primary, fallback).ok_or_else(|| Error::Config {
380                message: format!("missing environment variable {} or {}", primary, fallback),
381            })
382        }
383
384        let config = UploaderConfig {
385            endpoint: require_env("GARAGE_ENDPOINT", "S3_ENDPOINT")?,
386            region: get_env("GARAGE_REGION", "S3_REGION").unwrap_or_else(|| "garage".to_string()),
387            bucket: require_env("GARAGE_BUCKET", "S3_BUCKET")?,
388            public_base_url: require_env("GARAGE_PUBLIC_URL", "S3_PUBLIC_URL")?,
389            key_prefix: get_env("GARAGE_KEY_PREFIX", "S3_KEY_PREFIX"),
390            access_key_id: std::env::var("AWS_ACCESS_KEY_ID").map_err(|_| Error::Config {
391                message: "missing environment variable AWS_ACCESS_KEY_ID".into(),
392            })?,
393            secret_access_key: std::env::var("AWS_SECRET_ACCESS_KEY").map_err(|_| {
394                Error::Config {
395                    message: "missing environment variable AWS_SECRET_ACCESS_KEY".into(),
396                }
397            })?,
398            download_timeout: Duration::from_secs(DEFAULT_DOWNLOAD_TIMEOUT_SECS),
399            max_file_size: DEFAULT_MAX_FILE_SIZE,
400            max_buffered_bytes: DEFAULT_MAX_BUFFERED_BYTES,
401        };
402
403        config.validate()?;
404        Ok(config)
405    }
406
407    /// Create a configuration from a directory of secret files.
408    ///
409    /// Expected filenames:
410    /// - `endpoint`
411    /// - `region` (optional, defaults to "garage")
412    /// - `bucket`
413    /// - `public_url`
414    /// - `key_prefix` (optional)
415    /// - `access_key_id`
416    /// - `secret_access_key`
417    pub fn from_secret_dir(path: impl AsRef<Path>) -> Result<Self> {
418        Self::from_secret_dir_with_names(path, &SecretFileNames::default())
419    }
420
421    /// Create a configuration from a directory of secret files with custom filenames.
422    pub fn from_secret_dir_with_names(
423        path: impl AsRef<Path>,
424        names: &SecretFileNames,
425    ) -> Result<Self> {
426        let path = path.as_ref();
427
428        fn read_required(path: &Path, name: &str) -> Result<String> {
429            let value = std::fs::read_to_string(path.join(name)).map_err(|e| Error::Config {
430                message: format!("failed to read secret file {}: {}", name, e),
431            })?;
432            let trimmed = value.trim();
433            if trimmed.is_empty() {
434                return Err(Error::Config {
435                    message: format!("secret file {} is empty", name),
436                });
437            }
438            Ok(trimmed.to_string())
439        }
440
441        fn read_optional(path: &Path, name: &str) -> Option<String> {
442            std::fs::read_to_string(path.join(name))
443                .ok()
444                .map(|value| value.trim().to_string())
445                .filter(|value| !value.is_empty())
446        }
447
448        let config = UploaderConfig {
449            endpoint: read_required(path, &names.endpoint)?,
450            region: names
451                .region
452                .as_deref()
453                .and_then(|name| read_optional(path, name))
454                .unwrap_or_else(|| "garage".to_string()),
455            bucket: read_required(path, &names.bucket)?,
456            public_base_url: read_required(path, &names.public_url)?,
457            key_prefix: names
458                .key_prefix
459                .as_deref()
460                .and_then(|name| read_optional(path, name)),
461            access_key_id: read_required(path, &names.access_key_id)?,
462            secret_access_key: read_required(path, &names.secret_access_key)?,
463            download_timeout: Duration::from_secs(DEFAULT_DOWNLOAD_TIMEOUT_SECS),
464            max_file_size: DEFAULT_MAX_FILE_SIZE,
465            max_buffered_bytes: DEFAULT_MAX_BUFFERED_BYTES,
466        };
467
468        config.validate()?;
469        Ok(config)
470    }
471
472    /// Create a configuration from environment variables, falling back to a secret directory.
473    pub fn from_env_or_secret_dir(path: impl AsRef<Path>) -> Result<Self> {
474        match Self::from_env() {
475            Ok(config) => Ok(config),
476            Err(_) => Self::from_secret_dir(path),
477        }
478    }
479}
480
481/// Builder for [`UploaderConfig`].
482#[derive(Default)]
483pub struct UploaderConfigBuilder {
484    endpoint: Option<String>,
485    region: Option<String>,
486    bucket: Option<String>,
487    public_base_url: Option<String>,
488    key_prefix: Option<String>,
489    access_key_id: Option<String>,
490    secret_access_key: Option<String>,
491    download_timeout: Option<Duration>,
492    max_file_size: Option<u64>,
493    max_buffered_bytes: Option<u64>,
494}
495
496impl UploaderConfigBuilder {
497    /// Set the S3-compatible endpoint URL.
498    pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
499        self.endpoint = Some(endpoint.into());
500        self
501    }
502
503    /// Set the S3 region. Defaults to "garage".
504    pub fn region(mut self, region: impl Into<String>) -> Self {
505        self.region = Some(region.into());
506        self
507    }
508
509    /// Set the target bucket name.
510    pub fn bucket(mut self, bucket: impl Into<String>) -> Self {
511        self.bucket = Some(bucket.into());
512        self
513    }
514
515    /// Set the public CDN base URL for constructing public URLs.
516    pub fn public_base_url(mut self, url: impl Into<String>) -> Self {
517        self.public_base_url = Some(url.into());
518        self
519    }
520
521    /// Set an optional key prefix for all uploaded files.
522    pub fn key_prefix(mut self, prefix: impl Into<String>) -> Self {
523        self.key_prefix = Some(prefix.into());
524        self
525    }
526
527    /// Set the AWS/Garage credentials.
528    pub fn credentials(
529        mut self,
530        access_key_id: impl Into<String>,
531        secret_access_key: impl Into<String>,
532    ) -> Self {
533        self.access_key_id = Some(access_key_id.into());
534        self.secret_access_key = Some(secret_access_key.into());
535        self
536    }
537
538    /// Set the download timeout. Defaults to 30 seconds.
539    pub fn download_timeout(mut self, timeout: Duration) -> Self {
540        self.download_timeout = Some(timeout);
541        self
542    }
543
544    /// Set the maximum file size in bytes. Defaults to 100MB.
545    pub fn max_file_size(mut self, size: u64) -> Self {
546        self.max_file_size = Some(size);
547        self
548    }
549
550    /// Set the maximum size in bytes to buffer in memory when downloading.
551    pub fn max_buffered_bytes(mut self, size: u64) -> Self {
552        self.max_buffered_bytes = Some(size);
553        self
554    }
555
556    /// Build the configuration.
557    pub fn build(self) -> Result<UploaderConfig> {
558        let config = UploaderConfig {
559            endpoint: self.endpoint.ok_or_else(|| Error::Config {
560                message: "endpoint is required".into(),
561            })?,
562            region: self.region.unwrap_or_else(|| "garage".to_string()),
563            bucket: self.bucket.ok_or_else(|| Error::Config {
564                message: "bucket is required".into(),
565            })?,
566            public_base_url: self.public_base_url.ok_or_else(|| Error::Config {
567                message: "public_base_url is required".into(),
568            })?,
569            key_prefix: self.key_prefix,
570            access_key_id: self.access_key_id.ok_or_else(|| Error::Config {
571                message: "credentials are required".into(),
572            })?,
573            secret_access_key: self.secret_access_key.ok_or_else(|| Error::Config {
574                message: "credentials are required".into(),
575            })?,
576            download_timeout: self
577                .download_timeout
578                .unwrap_or(Duration::from_secs(DEFAULT_DOWNLOAD_TIMEOUT_SECS)),
579            max_file_size: self.max_file_size.unwrap_or(DEFAULT_MAX_FILE_SIZE),
580            max_buffered_bytes: self
581                .max_buffered_bytes
582                .unwrap_or(DEFAULT_MAX_BUFFERED_BYTES),
583        };
584
585        config.validate()?;
586        Ok(config)
587    }
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    #[test]
595    fn test_config_builder_validates_required_fields() {
596        let result = UploaderConfigBuilder::default().build();
597        assert!(result.is_err());
598
599        let result = UploaderConfigBuilder::default()
600            .endpoint("https://s3.example.com")
601            .build();
602        assert!(result.is_err());
603    }
604
605    #[test]
606    fn test_config_builder_creates_valid_config() {
607        let config = UploaderConfigBuilder::default()
608            .endpoint("https://s3.example.com")
609            .bucket("test-bucket")
610            .public_base_url("https://cdn.example.com")
611            .credentials("access_key", "secret_key")
612            .key_prefix("uploads")
613            .build()
614            .expect("expected valid config");
615
616        assert_eq!(config.endpoint, "https://s3.example.com");
617        assert_eq!(config.bucket, "test-bucket");
618        assert_eq!(config.region, "garage");
619        assert_eq!(config.key_prefix, Some("uploads".to_string()));
620    }
621
622    #[test]
623    fn test_config_validates_urls() {
624        let result = UploaderConfigBuilder::default()
625            .endpoint("not-a-url")
626            .bucket("test")
627            .public_base_url("https://cdn.example.com")
628            .credentials("key", "secret")
629            .build();
630
631        assert!(result.is_err());
632    }
633
634    #[test]
635    fn test_config_from_secret_dir_reads_required_fields() {
636        let dir = std::env::temp_dir().join(format!("garage-sdk-test-{}", uuid::Uuid::new_v4()));
637        std::fs::create_dir_all(&dir).expect("expected test dir to be created");
638
639        std::fs::write(dir.join("endpoint"), "https://s3.example.com")
640            .expect("expected endpoint file to be written");
641        std::fs::write(dir.join("bucket"), "test-bucket").expect("expected bucket file");
642        std::fs::write(dir.join("public_url"), "https://cdn.example.com")
643            .expect("expected public url file");
644        std::fs::write(dir.join("access_key_id"), "access_key").expect("expected access key file");
645        std::fs::write(dir.join("secret_access_key"), "secret_key")
646            .expect("expected secret key file");
647
648        let config = UploaderConfig::from_secret_dir(&dir).expect("expected config to load");
649        assert_eq!(config.endpoint, "https://s3.example.com");
650        assert_eq!(config.bucket, "test-bucket");
651        assert_eq!(config.public_base_url, "https://cdn.example.com");
652
653        std::fs::remove_dir_all(&dir).expect("expected test dir cleanup");
654    }
655
656    #[test]
657    fn test_config_from_secret_dir_with_names() {
658        let dir = std::env::temp_dir().join(format!("garage-sdk-test-{}", uuid::Uuid::new_v4()));
659        std::fs::create_dir_all(&dir).expect("expected test dir to be created");
660
661        std::fs::write(dir.join("s3_endpoint"), "https://s3.example.com")
662            .expect("expected endpoint file to be written");
663        std::fs::write(dir.join("s3_bucket"), "test-bucket").expect("expected bucket file");
664        std::fs::write(dir.join("s3_public_url"), "https://cdn.example.com")
665            .expect("expected public url file");
666        std::fs::write(dir.join("s3_access_key_id"), "access_key")
667            .expect("expected access key file");
668        std::fs::write(dir.join("s3_secret_access_key"), "secret_key")
669            .expect("expected secret key file");
670
671        let names = SecretFileNames {
672            endpoint: "s3_endpoint".into(),
673            region: None,
674            bucket: "s3_bucket".into(),
675            public_url: "s3_public_url".into(),
676            key_prefix: None,
677            access_key_id: "s3_access_key_id".into(),
678            secret_access_key: "s3_secret_access_key".into(),
679        };
680
681        let config =
682            UploaderConfig::from_secret_dir_with_names(&dir, &names).expect("expected config");
683        assert_eq!(config.endpoint, "https://s3.example.com");
684        assert_eq!(config.bucket, "test-bucket");
685        assert_eq!(config.public_base_url, "https://cdn.example.com");
686
687        std::fs::remove_dir_all(&dir).expect("expected test dir cleanup");
688    }
689
690    #[test]
691    fn test_secret_file_names_with_prefix() {
692        let names = SecretFileNames::with_prefix("s3_");
693        assert_eq!(names.endpoint, "s3_endpoint");
694        assert_eq!(names.bucket, "s3_bucket");
695        assert_eq!(names.public_url, "s3_public_url");
696        assert_eq!(names.access_key_id, "s3_access_key_id");
697        assert_eq!(names.secret_access_key, "s3_secret_access_key");
698        assert_eq!(names.region, Some("s3_region".into()));
699        assert_eq!(names.key_prefix, Some("s3_key_prefix".into()));
700    }
701
702    #[test]
703    fn test_secret_file_names_with_suffix() {
704        let names = SecretFileNames::with_suffix("_secret");
705        assert_eq!(names.endpoint, "endpoint_secret");
706        assert_eq!(names.bucket, "bucket_secret");
707        assert_eq!(names.public_url, "public_url_secret");
708        assert_eq!(names.access_key_id, "access_key_id_secret");
709        assert_eq!(names.secret_access_key, "secret_access_key_secret");
710        assert_eq!(names.region, Some("region_secret".into()));
711        assert_eq!(names.key_prefix, Some("key_prefix_secret".into()));
712    }
713
714    #[test]
715    fn test_secret_file_names_builder_customizes() {
716        let names = SecretFileNamesBuilder::new()
717            .endpoint("s3_endpoint")
718            .bucket("s3_bucket")
719            .public_url("s3_public_url")
720            .access_key_id("s3_access_key_id")
721            .secret_access_key("s3_secret_access_key")
722            .region(None::<String>)
723            .key_prefix(None::<String>)
724            .build()
725            .expect("expected builder to succeed");
726
727        assert_eq!(names.endpoint, "s3_endpoint");
728        assert_eq!(names.bucket, "s3_bucket");
729        assert_eq!(names.public_url, "s3_public_url");
730        assert_eq!(names.access_key_id, "s3_access_key_id");
731        assert_eq!(names.secret_access_key, "s3_secret_access_key");
732        assert_eq!(names.region, None);
733        assert_eq!(names.key_prefix, None);
734    }
735
736    #[test]
737    fn test_secret_file_names_builder_from_prefix() {
738        let names = SecretFileNamesBuilder::from_prefix("s3_")
739            .region(None::<String>)
740            .key_prefix(None::<String>)
741            .build()
742            .expect("expected builder to succeed");
743
744        assert_eq!(names.endpoint, "s3_endpoint");
745        assert_eq!(names.bucket, "s3_bucket");
746        assert_eq!(names.public_url, "s3_public_url");
747        assert_eq!(names.access_key_id, "s3_access_key_id");
748        assert_eq!(names.secret_access_key, "s3_secret_access_key");
749        assert_eq!(names.region, None);
750        assert_eq!(names.key_prefix, None);
751    }
752
753    #[test]
754    fn test_secret_file_names_builder_with_suffix() {
755        let names = SecretFileNamesBuilder::new()
756            .with_suffix("_secret")
757            .region(None::<String>)
758            .key_prefix(None::<String>)
759            .build()
760            .expect("expected builder to succeed");
761
762        assert_eq!(names.endpoint, "endpoint_secret");
763        assert_eq!(names.bucket, "bucket_secret");
764        assert_eq!(names.public_url, "public_url_secret");
765        assert_eq!(names.access_key_id, "access_key_id_secret");
766        assert_eq!(names.secret_access_key, "secret_access_key_secret");
767        assert_eq!(names.region, None);
768        assert_eq!(names.key_prefix, None);
769    }
770
771    #[test]
772    fn test_secret_file_names_builder_merge_defaults() {
773        let names = SecretFileNamesBuilder::empty()
774            .endpoint("custom_endpoint")
775            .merge_defaults(SecretFileNames::with_prefix("s3_"))
776            .build()
777            .expect("expected builder to succeed");
778
779        assert_eq!(names.endpoint, "custom_endpoint");
780        assert_eq!(names.bucket, "s3_bucket");
781        assert_eq!(names.public_url, "s3_public_url");
782        assert_eq!(names.access_key_id, "s3_access_key_id");
783        assert_eq!(names.secret_access_key, "s3_secret_access_key");
784    }
785}