Skip to main content

loonfs_objectstore/
store_config.rs

1//! The shared serde-facing object-store provider configuration.
2//!
3//! Both the server config file (`[store]`) and the CLI profile config
4//! (`[profiles.<name>.store]`) deserialize into [`StoreConfig`], validate it
5//! with [`StoreConfig::validate`], and construct the runtime store with
6//! [`StoreConfig::configured_object_store`]. The TOML shape is kind-tagged
7//! and kebab-case, documented by the examples in `configs/`.
8
9use 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
18/// Environment variable the S3-compatible providers read their access-key id
19/// from when the config file leaves it out.
20pub const ACCESS_KEY_ID_ENV: &str = "AWS_ACCESS_KEY_ID";
21/// Environment variable the S3-compatible providers read their secret access
22/// key from when the config file leaves it out.
23pub const SECRET_ACCESS_KEY_ENV: &str = "AWS_SECRET_ACCESS_KEY";
24/// Environment variable a temporary AWS credential's session token comes
25/// from when the config file leaves it out.
26pub const SESSION_TOKEN_ENV: &str = "AWS_SESSION_TOKEN";
27
28/// Provider selection plus credentials, as written in config files.
29///
30/// Serialization is transparent for secret fields and therefore writes the
31/// real credentials; only serialize a [`StoreConfig::redacted`] copy into
32/// display output.
33///
34/// # Credential precedence
35///
36/// The S3-compatible credential fields may be left out of the file and
37/// supplied through the standard environment instead — see
38/// [`StoreConfig::apply_env_credentials`]. A non-blank value in the file
39/// always wins; blank environment values are ignored. Loading a config is
40/// what decides whether that fallback applies, so each caller says for
41/// itself: the server's loader applies it, the CLI's profile loader does not
42/// (a profile stores what it was created with).
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
45pub enum StoreConfig {
46    /// Stores objects beneath a Unix-family directory using atomic filesystem replacement.
47    LocalFs {
48        /// Directory created or opened as the physical store root.
49        root: String,
50        /// Logical prefix applied inside `root`, or `None` to expose it directly.
51        #[serde(default, skip_serializing_if = "Option::is_none")]
52        key_prefix: Option<String>,
53    },
54    /// Connects to AWS S3 or an explicitly configured S3-compatible endpoint.
55    AwsS3 {
56        /// Bucket that acts as the physical store root.
57        bucket: String,
58        /// SigV4 signing region.
59        region: String,
60        /// Service endpoint override, or `None` to derive the regional AWS endpoint.
61        #[serde(default, skip_serializing_if = "Option::is_none")]
62        endpoint_url: Option<String>,
63        /// Access-key id used for provider requests and direct-upload signing.
64        /// Absent here means `AWS_ACCESS_KEY_ID`, for loaders that apply the
65        /// environment fallback.
66        #[serde(default)]
67        access_key_id: SecretString,
68        /// Secret access key used for provider requests and direct-upload signing.
69        /// Absent here means `AWS_SECRET_ACCESS_KEY`, for loaders that apply
70        /// the environment fallback.
71        #[serde(default)]
72        secret_access_key: SecretString,
73        /// Temporary credential token, or `None` for long-lived credentials.
74        #[serde(default, skip_serializing_if = "Option::is_none")]
75        session_token: Option<SecretString>,
76        /// Logical prefix applied inside the bucket, or `None` to expose its root.
77        #[serde(default, skip_serializing_if = "Option::is_none")]
78        key_prefix: Option<String>,
79        /// Uses path-style bucket addressing when `true`; defaults to virtual-hosted style.
80        #[serde(default)]
81        force_path_style: bool,
82    },
83    /// Connects to Cloudflare R2 through its S3-compatible endpoint.
84    CloudflareR2 {
85        /// R2 bucket that acts as the physical store root.
86        bucket: String,
87        /// Cloudflare account identity used to validate provider configuration.
88        account_id: String,
89        /// Account-level R2 S3 endpoint used with path-style bucket addressing.
90        endpoint_url: String,
91        /// S3-compatible access-key id used for requests and direct-upload
92        /// signing. R2 speaks the S3 API, so an absent value here means
93        /// `AWS_ACCESS_KEY_ID` too.
94        #[serde(default)]
95        access_key_id: SecretString,
96        /// S3-compatible secret used for requests and direct-upload signing.
97        /// Absent here means `AWS_SECRET_ACCESS_KEY`.
98        #[serde(default)]
99        secret_access_key: SecretString,
100        /// Logical prefix applied inside the bucket, or `None` to expose its root.
101        #[serde(default, skip_serializing_if = "Option::is_none")]
102        key_prefix: Option<String>,
103    },
104    /// Connects to Google Cloud Storage through its native generation-aware API.
105    GcpGcs {
106        /// GCS bucket that acts as the physical store root.
107        bucket: String,
108        /// Filesystem path to the service-account JSON used for authentication.
109        service_account_key_path: String,
110        /// Logical prefix applied inside the bucket, or `None` to expose its root.
111        #[serde(default, skip_serializing_if = "Option::is_none")]
112        key_prefix: Option<String>,
113    },
114    /// Connects to Azure Blob Storage through its native shared-key API.
115    AzureAbs {
116        /// Azure storage account used for addressing and signing.
117        account_name: String,
118        /// Blob container that acts as the physical store root.
119        container_name: String,
120        /// Shared account key used for request authentication.
121        access_key: SecretString,
122        /// Azure-compatible endpoint override, or `None` for the public service.
123        #[serde(default, skip_serializing_if = "Option::is_none")]
124        endpoint_url: Option<String>,
125        /// Logical prefix applied inside the container, or `None` to expose its root.
126        #[serde(default, skip_serializing_if = "Option::is_none")]
127        key_prefix: Option<String>,
128    },
129}
130
131/// Validation failure for a [`StoreConfig`].
132///
133/// Field paths are rooted at the store table (`store.bucket`, ...) so callers
134/// can report them directly or prefix them with their own config path.
135#[derive(Debug, Clone, PartialEq, Eq, Error)]
136pub enum StoreConfigError {
137    /// Reports a required field whose value is absent or blank.
138    #[error("missing `{field}`")]
139    MissingField {
140        /// `store.`-rooted path suitable for direct configuration diagnostics.
141        field: &'static str,
142    },
143    /// Reports a required credential absent from the file and from the
144    /// environment variable that can stand in for it.
145    #[error("missing `{field}`; set it in the config or export `{env}`")]
146    MissingCredential {
147        /// `store.`-rooted path suitable for direct configuration diagnostics.
148        field: &'static str,
149        /// Standard environment variable this credential also reads from.
150        env: &'static str,
151    },
152    /// Reports a present field whose value violates its provider-specific contract.
153    #[error("invalid `{field}`: {reason}")]
154    InvalidField {
155        /// `store.`-rooted path suitable for direct configuration diagnostics.
156        field: &'static str,
157        /// Specific validation failure without repeating the field path.
158        reason: String,
159    },
160}
161
162impl StoreConfigError {
163    /// The `store.`-rooted path of the offending field.
164    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    /// The provider kind this configuration selects.
175    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    /// Whether this endpoint is one whose direct-put preconditions the live
186    /// conformance suite has actually proven.
187    ///
188    /// `direct_put` hands a client a presigned URL and then trusts the
189    /// provider to have enforced the signed checksum and create-only
190    /// preconditions — completion never reads the bytes back. Only
191    /// first-party AWS S3 and Cloudflare R2 endpoints have been run against
192    /// that suite, so only they earn the trust. An endpoint override outside
193    /// the provider's own domain family is some other implementation of the
194    /// S3 API and is not covered by those runs.
195    ///
196    /// Providers without a presigner at all (local filesystem, GCS, Azure)
197    /// never reach this question: `direct_put` is unavailable for them
198    /// because [`ConfiguredObjectStore::transfer_issuer`] returns `None`.
199    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    /// Builds the configured runtime object store for this provider.
218    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    /// Fills blank S3-compatible credential fields from the standard
283    /// environment variables, so a config file need not carry secrets at
284    /// all.
285    ///
286    /// A non-blank value in the file always wins, and a blank environment
287    /// value is ignored rather than stored and later rejected. Providers
288    /// with no standard variable of their own — local filesystem, GCS,
289    /// Azure — are untouched: inventing a name for them would be a guess.
290    ///
291    /// Call this after decoding and before [`Self::validate`], so a config
292    /// that relies on the environment passes validation and one that does
293    /// not is reported against the field it actually lacks.
294    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    /// Checks required fields and URL shapes, reporting `store.`-rooted field
327    /// paths.
328    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    /// Returns a copy whose secret fields hold the redaction placeholder, for
406    /// serialization into `show`-style display output.
407    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
445/// Like [`require_non_empty`], for a field the standard environment can also
446/// supply; the failure names both places.
447fn 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
459/// Replaces a blank secret with the environment's value, if it has one.
460fn 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    // Config tests use panic in unexpected match arms for precise diagnostics.
537
538    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        // GCS has no presigner at all, so it never reaches the question.
666        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        // An S3 store may name no credentials at all.
679        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        // R2 reads the same S3-compatible variables.
706        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        // A value in the file wins, and a blank environment value is ignored.
718        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        // Providers with no standard variable are untouched.
763        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        // `deny_unknown_fields` must keep working through the internally
856        // tagged (`kind = ...`) enum representation: a typo'd key in the
857        // store table has to fail the parse and name the offending key.
858        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    /// Every example config in `configs/*.example.toml` must keep parsing
942    /// into the shared [`StoreConfig`]: the examples document the frozen TOML
943    /// shape.
944    #[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        // Five server examples plus the embedded CLI example.
967        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    /// Collects `[store]` tables from server configs and
989    /// `[profiles.<name>.store]` tables from CLI configs.
990    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}