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#[derive(Clone, Debug)]
14pub struct UploaderConfig {
15 pub endpoint: String,
17
18 pub region: String,
20
21 pub bucket: String,
23
24 pub public_base_url: String,
26
27 pub key_prefix: Option<String>,
29
30 pub access_key_id: String,
32
33 pub secret_access_key: String,
35
36 pub download_timeout: Duration,
38
39 pub max_file_size: u64,
41
42 pub max_buffered_bytes: u64,
47}
48
49#[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 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 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#[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 pub fn new() -> Self {
141 Self::default()
142 }
143
144 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 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 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 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 pub fn builder() -> UploaderConfigBuilder {
303 UploaderConfigBuilder::default()
304 }
305
306 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 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 pub fn from_secret_dir(path: impl AsRef<Path>) -> Result<Self> {
418 Self::from_secret_dir_with_names(path, &SecretFileNames::default())
419 }
420
421 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 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#[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 pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
499 self.endpoint = Some(endpoint.into());
500 self
501 }
502
503 pub fn region(mut self, region: impl Into<String>) -> Self {
505 self.region = Some(region.into());
506 self
507 }
508
509 pub fn bucket(mut self, bucket: impl Into<String>) -> Self {
511 self.bucket = Some(bucket.into());
512 self
513 }
514
515 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 pub fn key_prefix(mut self, prefix: impl Into<String>) -> Self {
523 self.key_prefix = Some(prefix.into());
524 self
525 }
526
527 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 pub fn download_timeout(mut self, timeout: Duration) -> Self {
540 self.download_timeout = Some(timeout);
541 self
542 }
543
544 pub fn max_file_size(mut self, size: u64) -> Self {
546 self.max_file_size = Some(size);
547 self
548 }
549
550 pub fn max_buffered_bytes(mut self, size: u64) -> Self {
552 self.max_buffered_bytes = Some(size);
553 self
554 }
555
556 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}