1use crate::abs::AzureAbsStoreConfig;
10use crate::gcs::GcpGcsStoreConfig;
11use crate::s3_compatible::{AwsS3StoreConfig, CloudflareR2StoreConfig};
12use crate::secret::SecretString;
13use crate::{ConfiguredObjectStore, ConfiguredObjectStoreKind};
14use http::Uri;
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17
18pub const ACCESS_KEY_ID_ENV: &str = "AWS_ACCESS_KEY_ID";
21pub const SECRET_ACCESS_KEY_ENV: &str = "AWS_SECRET_ACCESS_KEY";
24pub const SESSION_TOKEN_ENV: &str = "AWS_SESSION_TOKEN";
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
45pub enum StoreConfig {
46 LocalFs {
48 root: String,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 key_prefix: Option<String>,
53 },
54 AwsS3 {
56 bucket: String,
58 region: String,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 endpoint_url: Option<String>,
63 #[serde(default)]
67 access_key_id: SecretString,
68 #[serde(default)]
72 secret_access_key: SecretString,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 session_token: Option<SecretString>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 key_prefix: Option<String>,
79 #[serde(default)]
81 force_path_style: bool,
82 },
83 CloudflareR2 {
85 bucket: String,
87 account_id: String,
89 endpoint_url: String,
91 #[serde(default)]
95 access_key_id: SecretString,
96 #[serde(default)]
99 secret_access_key: SecretString,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 key_prefix: Option<String>,
103 },
104 GcpGcs {
106 bucket: String,
108 service_account_key_path: String,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 key_prefix: Option<String>,
113 },
114 AzureAbs {
116 account_name: String,
118 container_name: String,
120 access_key: SecretString,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 endpoint_url: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 key_prefix: Option<String>,
128 },
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Error)]
136pub enum StoreConfigError {
137 #[error("missing `{field}`")]
139 MissingField {
140 field: &'static str,
142 },
143 #[error("missing `{field}`; set it in the config or export `{env}`")]
146 MissingCredential {
147 field: &'static str,
149 env: &'static str,
151 },
152 #[error("invalid `{field}`: {reason}")]
154 InvalidField {
155 field: &'static str,
157 reason: String,
159 },
160}
161
162impl StoreConfigError {
163 pub fn field(&self) -> &'static str {
165 match self {
166 StoreConfigError::MissingField { field }
167 | StoreConfigError::MissingCredential { field, .. }
168 | StoreConfigError::InvalidField { field, .. } => field,
169 }
170 }
171}
172
173impl StoreConfig {
174 pub fn kind(&self) -> ConfiguredObjectStoreKind {
176 match self {
177 StoreConfig::LocalFs { .. } => ConfiguredObjectStoreKind::LocalFs,
178 StoreConfig::AwsS3 { .. } => ConfiguredObjectStoreKind::AwsS3,
179 StoreConfig::CloudflareR2 { .. } => ConfiguredObjectStoreKind::CloudflareR2,
180 StoreConfig::GcpGcs { .. } => ConfiguredObjectStoreKind::GcpGcs,
181 StoreConfig::AzureAbs { .. } => ConfiguredObjectStoreKind::AzureAbs,
182 }
183 }
184
185 pub fn direct_put_is_proven(&self) -> bool {
200 match self {
201 StoreConfig::AwsS3 { endpoint_url, .. } => match endpoint_url {
202 None => true,
203 Some(endpoint_url) => endpoint_in_domain_families(
204 endpoint_url,
205 &["amazonaws.com", "amazonaws.com.cn"],
206 ),
207 },
208 StoreConfig::CloudflareR2 { endpoint_url, .. } => {
209 endpoint_in_domain_families(endpoint_url, &["r2.cloudflarestorage.com"])
210 }
211 StoreConfig::LocalFs { .. }
212 | StoreConfig::GcpGcs { .. }
213 | StoreConfig::AzureAbs { .. } => false,
214 }
215 }
216
217 pub fn configured_object_store(&self) -> crate::object_store::Result<ConfiguredObjectStore> {
219 match self {
220 StoreConfig::LocalFs { root, key_prefix } => {
221 ConfiguredObjectStore::local_fs(root, key_prefix.as_deref())
222 }
223 StoreConfig::AwsS3 {
224 bucket,
225 region,
226 endpoint_url,
227 access_key_id,
228 secret_access_key,
229 session_token,
230 key_prefix,
231 force_path_style,
232 } => ConfiguredObjectStore::aws_s3(AwsS3StoreConfig {
233 bucket: bucket.clone(),
234 region: region.clone(),
235 endpoint_url: endpoint_url.clone(),
236 access_key_id: access_key_id.clone(),
237 secret_access_key: secret_access_key.clone(),
238 session_token: session_token.clone(),
239 key_prefix: key_prefix.clone(),
240 force_path_style: *force_path_style,
241 }),
242 StoreConfig::CloudflareR2 {
243 bucket,
244 account_id,
245 endpoint_url,
246 access_key_id,
247 secret_access_key,
248 key_prefix,
249 } => ConfiguredObjectStore::cloudflare_r2(CloudflareR2StoreConfig {
250 bucket: bucket.clone(),
251 account_id: account_id.clone(),
252 endpoint_url: endpoint_url.clone(),
253 access_key_id: access_key_id.clone(),
254 secret_access_key: secret_access_key.clone(),
255 key_prefix: key_prefix.clone(),
256 }),
257 StoreConfig::GcpGcs {
258 bucket,
259 service_account_key_path,
260 key_prefix,
261 } => ConfiguredObjectStore::gcp_gcs(GcpGcsStoreConfig {
262 bucket: bucket.clone(),
263 service_account_key_path: service_account_key_path.clone(),
264 key_prefix: key_prefix.clone(),
265 }),
266 StoreConfig::AzureAbs {
267 account_name,
268 container_name,
269 access_key,
270 endpoint_url,
271 key_prefix,
272 } => ConfiguredObjectStore::azure_abs(AzureAbsStoreConfig {
273 account_name: account_name.clone(),
274 container_name: container_name.clone(),
275 access_key: access_key.clone(),
276 endpoint_url: endpoint_url.clone(),
277 key_prefix: key_prefix.clone(),
278 }),
279 }
280 }
281
282 pub fn apply_env_credentials(&mut self) {
295 self.apply_env_credentials_from(|name| std::env::var(name).ok());
296 }
297
298 fn apply_env_credentials_from(&mut self, lookup: impl Fn(&str) -> Option<String>) {
299 match self {
300 StoreConfig::AwsS3 {
301 access_key_id,
302 secret_access_key,
303 session_token,
304 ..
305 } => {
306 fill_secret(access_key_id, &lookup, ACCESS_KEY_ID_ENV);
307 fill_secret(secret_access_key, &lookup, SECRET_ACCESS_KEY_ENV);
308 if session_token.is_none() {
309 *session_token = non_blank(lookup(SESSION_TOKEN_ENV)).map(SecretString::new);
310 }
311 }
312 StoreConfig::CloudflareR2 {
313 access_key_id,
314 secret_access_key,
315 ..
316 } => {
317 fill_secret(access_key_id, &lookup, ACCESS_KEY_ID_ENV);
318 fill_secret(secret_access_key, &lookup, SECRET_ACCESS_KEY_ENV);
319 }
320 StoreConfig::LocalFs { .. }
321 | StoreConfig::GcpGcs { .. }
322 | StoreConfig::AzureAbs { .. } => {}
323 }
324 }
325
326 pub fn validate(&self) -> Result<(), StoreConfigError> {
329 match self {
330 StoreConfig::LocalFs { root, .. } => {
331 require_non_empty("store.root", root)?;
332 }
333 StoreConfig::AwsS3 {
334 bucket,
335 region,
336 endpoint_url,
337 access_key_id,
338 secret_access_key,
339 ..
340 } => {
341 require_non_empty("store.bucket", bucket)?;
342 require_non_empty("store.region", region)?;
343 require_credential(
344 "store.access_key_id",
345 ACCESS_KEY_ID_ENV,
346 access_key_id.expose(),
347 )?;
348 require_credential(
349 "store.secret_access_key",
350 SECRET_ACCESS_KEY_ENV,
351 secret_access_key.expose(),
352 )?;
353 if let Some(url) = endpoint_url {
354 validate_absolute_http_url("store.endpoint_url", url)?;
355 }
356 }
357 StoreConfig::CloudflareR2 {
358 bucket,
359 account_id,
360 endpoint_url,
361 access_key_id,
362 secret_access_key,
363 ..
364 } => {
365 require_non_empty("store.bucket", bucket)?;
366 require_non_empty("store.account_id", account_id)?;
367 require_credential(
368 "store.access_key_id",
369 ACCESS_KEY_ID_ENV,
370 access_key_id.expose(),
371 )?;
372 require_credential(
373 "store.secret_access_key",
374 SECRET_ACCESS_KEY_ENV,
375 secret_access_key.expose(),
376 )?;
377 validate_absolute_http_url("store.endpoint_url", endpoint_url)?;
378 }
379 StoreConfig::GcpGcs {
380 bucket,
381 service_account_key_path,
382 ..
383 } => {
384 require_non_empty("store.bucket", bucket)?;
385 require_non_empty("store.service_account_key_path", service_account_key_path)?;
386 }
387 StoreConfig::AzureAbs {
388 account_name,
389 container_name,
390 access_key,
391 endpoint_url,
392 ..
393 } => {
394 require_non_empty("store.account_name", account_name)?;
395 require_non_empty("store.container_name", container_name)?;
396 require_non_empty("store.access_key", access_key.expose())?;
397 if let Some(url) = endpoint_url {
398 validate_absolute_http_url("store.endpoint_url", url)?;
399 }
400 }
401 }
402 Ok(())
403 }
404
405 pub fn redacted(&self) -> Self {
408 let mut redacted = self.clone();
409 match &mut redacted {
410 StoreConfig::LocalFs { .. } | StoreConfig::GcpGcs { .. } => {}
411 StoreConfig::AwsS3 {
412 access_key_id,
413 secret_access_key,
414 session_token,
415 ..
416 } => {
417 *access_key_id = access_key_id.masked();
418 *secret_access_key = secret_access_key.masked();
419 *session_token = session_token.as_ref().map(SecretString::masked);
420 }
421 StoreConfig::CloudflareR2 {
422 access_key_id,
423 secret_access_key,
424 ..
425 } => {
426 *access_key_id = access_key_id.masked();
427 *secret_access_key = secret_access_key.masked();
428 }
429 StoreConfig::AzureAbs { access_key, .. } => {
430 *access_key = access_key.masked();
431 }
432 }
433 redacted
434 }
435}
436
437fn require_non_empty(field: &'static str, value: &str) -> Result<(), StoreConfigError> {
438 if value.trim().is_empty() {
439 Err(StoreConfigError::MissingField { field })
440 } else {
441 Ok(())
442 }
443}
444
445fn require_credential(
448 field: &'static str,
449 env: &'static str,
450 value: &str,
451) -> Result<(), StoreConfigError> {
452 if value.trim().is_empty() {
453 Err(StoreConfigError::MissingCredential { field, env })
454 } else {
455 Ok(())
456 }
457}
458
459fn fill_secret(
461 secret: &mut SecretString,
462 lookup: &impl Fn(&str) -> Option<String>,
463 env: &'static str,
464) {
465 if !secret.expose().trim().is_empty() {
466 return;
467 }
468 if let Some(value) = non_blank(lookup(env)) {
469 *secret = SecretString::new(value);
470 }
471}
472
473fn non_blank(value: Option<String>) -> Option<String> {
474 value.filter(|value| !value.trim().is_empty())
475}
476
477fn validate_absolute_http_url(field: &'static str, value: &str) -> Result<(), StoreConfigError> {
478 let trimmed = value.trim();
479 if trimmed.is_empty() {
480 return Err(StoreConfigError::MissingField { field });
481 }
482
483 let uri: Uri =
484 trimmed.parse().map_err(
485 |err: http::uri::InvalidUri| StoreConfigError::InvalidField {
486 field,
487 reason: err.to_string(),
488 },
489 )?;
490
491 match uri.scheme_str() {
492 Some("http" | "https") => {}
493 Some(other) => {
494 return Err(StoreConfigError::InvalidField {
495 field,
496 reason: format!("scheme must be http or https, got `{other}`"),
497 });
498 }
499 None => {
500 return Err(StoreConfigError::InvalidField {
501 field,
502 reason: "must be an absolute http or https URL".to_owned(),
503 });
504 }
505 }
506
507 if uri.authority().is_none() {
508 return Err(StoreConfigError::InvalidField {
509 field,
510 reason: "must be an absolute http or https URL".to_owned(),
511 });
512 }
513
514 Ok(())
515}
516
517fn endpoint_in_domain_families(endpoint_url: &str, domain_families: &[&str]) -> bool {
518 let Ok(uri) = endpoint_url.parse::<Uri>() else {
519 return false;
520 };
521 let Some(host) = uri.host() else {
522 return false;
523 };
524 let host = host.trim_end_matches('.').to_ascii_lowercase();
525 domain_families.iter().any(|domain| {
526 host == *domain
527 || host
528 .strip_suffix(domain)
529 .is_some_and(|prefix| prefix.ends_with('.'))
530 })
531}
532
533#[cfg(test)]
534mod tests {
535 #![allow(clippy::panic)]
536 use super::{StoreConfig, StoreConfigError};
539 use crate::secret::SecretString;
540 use crate::ConfiguredObjectStoreKind;
541 use std::path::{Path, PathBuf};
542
543 fn parse(contents: &str) -> StoreConfig {
544 toml::from_str(contents).expect("parse store config")
545 }
546
547 #[test]
548 fn parses_all_provider_kinds_and_reports_their_kind() {
549 let cases: [(&str, ConfiguredObjectStoreKind); 5] = [
550 (
551 "kind = \"local-fs\"\nroot = \"/tmp/store\"",
552 ConfiguredObjectStoreKind::LocalFs,
553 ),
554 (
555 r#"
556kind = "aws-s3"
557bucket = "bucket"
558region = "us-east-1"
559access_key_id = "access"
560secret_access_key = "secret"
561"#,
562 ConfiguredObjectStoreKind::AwsS3,
563 ),
564 (
565 r#"
566kind = "cloudflare-r2"
567bucket = "bucket"
568account_id = "account"
569endpoint_url = "https://account.r2.cloudflarestorage.com"
570access_key_id = "access"
571secret_access_key = "secret"
572"#,
573 ConfiguredObjectStoreKind::CloudflareR2,
574 ),
575 (
576 r#"
577kind = "gcp-gcs"
578bucket = "bucket"
579service_account_key_path = "/tmp/service-account.json"
580"#,
581 ConfiguredObjectStoreKind::GcpGcs,
582 ),
583 (
584 r#"
585kind = "azure-abs"
586account_name = "account"
587container_name = "container"
588access_key = "key"
589"#,
590 ConfiguredObjectStoreKind::AzureAbs,
591 ),
592 ];
593
594 for (contents, kind) in cases {
595 let config = parse(contents);
596 assert_eq!(config.kind(), kind);
597 config.validate().expect("valid config");
598 }
599 }
600
601 #[test]
602 fn direct_put_is_proven_only_for_first_party_s3_and_r2_endpoints() {
603 let aws_default = parse(
604 r#"
605kind = "aws-s3"
606bucket = "bucket"
607region = "us-east-1"
608access_key_id = "access"
609secret_access_key = "secret"
610"#,
611 );
612 let aws_first_party = parse(
613 r#"
614kind = "aws-s3"
615bucket = "bucket"
616region = "cn-north-1"
617endpoint_url = "https://bucket.s3.cn-north-1.amazonaws.com.cn"
618access_key_id = "access"
619secret_access_key = "secret"
620"#,
621 );
622 let aws_custom = parse(
623 r#"
624kind = "aws-s3"
625bucket = "bucket"
626region = "us-east-1"
627endpoint_url = "https://gateway.example"
628access_key_id = "access"
629secret_access_key = "secret"
630"#,
631 );
632 let r2_first_party = parse(
633 r#"
634kind = "cloudflare-r2"
635bucket = "bucket"
636account_id = "account"
637endpoint_url = "https://account.r2.cloudflarestorage.com"
638access_key_id = "access"
639secret_access_key = "secret"
640"#,
641 );
642 let r2_custom = parse(
643 r#"
644kind = "cloudflare-r2"
645bucket = "bucket"
646account_id = "account"
647endpoint_url = "https://gateway.example"
648access_key_id = "access"
649secret_access_key = "secret"
650"#,
651 );
652 let gcs = parse(
653 r#"
654kind = "gcp-gcs"
655bucket = "bucket"
656service_account_key_path = "/tmp/service-account.json"
657"#,
658 );
659
660 assert!(aws_default.direct_put_is_proven());
661 assert!(aws_first_party.direct_put_is_proven());
662 assert!(!aws_custom.direct_put_is_proven());
663 assert!(r2_first_party.direct_put_is_proven());
664 assert!(!r2_custom.direct_put_is_proven());
665 assert!(!gcs.direct_put_is_proven());
667 }
668
669 #[test]
670 fn credentials_left_out_of_the_file_come_from_the_environment() {
671 let environment = |name: &str| match name {
672 "AWS_ACCESS_KEY_ID" => Some("env-access".to_owned()),
673 "AWS_SECRET_ACCESS_KEY" => Some("env-secret".to_owned()),
674 "AWS_SESSION_TOKEN" => Some("env-session".to_owned()),
675 _ => None,
676 };
677
678 let mut aws = parse(
680 r#"
681kind = "aws-s3"
682bucket = "bucket"
683region = "us-east-1"
684"#,
685 );
686 aws.apply_env_credentials_from(environment);
687 aws.validate().expect("the environment completed the store");
688 match &aws {
689 StoreConfig::AwsS3 {
690 access_key_id,
691 secret_access_key,
692 session_token,
693 ..
694 } => {
695 assert_eq!(access_key_id.expose(), "env-access");
696 assert_eq!(secret_access_key.expose(), "env-secret");
697 assert_eq!(
698 session_token.as_ref().map(SecretString::expose),
699 Some("env-session")
700 );
701 }
702 other => panic!("expected an aws-s3 store, got {other:?}"),
703 }
704
705 let mut r2 = parse(
707 r#"
708kind = "cloudflare-r2"
709bucket = "bucket"
710account_id = "account"
711endpoint_url = "https://account.r2.cloudflarestorage.com"
712"#,
713 );
714 r2.apply_env_credentials_from(environment);
715 r2.validate().expect("the environment completed the store");
716
717 let mut explicit = parse(
719 r#"
720kind = "aws-s3"
721bucket = "bucket"
722region = "us-east-1"
723access_key_id = "file-access"
724secret_access_key = "file-secret"
725"#,
726 );
727 explicit.apply_env_credentials_from(environment);
728 let blank = |_: &str| Some(" ".to_owned());
729 let mut unfilled = parse(
730 r#"
731kind = "aws-s3"
732bucket = "bucket"
733region = "us-east-1"
734"#,
735 );
736 unfilled.apply_env_credentials_from(blank);
737 match (&explicit, &unfilled) {
738 (
739 StoreConfig::AwsS3 {
740 access_key_id,
741 session_token,
742 ..
743 },
744 StoreConfig::AwsS3 {
745 access_key_id: unfilled_key,
746 session_token: unfilled_token,
747 ..
748 },
749 ) => {
750 assert_eq!(access_key_id.expose(), "file-access");
751 assert_eq!(
752 session_token.as_ref().map(SecretString::expose),
753 Some("env-session"),
754 "an absent optional field still takes the environment"
755 );
756 assert!(unfilled_key.expose().is_empty());
757 assert!(unfilled_token.is_none());
758 }
759 other => panic!("expected two aws-s3 stores, got {other:?}"),
760 }
761
762 let mut gcs = parse(
764 r#"
765kind = "gcp-gcs"
766bucket = "bucket"
767service_account_key_path = "/tmp/service-account.json"
768"#,
769 );
770 let before = gcs.clone();
771 gcs.apply_env_credentials_from(environment);
772 assert_eq!(gcs, before);
773 }
774
775 #[test]
776 fn a_credential_missing_everywhere_names_its_environment_variable() {
777 let store = parse(
778 r#"
779kind = "aws-s3"
780bucket = "bucket"
781region = "us-east-1"
782secret_access_key = "secret"
783"#,
784 );
785
786 let error = store.validate().expect_err("no access key anywhere");
787 assert_eq!(
788 error,
789 StoreConfigError::MissingCredential {
790 field: "store.access_key_id",
791 env: "AWS_ACCESS_KEY_ID",
792 }
793 );
794 assert_eq!(
795 error.to_string(),
796 "missing `store.access_key_id`; set it in the config or export `AWS_ACCESS_KEY_ID`"
797 );
798 }
799
800 #[test]
801 fn validate_reports_store_rooted_field_paths() {
802 let blank_bucket = parse(
803 r#"
804kind = "cloudflare-r2"
805bucket = " "
806account_id = "account"
807endpoint_url = "https://example.com"
808access_key_id = "access"
809secret_access_key = "secret"
810"#,
811 );
812 assert_eq!(
813 blank_bucket.validate(),
814 Err(StoreConfigError::MissingField {
815 field: "store.bucket"
816 })
817 );
818
819 let bad_scheme = parse(
820 r#"
821kind = "aws-s3"
822bucket = "bucket"
823region = "us-east-1"
824endpoint_url = "ftp://example.com"
825access_key_id = "access"
826secret_access_key = "secret"
827"#,
828 );
829 match bad_scheme.validate() {
830 Err(StoreConfigError::InvalidField { field, reason }) => {
831 assert_eq!(field, "store.endpoint_url");
832 assert!(reason.contains("ftp"));
833 }
834 other => panic!("expected invalid endpoint_url, got {other:?}"),
835 }
836
837 let blank_azure_account = parse(
838 r#"
839kind = "azure-abs"
840account_name = " "
841container_name = "container"
842access_key = "key"
843"#,
844 );
845 assert_eq!(
846 blank_azure_account.validate(),
847 Err(StoreConfigError::MissingField {
848 field: "store.account_name"
849 })
850 );
851 }
852
853 #[test]
854 fn unknown_keys_are_rejected_and_named() {
855 let error = toml::from_str::<StoreConfig>(
859 r#"
860kind = "aws-s3"
861bucket = "bucket"
862region = "us-east-1"
863access_key_id = "access"
864secret_access_key = "secret"
865buckt = "typo"
866"#,
867 )
868 .expect_err("typo'd key must be rejected");
869
870 let message = error.to_string();
871 assert!(
872 message.contains("buckt"),
873 "error must name the unknown key, got: {message}"
874 );
875 }
876
877 #[test]
878 fn debug_output_redacts_credentials() {
879 let config = parse(
880 r#"
881kind = "aws-s3"
882bucket = "bucket"
883region = "us-east-1"
884access_key_id = "debug-access-key-id"
885secret_access_key = "debug-secret-access-key"
886session_token = "debug-session-token"
887"#,
888 );
889
890 let rendered = format!("{config:?}");
891
892 assert!(!rendered.contains("debug-access-key-id"));
893 assert!(!rendered.contains("debug-secret-access-key"));
894 assert!(!rendered.contains("debug-session-token"));
895 assert!(rendered.contains("bucket"));
896 }
897
898 #[test]
899 fn redacted_copy_serializes_without_credentials() {
900 let config = parse(
901 r#"
902kind = "cloudflare-r2"
903bucket = "bucket"
904account_id = "account"
905endpoint_url = "https://account.r2.cloudflarestorage.com"
906access_key_id = "plain-access-key-id"
907secret_access_key = "plain-secret-access-key"
908"#,
909 );
910
911 let rendered = toml::to_string_pretty(&config.redacted()).expect("serialize redacted");
912
913 assert!(!rendered.contains("plain-access-key-id"));
914 assert!(!rendered.contains("plain-secret-access-key"));
915 assert!(rendered.contains("<redacted>"));
916 assert!(rendered.contains("account"));
917 }
918
919 #[test]
920 fn serialization_round_trips_the_store_table() {
921 let config = parse(
922 r#"
923kind = "aws-s3"
924bucket = "bucket"
925region = "us-east-1"
926access_key_id = "access"
927secret_access_key = "secret"
928key_prefix = "demo"
929"#,
930 );
931
932 let rendered = toml::to_string_pretty(&config).expect("serialize store config");
933 assert!(rendered.contains("kind = \"aws-s3\""));
934 assert!(!rendered.contains("session_token"));
935 assert!(!rendered.contains("endpoint_url"));
936
937 let reparsed: StoreConfig = toml::from_str(&rendered).expect("reparse store config");
938 assert_eq!(reparsed, config);
939 }
940
941 #[test]
945 fn example_configs_store_sections_parse() {
946 let configs_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../configs");
947 let mut store_sections = 0usize;
948
949 for path in example_config_paths(&configs_dir) {
950 let contents = std::fs::read_to_string(&path)
951 .unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
952 let value: toml::Value = toml::from_str(&contents)
953 .unwrap_or_else(|err| panic!("parse {}: {err}", path.display()));
954
955 for store in store_tables(&value) {
956 let config: StoreConfig = store.clone().try_into().unwrap_or_else(|err| {
957 panic!("store section in {} must parse: {err}", path.display())
958 });
959 config.validate().unwrap_or_else(|err| {
960 panic!("store section in {} must validate: {err}", path.display())
961 });
962 store_sections += 1;
963 }
964 }
965
966 assert!(
968 store_sections >= 6,
969 "expected at least 6 store sections across configs/*.example.toml, found {store_sections}"
970 );
971 }
972
973 fn example_config_paths(configs_dir: &Path) -> Vec<PathBuf> {
974 let mut paths: Vec<PathBuf> = std::fs::read_dir(configs_dir)
975 .expect("read configs directory")
976 .map(|entry| entry.expect("read configs entry").path())
977 .filter(|path| {
978 path.file_name()
979 .and_then(|name| name.to_str())
980 .is_some_and(|name| name.ends_with(".example.toml"))
981 })
982 .collect();
983 paths.sort();
984 assert!(!paths.is_empty(), "no example configs found");
985 paths
986 }
987
988 fn store_tables(value: &toml::Value) -> Vec<&toml::Value> {
991 let mut sections = Vec::new();
992 if let Some(store) = value.get("store") {
993 sections.push(store);
994 }
995 if let Some(profiles) = value.get("profiles").and_then(toml::Value::as_table) {
996 for profile in profiles.values() {
997 if let Some(store) = profile.get("store") {
998 sections.push(store);
999 }
1000 }
1001 }
1002 sections
1003 }
1004}