use serde::{Deserialize, Serialize};
use super::error::StorageError;
use super::url::Scheme;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct S3Config {
pub region: Option<String>,
pub endpoint: Option<String>,
pub access_key_id: Option<String>,
pub secret_access_key: Option<String>,
pub session_token: Option<String>,
#[serde(default)]
pub allow_http: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GcsConfig {
pub service_account_json: Option<String>,
pub service_account_path: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AzureConfig {
pub account_name: Option<String>,
pub account_key: Option<String>,
pub sas_token: Option<String>,
pub tenant_id: Option<String>,
pub client_id: Option<String>,
pub client_secret: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct R2Config {
pub account_id: Option<String>,
pub endpoint: Option<String>,
pub access_key_id: Option<String>,
pub secret_access_key: Option<String>,
#[serde(default)]
pub allow_http: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum CloudConfig {
S3(S3Config),
Gcs(GcsConfig),
Azure(AzureConfig),
R2(R2Config),
}
impl CloudConfig {
pub fn validate(&self) -> Result<(), StorageError> {
match self {
Self::S3(c) => c.validate(),
Self::Gcs(c) => c.validate(),
Self::Azure(c) => c.validate(),
Self::R2(c) => c.validate(),
}
}
}
impl S3Config {
pub fn validate(&self) -> Result<(), StorageError> {
match (
self.access_key_id.as_deref(),
self.secret_access_key.as_deref(),
) {
(Some(_), None) => Err(StorageError::DriverInit {
scheme: Scheme::S3,
reason: "access_key_id is set but secret_access_key is missing".into(),
}),
(None, Some(_)) => Err(StorageError::DriverInit {
scheme: Scheme::S3,
reason: "secret_access_key is set but access_key_id is missing".into(),
}),
_ => {
if self.session_token.is_some() && self.access_key_id.is_none() {
return Err(StorageError::DriverInit {
scheme: Scheme::S3,
reason: "session_token requires access_key_id and secret_access_key".into(),
});
}
Ok(())
}
}
}
}
impl GcsConfig {
pub fn validate(&self) -> Result<(), StorageError> {
if self.service_account_json.is_some() && self.service_account_path.is_some() {
return Err(StorageError::DriverInit {
scheme: Scheme::Gcs,
reason: "service_account_json and service_account_path are mutually exclusive"
.into(),
});
}
Ok(())
}
}
impl AzureConfig {
pub fn validate(&self) -> Result<(), StorageError> {
let any_set = self.account_key.is_some()
|| self.sas_token.is_some()
|| self.tenant_id.is_some()
|| self.client_id.is_some()
|| self.client_secret.is_some();
if any_set && self.account_name.is_none() {
return Err(StorageError::DriverInit {
scheme: Scheme::Azure,
reason: "account_name is required when any other Azure credential field is set"
.into(),
});
}
if self.account_key.is_some() && self.sas_token.is_some() {
return Err(StorageError::DriverInit {
scheme: Scheme::Azure,
reason: "account_key and sas_token are mutually exclusive".into(),
});
}
let oauth_set = [
self.tenant_id.is_some(),
self.client_id.is_some(),
self.client_secret.is_some(),
];
let oauth_count = oauth_set.iter().filter(|b| **b).count();
if oauth_count != 0 && oauth_count != oauth_set.len() {
return Err(StorageError::DriverInit {
scheme: Scheme::Azure,
reason: "tenant_id, client_id, and client_secret must all be set together".into(),
});
}
Ok(())
}
}
impl R2Config {
pub fn resolved_endpoint(&self) -> Option<String> {
self.endpoint.clone().or_else(|| {
self.account_id
.as_ref()
.map(|a| format!("https://{a}.r2.cloudflarestorage.com"))
})
}
pub fn validate(&self) -> Result<(), StorageError> {
if self.resolved_endpoint().is_none() {
return Err(StorageError::DriverInit {
scheme: Scheme::R2,
reason: "R2 requires either account_id or an explicit endpoint".into(),
});
}
match (
self.access_key_id.as_deref(),
self.secret_access_key.as_deref(),
) {
(Some(_), None) => Err(StorageError::DriverInit {
scheme: Scheme::R2,
reason: "access_key_id is set but secret_access_key is missing".into(),
}),
(None, Some(_)) => Err(StorageError::DriverInit {
scheme: Scheme::R2,
reason: "secret_access_key is set but access_key_id is missing".into(),
}),
_ => Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn s3_validate_rejects_half_credentials() {
let bad = S3Config {
access_key_id: Some("AKIA…".into()),
..Default::default()
};
assert!(matches!(
bad.validate(),
Err(StorageError::DriverInit {
scheme: Scheme::S3,
..
})
));
let also_bad = S3Config {
secret_access_key: Some("xyz".into()),
..Default::default()
};
assert!(matches!(
also_bad.validate(),
Err(StorageError::DriverInit {
scheme: Scheme::S3,
..
})
));
}
#[test]
fn s3_validate_rejects_orphan_session_token() {
let bad = S3Config {
session_token: Some("FwoGZ…".into()),
..Default::default()
};
assert!(matches!(
bad.validate(),
Err(StorageError::DriverInit {
scheme: Scheme::S3,
..
})
));
}
#[test]
fn s3_validate_accepts_default_chain() {
assert!(S3Config::default().validate().is_ok());
}
#[test]
fn s3_validate_accepts_full_credentials() {
let good = S3Config {
access_key_id: Some("AKIA…".into()),
secret_access_key: Some("xyz".into()),
..Default::default()
};
assert!(good.validate().is_ok());
}
#[test]
fn r2_derives_endpoint_from_account_id() {
let cfg = R2Config {
account_id: Some("abc123".into()),
..Default::default()
};
assert_eq!(
cfg.resolved_endpoint().as_deref(),
Some("https://abc123.r2.cloudflarestorage.com")
);
}
#[test]
fn r2_explicit_endpoint_overrides_account_id() {
let cfg = R2Config {
account_id: Some("abc123".into()),
endpoint: Some("https://files.example.com".into()),
..Default::default()
};
assert_eq!(
cfg.resolved_endpoint().as_deref(),
Some("https://files.example.com")
);
}
#[test]
fn r2_validate_requires_account_or_endpoint() {
assert!(matches!(
R2Config::default().validate(),
Err(StorageError::DriverInit {
scheme: Scheme::R2,
..
})
));
}
#[test]
fn r2_validate_rejects_half_credentials() {
let bad = R2Config {
account_id: Some("abc".into()),
access_key_id: Some("k".into()),
..Default::default()
};
assert!(matches!(
bad.validate(),
Err(StorageError::DriverInit {
scheme: Scheme::R2,
..
})
));
}
#[test]
fn r2_validate_accepts_account_plus_full_credentials() {
let good = R2Config {
account_id: Some("abc".into()),
access_key_id: Some("k".into()),
secret_access_key: Some("s".into()),
..Default::default()
};
assert!(good.validate().is_ok());
}
#[test]
fn gcs_validate_rejects_both_json_and_path() {
let bad = GcsConfig {
service_account_json: Some("{...}".into()),
service_account_path: Some("/key.json".into()),
};
assert!(matches!(
bad.validate(),
Err(StorageError::DriverInit {
scheme: Scheme::Gcs,
..
})
));
}
#[test]
fn gcs_validate_accepts_default_or_one_source() {
assert!(GcsConfig::default().validate().is_ok());
assert!(GcsConfig {
service_account_json: Some("{...}".into()),
..Default::default()
}
.validate()
.is_ok());
}
#[test]
fn azure_validate_rejects_missing_account_name() {
let bad = AzureConfig {
account_key: Some("k".into()),
..Default::default()
};
assert!(matches!(
bad.validate(),
Err(StorageError::DriverInit {
scheme: Scheme::Azure,
..
})
));
}
#[test]
fn azure_validate_rejects_key_and_sas_together() {
let bad = AzureConfig {
account_name: Some("acct".into()),
account_key: Some("k".into()),
sas_token: Some("?sv=…".into()),
..Default::default()
};
assert!(matches!(
bad.validate(),
Err(StorageError::DriverInit {
scheme: Scheme::Azure,
..
})
));
}
#[test]
fn azure_validate_rejects_partial_oauth() {
let bad = AzureConfig {
account_name: Some("acct".into()),
tenant_id: Some("t".into()),
client_id: Some("c".into()),
..Default::default()
};
assert!(matches!(
bad.validate(),
Err(StorageError::DriverInit {
scheme: Scheme::Azure,
..
})
));
}
#[test]
fn azure_validate_accepts_complete_oauth_triple() {
let good = AzureConfig {
account_name: Some("acct".into()),
tenant_id: Some("t".into()),
client_id: Some("c".into()),
client_secret: Some("s".into()),
..Default::default()
};
assert!(good.validate().is_ok());
}
}