use std::collections::HashMap;
use crate::error::MiniAppError;
pub const ENV_ENDPOINT: &str = "MINI_APP_S3_ENDPOINT";
pub const ENV_BUCKET: &str = "MINI_APP_S3_BUCKET";
pub const ENV_ACCESS_KEY_ID: &str = "MINI_APP_S3_ACCESS_KEY_ID";
pub const ENV_SECRET_ACCESS_KEY: &str = "MINI_APP_S3_SECRET_ACCESS_KEY";
pub const ENV_PREFIX: &str = "MINI_APP_S3_PREFIX";
pub const ENV_REGION: &str = "MINI_APP_S3_REGION";
pub const ENV_VIRTUAL_HOSTED_STYLE: &str = "MINI_APP_S3_VIRTUAL_HOSTED_STYLE";
pub const ENV_CHECKSUM: &str = "MINI_APP_S3_CHECKSUM";
pub const DEFAULT_PREFIX: &str = "mini-app-snapshots/";
pub const DEFAULT_REGION: &str = "us-east-1";
pub fn derive_region_from_endpoint(endpoint: &str) -> Option<String> {
let host = endpoint
.trim_start_matches("https://")
.trim_start_matches("http://")
.split(['/', ':'])
.next()?;
let labels: Vec<&str> = host.split('.').collect();
if labels.len() >= 4 && labels[0] == "s3" && !labels[1].is_empty() {
Some(labels[1].to_string())
} else {
None
}
}
#[derive(Debug, Clone)]
pub struct S3UploadConfig {
pub endpoint: String,
pub bucket: String,
pub access_key_id: String,
pub secret_access_key: String,
pub prefix: String,
pub region: Option<String>,
pub virtual_hosted_style: bool,
pub checksum_sha256: bool,
}
impl S3UploadConfig {
pub fn from_vars(vars: &HashMap<String, String>) -> Result<Self, MiniAppError> {
let get = |key: &str| -> Option<String> {
vars.get(key)
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
};
let mut missing: Vec<&str> = Vec::new();
let endpoint = get(ENV_ENDPOINT);
if endpoint.is_none() {
missing.push(ENV_ENDPOINT);
}
let bucket = get(ENV_BUCKET);
if bucket.is_none() {
missing.push(ENV_BUCKET);
}
let access_key_id = get(ENV_ACCESS_KEY_ID);
if access_key_id.is_none() {
missing.push(ENV_ACCESS_KEY_ID);
}
let secret_access_key = get(ENV_SECRET_ACCESS_KEY);
if secret_access_key.is_none() {
missing.push(ENV_SECRET_ACCESS_KEY);
}
if !missing.is_empty() {
return Err(MiniAppError::UploadNotConfigured(format!(
"missing env: {}",
missing.join(", ")
)));
}
let virtual_hosted_style = match get(ENV_VIRTUAL_HOSTED_STYLE).as_deref() {
None => false,
Some(v) if v.eq_ignore_ascii_case("true") || v == "1" => true,
Some(v) if v.eq_ignore_ascii_case("false") || v == "0" => false,
Some(other) => {
return Err(MiniAppError::UploadNotConfigured(format!(
"{ENV_VIRTUAL_HOSTED_STYLE} must be true/false, got '{other}'"
)));
}
};
let checksum_sha256 = match get(ENV_CHECKSUM).as_deref() {
None => false,
Some(v) if v.eq_ignore_ascii_case("none") => false,
Some(v) if v.eq_ignore_ascii_case("sha256") => true,
Some(other) => {
return Err(MiniAppError::UploadNotConfigured(format!(
"{ENV_CHECKSUM} must be none/sha256, got '{other}'"
)));
}
};
let endpoint = endpoint.unwrap();
let region = get(ENV_REGION).or_else(|| derive_region_from_endpoint(&endpoint));
Ok(S3UploadConfig {
endpoint,
bucket: bucket.unwrap(),
access_key_id: access_key_id.unwrap(),
secret_access_key: secret_access_key.unwrap(),
prefix: get(ENV_PREFIX).unwrap_or_else(|| DEFAULT_PREFIX.to_string()),
region,
virtual_hosted_style,
checksum_sha256,
})
}
pub fn from_env() -> Result<Self, MiniAppError> {
let vars: HashMap<String, String> = std::env::vars().collect();
Self::from_vars(&vars)
}
pub fn key_for(&self, file_name: &str) -> String {
let trimmed = self.prefix.trim_end_matches('/');
if trimmed.is_empty() {
file_name.to_string()
} else {
format!("{}/{}", trimmed, file_name)
}
}
}
pub const fn upload_feature_enabled() -> bool {
cfg!(feature = "s3-upload")
}
#[cfg(feature = "s3-upload")]
pub async fn upload_snapshot(
config: &S3UploadConfig,
local_path: &std::path::Path,
key: &str,
) -> Result<u64, MiniAppError> {
use object_store::ObjectStore;
use object_store::aws::AmazonS3Builder;
let bytes = tokio::fs::read(local_path)
.await
.map_err(|e| MiniAppError::Upload(format!("cannot read snapshot file: {e}")))?;
let len = bytes.len() as u64;
let mut builder = AmazonS3Builder::new()
.with_endpoint(&config.endpoint)
.with_bucket_name(&config.bucket)
.with_access_key_id(&config.access_key_id)
.with_secret_access_key(&config.secret_access_key)
.with_region(config.region.as_deref().unwrap_or(DEFAULT_REGION))
.with_virtual_hosted_style_request(config.virtual_hosted_style);
if config.checksum_sha256 {
builder = builder.with_checksum_algorithm(object_store::aws::Checksum::SHA256);
}
if config.endpoint.starts_with("http://") {
builder = builder.with_allow_http(true);
}
let store = builder
.build()
.map_err(|e| MiniAppError::Upload(format!("cannot build s3 client: {e}")))?;
let object_path = object_store::path::Path::from(key);
store
.put(&object_path, bytes::Bytes::from(bytes).into())
.await
.map_err(|e| MiniAppError::Upload(format!("put '{key}' failed: {e}")))?;
Ok(len)
}
#[cfg(test)]
mod tests {
use super::*;
fn full_vars() -> HashMap<String, String> {
HashMap::from([
(
ENV_ENDPOINT.to_string(),
"https://s3.example.com".to_string(),
),
(ENV_BUCKET.to_string(), "my-bucket".to_string()),
(ENV_ACCESS_KEY_ID.to_string(), "AKID".to_string()),
(ENV_SECRET_ACCESS_KEY.to_string(), "SECRET".to_string()),
])
}
#[test]
fn from_vars_resolves_with_defaults() {
let config = S3UploadConfig::from_vars(&full_vars()).expect("must resolve");
assert_eq!(config.endpoint, "https://s3.example.com");
assert_eq!(config.bucket, "my-bucket");
assert_eq!(config.prefix, DEFAULT_PREFIX);
assert_eq!(config.region, None);
assert!(!config.virtual_hosted_style, "default must be path style");
assert!(!config.checksum_sha256, "default must send no checksum");
}
#[test]
fn from_vars_checksum_parse() {
for (raw, expected) in [
("none", false),
("NONE", false),
("sha256", true),
("SHA256", true),
] {
let mut vars = full_vars();
vars.insert(ENV_CHECKSUM.to_string(), raw.to_string());
let config = S3UploadConfig::from_vars(&vars).expect("must resolve");
assert_eq!(config.checksum_sha256, expected, "raw value '{raw}'");
}
let mut vars = full_vars();
vars.insert(ENV_CHECKSUM.to_string(), "crc32".to_string());
let err = S3UploadConfig::from_vars(&vars).expect_err("unsupported algo must fail");
let MiniAppError::UploadNotConfigured(msg) = &err else {
panic!("expected UploadNotConfigured, got {err:?}");
};
assert!(
msg.contains(ENV_CHECKSUM),
"message must name the offending var: {msg}"
);
}
#[test]
fn from_vars_virtual_hosted_style_parse() {
for (raw, expected) in [
("true", true),
("TRUE", true),
("1", true),
("false", false),
("0", false),
] {
let mut vars = full_vars();
vars.insert(ENV_VIRTUAL_HOSTED_STYLE.to_string(), raw.to_string());
let config = S3UploadConfig::from_vars(&vars).expect("must resolve");
assert_eq!(config.virtual_hosted_style, expected, "raw value '{raw}'");
}
let mut vars = full_vars();
vars.insert(ENV_VIRTUAL_HOSTED_STYLE.to_string(), "maybe".to_string());
let err = S3UploadConfig::from_vars(&vars).expect_err("junk value must fail");
let MiniAppError::UploadNotConfigured(msg) = &err else {
panic!("expected UploadNotConfigured, got {err:?}");
};
assert!(
msg.contains(ENV_VIRTUAL_HOSTED_STYLE),
"message must name the offending var: {msg}"
);
}
#[test]
fn from_vars_resolves_optionals() {
let mut vars = full_vars();
vars.insert(ENV_PREFIX.to_string(), "backups/mini".to_string());
vars.insert(ENV_REGION.to_string(), "us-west-004".to_string());
let config = S3UploadConfig::from_vars(&vars).expect("must resolve");
assert_eq!(config.prefix, "backups/mini");
assert_eq!(config.region.as_deref(), Some("us-west-004"));
}
#[test]
fn from_vars_empty_reports_all_missing() {
let err = S3UploadConfig::from_vars(&HashMap::new()).expect_err("must fail");
let MiniAppError::UploadNotConfigured(msg) = &err else {
panic!("expected UploadNotConfigured, got {err:?}");
};
for var in [
ENV_ENDPOINT,
ENV_BUCKET,
ENV_ACCESS_KEY_ID,
ENV_SECRET_ACCESS_KEY,
] {
assert!(msg.contains(var), "message must name '{var}': {msg}");
}
assert_eq!(err.code(), crate::error::codes::UPLOAD_NOT_CONFIGURED);
}
#[test]
fn from_vars_empty_string_counts_as_missing() {
let mut vars = full_vars();
vars.insert(ENV_BUCKET.to_string(), " ".to_string());
let err = S3UploadConfig::from_vars(&vars).expect_err("must fail");
let MiniAppError::UploadNotConfigured(msg) = &err else {
panic!("expected UploadNotConfigured, got {err:?}");
};
assert!(msg.contains(ENV_BUCKET), "message must name bucket: {msg}");
assert!(
!msg.contains(ENV_ENDPOINT),
"endpoint was provided and must not be listed: {msg}"
);
}
#[test]
fn from_vars_region_derived_from_endpoint() {
let mut vars = full_vars();
vars.insert(
ENV_ENDPOINT.to_string(),
"https://s3.us-east-005.backblazeb2.com".to_string(),
);
let config = S3UploadConfig::from_vars(&vars).expect("must resolve");
assert_eq!(config.region.as_deref(), Some("us-east-005"));
vars.insert(ENV_REGION.to_string(), "eu-central-003".to_string());
let config = S3UploadConfig::from_vars(&vars).expect("must resolve");
assert_eq!(config.region.as_deref(), Some("eu-central-003"));
}
#[test]
fn derive_region_shapes() {
for (endpoint, expected) in [
(
"https://s3.us-east-005.backblazeb2.com",
Some("us-east-005"),
),
("https://s3.us-west-2.amazonaws.com", Some("us-west-2")),
(
"https://s3.us-west-2.amazonaws.com/extra/path",
Some("us-west-2"),
),
("https://s3.amazonaws.com", None),
("http://localhost:9000", None),
("https://account.r2.cloudflarestorage.com", None),
] {
assert_eq!(
derive_region_from_endpoint(endpoint).as_deref(),
expected,
"endpoint '{endpoint}'"
);
}
}
#[test]
fn key_for_prefix_join() {
let mut config = S3UploadConfig::from_vars(&full_vars()).expect("must resolve");
config.prefix = "snaps/".to_string();
assert_eq!(config.key_for("issue.100.db"), "snaps/issue.100.db");
config.prefix = "snaps".to_string();
assert_eq!(config.key_for("issue.100.db"), "snaps/issue.100.db");
config.prefix = String::new();
assert_eq!(config.key_for("issue.100.db"), "issue.100.db");
}
}