use crate::abs::AzureAbsStoreConfig;
use crate::gcs::GcpGcsStoreConfig;
use crate::s3_compatible::{AwsS3StoreConfig, CloudflareR2StoreConfig};
use crate::secret::SecretString;
use crate::{ConfiguredObjectStore, ConfiguredObjectStoreKind};
use http::Uri;
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const ACCESS_KEY_ID_ENV: &str = "AWS_ACCESS_KEY_ID";
pub const SECRET_ACCESS_KEY_ENV: &str = "AWS_SECRET_ACCESS_KEY";
pub const SESSION_TOKEN_ENV: &str = "AWS_SESSION_TOKEN";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
pub enum StoreConfig {
LocalFs {
root: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
key_prefix: Option<String>,
},
AwsS3 {
bucket: String,
region: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
endpoint_url: Option<String>,
#[serde(default)]
access_key_id: SecretString,
#[serde(default)]
secret_access_key: SecretString,
#[serde(default, skip_serializing_if = "Option::is_none")]
session_token: Option<SecretString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
key_prefix: Option<String>,
#[serde(default)]
force_path_style: bool,
},
CloudflareR2 {
bucket: String,
account_id: String,
endpoint_url: String,
#[serde(default)]
access_key_id: SecretString,
#[serde(default)]
secret_access_key: SecretString,
#[serde(default, skip_serializing_if = "Option::is_none")]
key_prefix: Option<String>,
},
GcpGcs {
bucket: String,
service_account_key_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
key_prefix: Option<String>,
},
AzureAbs {
account_name: String,
container_name: String,
access_key: SecretString,
#[serde(default, skip_serializing_if = "Option::is_none")]
endpoint_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
key_prefix: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum StoreConfigError {
#[error("missing `{field}`")]
MissingField {
field: &'static str,
},
#[error("missing `{field}`; set it in the config or export `{env}`")]
MissingCredential {
field: &'static str,
env: &'static str,
},
#[error("invalid `{field}`: {reason}")]
InvalidField {
field: &'static str,
reason: String,
},
}
impl StoreConfigError {
pub fn field(&self) -> &'static str {
match self {
StoreConfigError::MissingField { field }
| StoreConfigError::MissingCredential { field, .. }
| StoreConfigError::InvalidField { field, .. } => field,
}
}
}
impl StoreConfig {
pub fn kind(&self) -> ConfiguredObjectStoreKind {
match self {
StoreConfig::LocalFs { .. } => ConfiguredObjectStoreKind::LocalFs,
StoreConfig::AwsS3 { .. } => ConfiguredObjectStoreKind::AwsS3,
StoreConfig::CloudflareR2 { .. } => ConfiguredObjectStoreKind::CloudflareR2,
StoreConfig::GcpGcs { .. } => ConfiguredObjectStoreKind::GcpGcs,
StoreConfig::AzureAbs { .. } => ConfiguredObjectStoreKind::AzureAbs,
}
}
pub fn direct_put_is_proven(&self) -> bool {
match self {
StoreConfig::AwsS3 { endpoint_url, .. } => match endpoint_url {
None => true,
Some(endpoint_url) => endpoint_in_domain_families(
endpoint_url,
&["amazonaws.com", "amazonaws.com.cn"],
),
},
StoreConfig::CloudflareR2 { endpoint_url, .. } => {
endpoint_in_domain_families(endpoint_url, &["r2.cloudflarestorage.com"])
}
StoreConfig::LocalFs { .. }
| StoreConfig::GcpGcs { .. }
| StoreConfig::AzureAbs { .. } => false,
}
}
pub fn configured_object_store(&self) -> crate::object_store::Result<ConfiguredObjectStore> {
match self {
StoreConfig::LocalFs { root, key_prefix } => {
ConfiguredObjectStore::local_fs(root, key_prefix.as_deref())
}
StoreConfig::AwsS3 {
bucket,
region,
endpoint_url,
access_key_id,
secret_access_key,
session_token,
key_prefix,
force_path_style,
} => ConfiguredObjectStore::aws_s3(AwsS3StoreConfig {
bucket: bucket.clone(),
region: region.clone(),
endpoint_url: endpoint_url.clone(),
access_key_id: access_key_id.clone(),
secret_access_key: secret_access_key.clone(),
session_token: session_token.clone(),
key_prefix: key_prefix.clone(),
force_path_style: *force_path_style,
}),
StoreConfig::CloudflareR2 {
bucket,
account_id,
endpoint_url,
access_key_id,
secret_access_key,
key_prefix,
} => ConfiguredObjectStore::cloudflare_r2(CloudflareR2StoreConfig {
bucket: bucket.clone(),
account_id: account_id.clone(),
endpoint_url: endpoint_url.clone(),
access_key_id: access_key_id.clone(),
secret_access_key: secret_access_key.clone(),
key_prefix: key_prefix.clone(),
}),
StoreConfig::GcpGcs {
bucket,
service_account_key_path,
key_prefix,
} => ConfiguredObjectStore::gcp_gcs(GcpGcsStoreConfig {
bucket: bucket.clone(),
service_account_key_path: service_account_key_path.clone(),
key_prefix: key_prefix.clone(),
}),
StoreConfig::AzureAbs {
account_name,
container_name,
access_key,
endpoint_url,
key_prefix,
} => ConfiguredObjectStore::azure_abs(AzureAbsStoreConfig {
account_name: account_name.clone(),
container_name: container_name.clone(),
access_key: access_key.clone(),
endpoint_url: endpoint_url.clone(),
key_prefix: key_prefix.clone(),
}),
}
}
pub fn apply_env_credentials(&mut self) {
self.apply_env_credentials_from(|name| std::env::var(name).ok());
}
fn apply_env_credentials_from(&mut self, lookup: impl Fn(&str) -> Option<String>) {
match self {
StoreConfig::AwsS3 {
access_key_id,
secret_access_key,
session_token,
..
} => {
fill_secret(access_key_id, &lookup, ACCESS_KEY_ID_ENV);
fill_secret(secret_access_key, &lookup, SECRET_ACCESS_KEY_ENV);
if session_token.is_none() {
*session_token = non_blank(lookup(SESSION_TOKEN_ENV)).map(SecretString::new);
}
}
StoreConfig::CloudflareR2 {
access_key_id,
secret_access_key,
..
} => {
fill_secret(access_key_id, &lookup, ACCESS_KEY_ID_ENV);
fill_secret(secret_access_key, &lookup, SECRET_ACCESS_KEY_ENV);
}
StoreConfig::LocalFs { .. }
| StoreConfig::GcpGcs { .. }
| StoreConfig::AzureAbs { .. } => {}
}
}
pub fn validate(&self) -> Result<(), StoreConfigError> {
match self {
StoreConfig::LocalFs { root, .. } => {
require_non_empty("store.root", root)?;
}
StoreConfig::AwsS3 {
bucket,
region,
endpoint_url,
access_key_id,
secret_access_key,
..
} => {
require_non_empty("store.bucket", bucket)?;
require_non_empty("store.region", region)?;
require_credential(
"store.access_key_id",
ACCESS_KEY_ID_ENV,
access_key_id.expose(),
)?;
require_credential(
"store.secret_access_key",
SECRET_ACCESS_KEY_ENV,
secret_access_key.expose(),
)?;
if let Some(url) = endpoint_url {
validate_absolute_http_url("store.endpoint_url", url)?;
}
}
StoreConfig::CloudflareR2 {
bucket,
account_id,
endpoint_url,
access_key_id,
secret_access_key,
..
} => {
require_non_empty("store.bucket", bucket)?;
require_non_empty("store.account_id", account_id)?;
require_credential(
"store.access_key_id",
ACCESS_KEY_ID_ENV,
access_key_id.expose(),
)?;
require_credential(
"store.secret_access_key",
SECRET_ACCESS_KEY_ENV,
secret_access_key.expose(),
)?;
validate_absolute_http_url("store.endpoint_url", endpoint_url)?;
}
StoreConfig::GcpGcs {
bucket,
service_account_key_path,
..
} => {
require_non_empty("store.bucket", bucket)?;
require_non_empty("store.service_account_key_path", service_account_key_path)?;
}
StoreConfig::AzureAbs {
account_name,
container_name,
access_key,
endpoint_url,
..
} => {
require_non_empty("store.account_name", account_name)?;
require_non_empty("store.container_name", container_name)?;
require_non_empty("store.access_key", access_key.expose())?;
if let Some(url) = endpoint_url {
validate_absolute_http_url("store.endpoint_url", url)?;
}
}
}
Ok(())
}
pub fn redacted(&self) -> Self {
let mut redacted = self.clone();
match &mut redacted {
StoreConfig::LocalFs { .. } | StoreConfig::GcpGcs { .. } => {}
StoreConfig::AwsS3 {
access_key_id,
secret_access_key,
session_token,
..
} => {
*access_key_id = access_key_id.masked();
*secret_access_key = secret_access_key.masked();
*session_token = session_token.as_ref().map(SecretString::masked);
}
StoreConfig::CloudflareR2 {
access_key_id,
secret_access_key,
..
} => {
*access_key_id = access_key_id.masked();
*secret_access_key = secret_access_key.masked();
}
StoreConfig::AzureAbs { access_key, .. } => {
*access_key = access_key.masked();
}
}
redacted
}
}
fn require_non_empty(field: &'static str, value: &str) -> Result<(), StoreConfigError> {
if value.trim().is_empty() {
Err(StoreConfigError::MissingField { field })
} else {
Ok(())
}
}
fn require_credential(
field: &'static str,
env: &'static str,
value: &str,
) -> Result<(), StoreConfigError> {
if value.trim().is_empty() {
Err(StoreConfigError::MissingCredential { field, env })
} else {
Ok(())
}
}
fn fill_secret(
secret: &mut SecretString,
lookup: &impl Fn(&str) -> Option<String>,
env: &'static str,
) {
if !secret.expose().trim().is_empty() {
return;
}
if let Some(value) = non_blank(lookup(env)) {
*secret = SecretString::new(value);
}
}
fn non_blank(value: Option<String>) -> Option<String> {
value.filter(|value| !value.trim().is_empty())
}
fn validate_absolute_http_url(field: &'static str, value: &str) -> Result<(), StoreConfigError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(StoreConfigError::MissingField { field });
}
let uri: Uri =
trimmed.parse().map_err(
|err: http::uri::InvalidUri| StoreConfigError::InvalidField {
field,
reason: err.to_string(),
},
)?;
match uri.scheme_str() {
Some("http" | "https") => {}
Some(other) => {
return Err(StoreConfigError::InvalidField {
field,
reason: format!("scheme must be http or https, got `{other}`"),
});
}
None => {
return Err(StoreConfigError::InvalidField {
field,
reason: "must be an absolute http or https URL".to_owned(),
});
}
}
if uri.authority().is_none() {
return Err(StoreConfigError::InvalidField {
field,
reason: "must be an absolute http or https URL".to_owned(),
});
}
Ok(())
}
fn endpoint_in_domain_families(endpoint_url: &str, domain_families: &[&str]) -> bool {
let Ok(uri) = endpoint_url.parse::<Uri>() else {
return false;
};
let Some(host) = uri.host() else {
return false;
};
let host = host.trim_end_matches('.').to_ascii_lowercase();
domain_families.iter().any(|domain| {
host == *domain
|| host
.strip_suffix(domain)
.is_some_and(|prefix| prefix.ends_with('.'))
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::panic)]
use super::{StoreConfig, StoreConfigError};
use crate::secret::SecretString;
use crate::ConfiguredObjectStoreKind;
use std::path::{Path, PathBuf};
fn parse(contents: &str) -> StoreConfig {
toml::from_str(contents).expect("parse store config")
}
#[test]
fn parses_all_provider_kinds_and_reports_their_kind() {
let cases: [(&str, ConfiguredObjectStoreKind); 5] = [
(
"kind = \"local-fs\"\nroot = \"/tmp/store\"",
ConfiguredObjectStoreKind::LocalFs,
),
(
r#"
kind = "aws-s3"
bucket = "bucket"
region = "us-east-1"
access_key_id = "access"
secret_access_key = "secret"
"#,
ConfiguredObjectStoreKind::AwsS3,
),
(
r#"
kind = "cloudflare-r2"
bucket = "bucket"
account_id = "account"
endpoint_url = "https://account.r2.cloudflarestorage.com"
access_key_id = "access"
secret_access_key = "secret"
"#,
ConfiguredObjectStoreKind::CloudflareR2,
),
(
r#"
kind = "gcp-gcs"
bucket = "bucket"
service_account_key_path = "/tmp/service-account.json"
"#,
ConfiguredObjectStoreKind::GcpGcs,
),
(
r#"
kind = "azure-abs"
account_name = "account"
container_name = "container"
access_key = "key"
"#,
ConfiguredObjectStoreKind::AzureAbs,
),
];
for (contents, kind) in cases {
let config = parse(contents);
assert_eq!(config.kind(), kind);
config.validate().expect("valid config");
}
}
#[test]
fn direct_put_is_proven_only_for_first_party_s3_and_r2_endpoints() {
let aws_default = parse(
r#"
kind = "aws-s3"
bucket = "bucket"
region = "us-east-1"
access_key_id = "access"
secret_access_key = "secret"
"#,
);
let aws_first_party = parse(
r#"
kind = "aws-s3"
bucket = "bucket"
region = "cn-north-1"
endpoint_url = "https://bucket.s3.cn-north-1.amazonaws.com.cn"
access_key_id = "access"
secret_access_key = "secret"
"#,
);
let aws_custom = parse(
r#"
kind = "aws-s3"
bucket = "bucket"
region = "us-east-1"
endpoint_url = "https://gateway.example"
access_key_id = "access"
secret_access_key = "secret"
"#,
);
let r2_first_party = parse(
r#"
kind = "cloudflare-r2"
bucket = "bucket"
account_id = "account"
endpoint_url = "https://account.r2.cloudflarestorage.com"
access_key_id = "access"
secret_access_key = "secret"
"#,
);
let r2_custom = parse(
r#"
kind = "cloudflare-r2"
bucket = "bucket"
account_id = "account"
endpoint_url = "https://gateway.example"
access_key_id = "access"
secret_access_key = "secret"
"#,
);
let gcs = parse(
r#"
kind = "gcp-gcs"
bucket = "bucket"
service_account_key_path = "/tmp/service-account.json"
"#,
);
assert!(aws_default.direct_put_is_proven());
assert!(aws_first_party.direct_put_is_proven());
assert!(!aws_custom.direct_put_is_proven());
assert!(r2_first_party.direct_put_is_proven());
assert!(!r2_custom.direct_put_is_proven());
assert!(!gcs.direct_put_is_proven());
}
#[test]
fn credentials_left_out_of_the_file_come_from_the_environment() {
let environment = |name: &str| match name {
"AWS_ACCESS_KEY_ID" => Some("env-access".to_owned()),
"AWS_SECRET_ACCESS_KEY" => Some("env-secret".to_owned()),
"AWS_SESSION_TOKEN" => Some("env-session".to_owned()),
_ => None,
};
let mut aws = parse(
r#"
kind = "aws-s3"
bucket = "bucket"
region = "us-east-1"
"#,
);
aws.apply_env_credentials_from(environment);
aws.validate().expect("the environment completed the store");
match &aws {
StoreConfig::AwsS3 {
access_key_id,
secret_access_key,
session_token,
..
} => {
assert_eq!(access_key_id.expose(), "env-access");
assert_eq!(secret_access_key.expose(), "env-secret");
assert_eq!(
session_token.as_ref().map(SecretString::expose),
Some("env-session")
);
}
other => panic!("expected an aws-s3 store, got {other:?}"),
}
let mut r2 = parse(
r#"
kind = "cloudflare-r2"
bucket = "bucket"
account_id = "account"
endpoint_url = "https://account.r2.cloudflarestorage.com"
"#,
);
r2.apply_env_credentials_from(environment);
r2.validate().expect("the environment completed the store");
let mut explicit = parse(
r#"
kind = "aws-s3"
bucket = "bucket"
region = "us-east-1"
access_key_id = "file-access"
secret_access_key = "file-secret"
"#,
);
explicit.apply_env_credentials_from(environment);
let blank = |_: &str| Some(" ".to_owned());
let mut unfilled = parse(
r#"
kind = "aws-s3"
bucket = "bucket"
region = "us-east-1"
"#,
);
unfilled.apply_env_credentials_from(blank);
match (&explicit, &unfilled) {
(
StoreConfig::AwsS3 {
access_key_id,
session_token,
..
},
StoreConfig::AwsS3 {
access_key_id: unfilled_key,
session_token: unfilled_token,
..
},
) => {
assert_eq!(access_key_id.expose(), "file-access");
assert_eq!(
session_token.as_ref().map(SecretString::expose),
Some("env-session"),
"an absent optional field still takes the environment"
);
assert!(unfilled_key.expose().is_empty());
assert!(unfilled_token.is_none());
}
other => panic!("expected two aws-s3 stores, got {other:?}"),
}
let mut gcs = parse(
r#"
kind = "gcp-gcs"
bucket = "bucket"
service_account_key_path = "/tmp/service-account.json"
"#,
);
let before = gcs.clone();
gcs.apply_env_credentials_from(environment);
assert_eq!(gcs, before);
}
#[test]
fn a_credential_missing_everywhere_names_its_environment_variable() {
let store = parse(
r#"
kind = "aws-s3"
bucket = "bucket"
region = "us-east-1"
secret_access_key = "secret"
"#,
);
let error = store.validate().expect_err("no access key anywhere");
assert_eq!(
error,
StoreConfigError::MissingCredential {
field: "store.access_key_id",
env: "AWS_ACCESS_KEY_ID",
}
);
assert_eq!(
error.to_string(),
"missing `store.access_key_id`; set it in the config or export `AWS_ACCESS_KEY_ID`"
);
}
#[test]
fn validate_reports_store_rooted_field_paths() {
let blank_bucket = parse(
r#"
kind = "cloudflare-r2"
bucket = " "
account_id = "account"
endpoint_url = "https://example.com"
access_key_id = "access"
secret_access_key = "secret"
"#,
);
assert_eq!(
blank_bucket.validate(),
Err(StoreConfigError::MissingField {
field: "store.bucket"
})
);
let bad_scheme = parse(
r#"
kind = "aws-s3"
bucket = "bucket"
region = "us-east-1"
endpoint_url = "ftp://example.com"
access_key_id = "access"
secret_access_key = "secret"
"#,
);
match bad_scheme.validate() {
Err(StoreConfigError::InvalidField { field, reason }) => {
assert_eq!(field, "store.endpoint_url");
assert!(reason.contains("ftp"));
}
other => panic!("expected invalid endpoint_url, got {other:?}"),
}
let blank_azure_account = parse(
r#"
kind = "azure-abs"
account_name = " "
container_name = "container"
access_key = "key"
"#,
);
assert_eq!(
blank_azure_account.validate(),
Err(StoreConfigError::MissingField {
field: "store.account_name"
})
);
}
#[test]
fn unknown_keys_are_rejected_and_named() {
let error = toml::from_str::<StoreConfig>(
r#"
kind = "aws-s3"
bucket = "bucket"
region = "us-east-1"
access_key_id = "access"
secret_access_key = "secret"
buckt = "typo"
"#,
)
.expect_err("typo'd key must be rejected");
let message = error.to_string();
assert!(
message.contains("buckt"),
"error must name the unknown key, got: {message}"
);
}
#[test]
fn debug_output_redacts_credentials() {
let config = parse(
r#"
kind = "aws-s3"
bucket = "bucket"
region = "us-east-1"
access_key_id = "debug-access-key-id"
secret_access_key = "debug-secret-access-key"
session_token = "debug-session-token"
"#,
);
let rendered = format!("{config:?}");
assert!(!rendered.contains("debug-access-key-id"));
assert!(!rendered.contains("debug-secret-access-key"));
assert!(!rendered.contains("debug-session-token"));
assert!(rendered.contains("bucket"));
}
#[test]
fn redacted_copy_serializes_without_credentials() {
let config = parse(
r#"
kind = "cloudflare-r2"
bucket = "bucket"
account_id = "account"
endpoint_url = "https://account.r2.cloudflarestorage.com"
access_key_id = "plain-access-key-id"
secret_access_key = "plain-secret-access-key"
"#,
);
let rendered = toml::to_string_pretty(&config.redacted()).expect("serialize redacted");
assert!(!rendered.contains("plain-access-key-id"));
assert!(!rendered.contains("plain-secret-access-key"));
assert!(rendered.contains("<redacted>"));
assert!(rendered.contains("account"));
}
#[test]
fn serialization_round_trips_the_store_table() {
let config = parse(
r#"
kind = "aws-s3"
bucket = "bucket"
region = "us-east-1"
access_key_id = "access"
secret_access_key = "secret"
key_prefix = "demo"
"#,
);
let rendered = toml::to_string_pretty(&config).expect("serialize store config");
assert!(rendered.contains("kind = \"aws-s3\""));
assert!(!rendered.contains("session_token"));
assert!(!rendered.contains("endpoint_url"));
let reparsed: StoreConfig = toml::from_str(&rendered).expect("reparse store config");
assert_eq!(reparsed, config);
}
#[test]
fn example_configs_store_sections_parse() {
let configs_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../configs");
let mut store_sections = 0usize;
for path in example_config_paths(&configs_dir) {
let contents = std::fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
let value: toml::Value = toml::from_str(&contents)
.unwrap_or_else(|err| panic!("parse {}: {err}", path.display()));
for store in store_tables(&value) {
let config: StoreConfig = store.clone().try_into().unwrap_or_else(|err| {
panic!("store section in {} must parse: {err}", path.display())
});
config.validate().unwrap_or_else(|err| {
panic!("store section in {} must validate: {err}", path.display())
});
store_sections += 1;
}
}
assert!(
store_sections >= 6,
"expected at least 6 store sections across configs/*.example.toml, found {store_sections}"
);
}
fn example_config_paths(configs_dir: &Path) -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = std::fs::read_dir(configs_dir)
.expect("read configs directory")
.map(|entry| entry.expect("read configs entry").path())
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(".example.toml"))
})
.collect();
paths.sort();
assert!(!paths.is_empty(), "no example configs found");
paths
}
fn store_tables(value: &toml::Value) -> Vec<&toml::Value> {
let mut sections = Vec::new();
if let Some(store) = value.get("store") {
sections.push(store);
}
if let Some(profiles) = value.get("profiles").and_then(toml::Value::as_table) {
for profile in profiles.values() {
if let Some(store) = profile.get("store") {
sections.push(store);
}
}
}
sections
}
}