use opendal::Operator;
use opendal::services::S3;
use super::cloud::{CloudBackend, CloudDestination};
use crate::config::DestinationConfig;
use crate::error::Result;
pub type S3Destination = CloudDestination<S3Backend>;
pub struct S3Backend;
fn read_credential_env(env_name: &str, label: &str) -> Result<zeroize::Zeroizing<String>> {
let value = std::env::var(env_name)
.map_err(|_| anyhow::anyhow!("env var '{}' not set for S3 {}", env_name, label))?;
Ok(zeroize::Zeroizing::new(value))
}
impl CloudBackend for S3Backend {
const RUNTIME_LABEL: &'static str = "S3";
const SCHEME: &'static str = "s3";
fn build_operator(config: &DestinationConfig) -> Result<Operator> {
let bucket = config
.bucket
.as_deref()
.ok_or_else(|| anyhow::anyhow!("S3 destination requires 'bucket'"))?;
let mut builder = S3::default().bucket(bucket);
if let Some(region) = &config.region {
builder = builder.region(region);
}
if let Some(endpoint) = &config.endpoint {
builder = builder.endpoint(endpoint);
}
if let Some(env_name) = &config.access_key_env {
let key = read_credential_env(env_name, "access key")?;
builder = builder.access_key_id(key.as_str());
}
if let Some(env_name) = &config.secret_key_env {
let secret = read_credential_env(env_name, "secret key")?;
builder = builder.secret_access_key(secret.as_str());
}
if let Some(env_name) = &config.session_token_env {
let token = read_credential_env(env_name, "session token")?;
builder = builder.session_token(token.as_str());
}
if let Some(profile) = &config.aws_profile {
log::info!("S3: using AWS profile '{}'", profile);
let cred_config = reqsign::AwsConfig {
profile: profile.clone(),
..Default::default()
}
.from_profile()
.from_env();
let loader = reqsign::AwsDefaultLoader::new(reqwest::Client::new(), cred_config);
builder = builder.customized_credential_load(Box::new(loader));
}
Ok(Operator::new(builder)?.finish())
}
}
#[cfg(test)]
mod tests {
#[test]
fn aws_profile_does_not_mutate_aws_profile_env_var() {
let before = std::env::var("AWS_PROFILE").ok();
let profile = "unit-test-profile-rivet";
let cred_config = reqsign::AwsConfig {
profile: profile.to_string(),
..Default::default()
}
.from_profile()
.from_env();
drop(cred_config);
let after = std::env::var("AWS_PROFILE").ok();
assert_eq!(
before, after,
"building AwsConfig with a named profile must not mutate the AWS_PROFILE env var"
);
}
#[test]
fn aws_profile_independent_configs_are_independent() {
let cfg_a = reqsign::AwsConfig {
profile: "profile-a".to_string(),
..Default::default()
};
let cfg_b = reqsign::AwsConfig {
profile: "profile-b".to_string(),
..Default::default()
};
assert_eq!(cfg_a.profile, "profile-a");
assert_eq!(cfg_b.profile, "profile-b");
}
#[test]
fn aws_profile_config_field_parsed_from_destination_config() {
use crate::config::DestinationConfig;
let yaml = r#"
type: s3
bucket: my-bucket
aws_profile: staging
"#;
let config: DestinationConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.aws_profile.as_deref(), Some("staging"));
}
#[test]
fn session_token_env_field_parsed_from_destination_config() {
use crate::config::DestinationConfig;
let yaml = r#"
type: s3
bucket: my-bucket
access_key_env: AWS_ACCESS_KEY_ID
secret_key_env: AWS_SECRET_ACCESS_KEY
session_token_env: AWS_SESSION_TOKEN
"#;
let config: DestinationConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(
config.session_token_env.as_deref(),
Some("AWS_SESSION_TOKEN")
);
}
#[test]
fn read_credential_env_missing_var_errors_with_label() {
let name = "RIVET_TEST_S3_TOKEN_DEFINITELY_UNSET_XYZ";
unsafe { std::env::remove_var(name) };
let err = super::read_credential_env(name, "session token").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains(name), "missing env var name in error: {msg}");
assert!(
msg.contains("session token"),
"missing credential label in error: {msg}"
);
}
#[test]
fn read_credential_env_reads_value_into_zeroizing() {
let name = "RIVET_TEST_S3_TOKEN_PRESENT_XYZ";
unsafe { std::env::set_var(name, "fake-token-value") };
let zeroizing = super::read_credential_env(name, "session token").unwrap();
assert_eq!(zeroizing.as_str(), "fake-token-value");
unsafe { std::env::remove_var(name) };
}
}