use crate::error::{Error, Result};
use std::path::Path;
use std::time::Duration;
use url::Url;
const DEFAULT_MAX_FILE_SIZE: u64 = 100 * 1024 * 1024;
const DEFAULT_DOWNLOAD_TIMEOUT_SECS: u64 = 30;
pub(crate) const DEFAULT_MAX_BUFFERED_BYTES: u64 = 8 * 1024 * 1024;
#[derive(Clone, Debug)]
pub struct UploaderConfig {
pub endpoint: String,
pub region: String,
pub bucket: String,
pub public_base_url: String,
pub key_prefix: Option<String>,
pub access_key_id: String,
pub secret_access_key: String,
pub download_timeout: Duration,
pub max_file_size: u64,
pub max_buffered_bytes: u64,
}
#[derive(Clone, Debug)]
pub struct SecretFileNames {
pub endpoint: String,
pub region: Option<String>,
pub bucket: String,
pub public_url: String,
pub key_prefix: Option<String>,
pub access_key_id: String,
pub secret_access_key: String,
}
impl Default for SecretFileNames {
fn default() -> Self {
Self {
endpoint: "endpoint".into(),
region: Some("region".into()),
bucket: "bucket".into(),
public_url: "public_url".into(),
key_prefix: Some("key_prefix".into()),
access_key_id: "access_key_id".into(),
secret_access_key: "secret_access_key".into(),
}
}
}
impl SecretFileNames {
pub fn with_prefix(prefix: &str) -> Self {
fn name(prefix: &str, value: &str) -> String {
format!("{}{}", prefix, value)
}
Self {
endpoint: name(prefix, "endpoint"),
region: Some(name(prefix, "region")),
bucket: name(prefix, "bucket"),
public_url: name(prefix, "public_url"),
key_prefix: Some(name(prefix, "key_prefix")),
access_key_id: name(prefix, "access_key_id"),
secret_access_key: name(prefix, "secret_access_key"),
}
}
pub fn with_suffix(suffix: &str) -> Self {
fn name(value: &str, suffix: &str) -> String {
format!("{}{}", value, suffix)
}
Self {
endpoint: name("endpoint", suffix),
region: Some(name("region", suffix)),
bucket: name("bucket", suffix),
public_url: name("public_url", suffix),
key_prefix: Some(name("key_prefix", suffix)),
access_key_id: name("access_key_id", suffix),
secret_access_key: name("secret_access_key", suffix),
}
}
}
#[derive(Clone, Debug)]
pub struct SecretFileNamesBuilder {
endpoint: Option<String>,
region: Option<String>,
bucket: Option<String>,
public_url: Option<String>,
key_prefix: Option<String>,
access_key_id: Option<String>,
secret_access_key: Option<String>,
}
impl Default for SecretFileNamesBuilder {
fn default() -> Self {
let defaults = SecretFileNames::default();
Self {
endpoint: Some(defaults.endpoint),
region: defaults.region,
bucket: Some(defaults.bucket),
public_url: Some(defaults.public_url),
key_prefix: defaults.key_prefix,
access_key_id: Some(defaults.access_key_id),
secret_access_key: Some(defaults.secret_access_key),
}
}
}
impl SecretFileNamesBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn empty() -> Self {
Self {
endpoint: None,
region: None,
bucket: None,
public_url: None,
key_prefix: None,
access_key_id: None,
secret_access_key: None,
}
}
pub fn from_prefix(prefix: &str) -> Self {
let names = SecretFileNames::with_prefix(prefix);
Self {
endpoint: Some(names.endpoint),
region: names.region,
bucket: Some(names.bucket),
public_url: Some(names.public_url),
key_prefix: names.key_prefix,
access_key_id: Some(names.access_key_id),
secret_access_key: Some(names.secret_access_key),
}
}
pub fn from_suffix(suffix: &str) -> Self {
let names = SecretFileNames::with_suffix(suffix);
Self {
endpoint: Some(names.endpoint),
region: names.region,
bucket: Some(names.bucket),
public_url: Some(names.public_url),
key_prefix: names.key_prefix,
access_key_id: Some(names.access_key_id),
secret_access_key: Some(names.secret_access_key),
}
}
pub fn endpoint(mut self, value: impl Into<String>) -> Self {
self.endpoint = Some(value.into());
self
}
pub fn region(mut self, value: Option<impl Into<String>>) -> Self {
self.region = value.map(Into::into);
self
}
pub fn bucket(mut self, value: impl Into<String>) -> Self {
self.bucket = Some(value.into());
self
}
pub fn public_url(mut self, value: impl Into<String>) -> Self {
self.public_url = Some(value.into());
self
}
pub fn key_prefix(mut self, value: Option<impl Into<String>>) -> Self {
self.key_prefix = value.map(Into::into);
self
}
pub fn access_key_id(mut self, value: impl Into<String>) -> Self {
self.access_key_id = Some(value.into());
self
}
pub fn secret_access_key(mut self, value: impl Into<String>) -> Self {
self.secret_access_key = Some(value.into());
self
}
pub fn with_prefix(mut self, prefix: &str) -> Self {
let names = SecretFileNames::with_prefix(prefix);
self.endpoint = Some(names.endpoint);
self.region = names.region;
self.bucket = Some(names.bucket);
self.public_url = Some(names.public_url);
self.key_prefix = names.key_prefix;
self.access_key_id = Some(names.access_key_id);
self.secret_access_key = Some(names.secret_access_key);
self
}
pub fn with_suffix(mut self, suffix: &str) -> Self {
let names = SecretFileNames::with_suffix(suffix);
self.endpoint = Some(names.endpoint);
self.region = names.region;
self.bucket = Some(names.bucket);
self.public_url = Some(names.public_url);
self.key_prefix = names.key_prefix;
self.access_key_id = Some(names.access_key_id);
self.secret_access_key = Some(names.secret_access_key);
self
}
pub fn merge_defaults(mut self, names: SecretFileNames) -> Self {
if self.endpoint.is_none() {
self.endpoint = Some(names.endpoint);
}
if self.region.is_none() {
self.region = names.region;
}
if self.bucket.is_none() {
self.bucket = Some(names.bucket);
}
if self.public_url.is_none() {
self.public_url = Some(names.public_url);
}
if self.key_prefix.is_none() {
self.key_prefix = names.key_prefix;
}
if self.access_key_id.is_none() {
self.access_key_id = Some(names.access_key_id);
}
if self.secret_access_key.is_none() {
self.secret_access_key = Some(names.secret_access_key);
}
self
}
pub fn build(self) -> Result<SecretFileNames> {
Ok(SecretFileNames {
endpoint: self.endpoint.ok_or_else(|| Error::Config {
message: "endpoint filename is required".into(),
})?,
region: self.region,
bucket: self.bucket.ok_or_else(|| Error::Config {
message: "bucket filename is required".into(),
})?,
public_url: self.public_url.ok_or_else(|| Error::Config {
message: "public_url filename is required".into(),
})?,
key_prefix: self.key_prefix,
access_key_id: self.access_key_id.ok_or_else(|| Error::Config {
message: "access_key_id filename is required".into(),
})?,
secret_access_key: self.secret_access_key.ok_or_else(|| Error::Config {
message: "secret_access_key filename is required".into(),
})?,
})
}
}
impl UploaderConfig {
pub fn builder() -> UploaderConfigBuilder {
UploaderConfigBuilder::default()
}
pub(crate) fn validate(&self) -> Result<()> {
if self.endpoint.is_empty() {
return Err(Error::Config {
message: "endpoint is required".into(),
});
}
Url::parse(&self.endpoint).map_err(|e| Error::Config {
message: format!("invalid endpoint URL: {}", e),
})?;
if self.bucket.is_empty() {
return Err(Error::Config {
message: "bucket is required".into(),
});
}
if self.public_base_url.is_empty() {
return Err(Error::Config {
message: "public_base_url is required".into(),
});
}
Url::parse(&self.public_base_url).map_err(|e| Error::Config {
message: format!("invalid public_base_url: {}", e),
})?;
if self.access_key_id.is_empty() {
return Err(Error::Config {
message: "access_key_id is required".into(),
});
}
if self.secret_access_key.is_empty() {
return Err(Error::Config {
message: "secret_access_key is required".into(),
});
}
if self.max_buffered_bytes == 0 {
return Err(Error::Config {
message: "max_buffered_bytes must be greater than zero".into(),
});
}
if self.max_buffered_bytes > self.max_file_size {
return Err(Error::Config {
message: "max_buffered_bytes must not exceed max_file_size".into(),
});
}
Ok(())
}
pub fn from_env() -> Result<Self> {
fn get_env(primary: &str, fallback: &str) -> Option<String> {
std::env::var(primary)
.ok()
.or_else(|| std::env::var(fallback).ok())
}
fn require_env(primary: &str, fallback: &str) -> Result<String> {
get_env(primary, fallback).ok_or_else(|| Error::Config {
message: format!("missing environment variable {} or {}", primary, fallback),
})
}
let config = UploaderConfig {
endpoint: require_env("GARAGE_ENDPOINT", "S3_ENDPOINT")?,
region: get_env("GARAGE_REGION", "S3_REGION").unwrap_or_else(|| "garage".to_string()),
bucket: require_env("GARAGE_BUCKET", "S3_BUCKET")?,
public_base_url: require_env("GARAGE_PUBLIC_URL", "S3_PUBLIC_URL")?,
key_prefix: get_env("GARAGE_KEY_PREFIX", "S3_KEY_PREFIX"),
access_key_id: std::env::var("AWS_ACCESS_KEY_ID").map_err(|_| Error::Config {
message: "missing environment variable AWS_ACCESS_KEY_ID".into(),
})?,
secret_access_key: std::env::var("AWS_SECRET_ACCESS_KEY").map_err(|_| {
Error::Config {
message: "missing environment variable AWS_SECRET_ACCESS_KEY".into(),
}
})?,
download_timeout: Duration::from_secs(DEFAULT_DOWNLOAD_TIMEOUT_SECS),
max_file_size: DEFAULT_MAX_FILE_SIZE,
max_buffered_bytes: DEFAULT_MAX_BUFFERED_BYTES,
};
config.validate()?;
Ok(config)
}
pub fn from_secret_dir(path: impl AsRef<Path>) -> Result<Self> {
Self::from_secret_dir_with_names(path, &SecretFileNames::default())
}
pub fn from_secret_dir_with_names(
path: impl AsRef<Path>,
names: &SecretFileNames,
) -> Result<Self> {
let path = path.as_ref();
fn read_required(path: &Path, name: &str) -> Result<String> {
let value = std::fs::read_to_string(path.join(name)).map_err(|e| Error::Config {
message: format!("failed to read secret file {}: {}", name, e),
})?;
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(Error::Config {
message: format!("secret file {} is empty", name),
});
}
Ok(trimmed.to_string())
}
fn read_optional(path: &Path, name: &str) -> Option<String> {
std::fs::read_to_string(path.join(name))
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
let config = UploaderConfig {
endpoint: read_required(path, &names.endpoint)?,
region: names
.region
.as_deref()
.and_then(|name| read_optional(path, name))
.unwrap_or_else(|| "garage".to_string()),
bucket: read_required(path, &names.bucket)?,
public_base_url: read_required(path, &names.public_url)?,
key_prefix: names
.key_prefix
.as_deref()
.and_then(|name| read_optional(path, name)),
access_key_id: read_required(path, &names.access_key_id)?,
secret_access_key: read_required(path, &names.secret_access_key)?,
download_timeout: Duration::from_secs(DEFAULT_DOWNLOAD_TIMEOUT_SECS),
max_file_size: DEFAULT_MAX_FILE_SIZE,
max_buffered_bytes: DEFAULT_MAX_BUFFERED_BYTES,
};
config.validate()?;
Ok(config)
}
pub fn from_env_or_secret_dir(path: impl AsRef<Path>) -> Result<Self> {
match Self::from_env() {
Ok(config) => Ok(config),
Err(_) => Self::from_secret_dir(path),
}
}
}
#[derive(Default)]
pub struct UploaderConfigBuilder {
endpoint: Option<String>,
region: Option<String>,
bucket: Option<String>,
public_base_url: Option<String>,
key_prefix: Option<String>,
access_key_id: Option<String>,
secret_access_key: Option<String>,
download_timeout: Option<Duration>,
max_file_size: Option<u64>,
max_buffered_bytes: Option<u64>,
}
impl UploaderConfigBuilder {
pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = Some(endpoint.into());
self
}
pub fn region(mut self, region: impl Into<String>) -> Self {
self.region = Some(region.into());
self
}
pub fn bucket(mut self, bucket: impl Into<String>) -> Self {
self.bucket = Some(bucket.into());
self
}
pub fn public_base_url(mut self, url: impl Into<String>) -> Self {
self.public_base_url = Some(url.into());
self
}
pub fn key_prefix(mut self, prefix: impl Into<String>) -> Self {
self.key_prefix = Some(prefix.into());
self
}
pub fn credentials(
mut self,
access_key_id: impl Into<String>,
secret_access_key: impl Into<String>,
) -> Self {
self.access_key_id = Some(access_key_id.into());
self.secret_access_key = Some(secret_access_key.into());
self
}
pub fn download_timeout(mut self, timeout: Duration) -> Self {
self.download_timeout = Some(timeout);
self
}
pub fn max_file_size(mut self, size: u64) -> Self {
self.max_file_size = Some(size);
self
}
pub fn max_buffered_bytes(mut self, size: u64) -> Self {
self.max_buffered_bytes = Some(size);
self
}
pub fn build(self) -> Result<UploaderConfig> {
let config = UploaderConfig {
endpoint: self.endpoint.ok_or_else(|| Error::Config {
message: "endpoint is required".into(),
})?,
region: self.region.unwrap_or_else(|| "garage".to_string()),
bucket: self.bucket.ok_or_else(|| Error::Config {
message: "bucket is required".into(),
})?,
public_base_url: self.public_base_url.ok_or_else(|| Error::Config {
message: "public_base_url is required".into(),
})?,
key_prefix: self.key_prefix,
access_key_id: self.access_key_id.ok_or_else(|| Error::Config {
message: "credentials are required".into(),
})?,
secret_access_key: self.secret_access_key.ok_or_else(|| Error::Config {
message: "credentials are required".into(),
})?,
download_timeout: self
.download_timeout
.unwrap_or(Duration::from_secs(DEFAULT_DOWNLOAD_TIMEOUT_SECS)),
max_file_size: self.max_file_size.unwrap_or(DEFAULT_MAX_FILE_SIZE),
max_buffered_bytes: self
.max_buffered_bytes
.unwrap_or(DEFAULT_MAX_BUFFERED_BYTES),
};
config.validate()?;
Ok(config)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_builder_validates_required_fields() {
let result = UploaderConfigBuilder::default().build();
assert!(result.is_err());
let result = UploaderConfigBuilder::default()
.endpoint("https://s3.example.com")
.build();
assert!(result.is_err());
}
#[test]
fn test_config_builder_creates_valid_config() {
let config = UploaderConfigBuilder::default()
.endpoint("https://s3.example.com")
.bucket("test-bucket")
.public_base_url("https://cdn.example.com")
.credentials("access_key", "secret_key")
.key_prefix("uploads")
.build()
.expect("expected valid config");
assert_eq!(config.endpoint, "https://s3.example.com");
assert_eq!(config.bucket, "test-bucket");
assert_eq!(config.region, "garage");
assert_eq!(config.key_prefix, Some("uploads".to_string()));
}
#[test]
fn test_config_validates_urls() {
let result = UploaderConfigBuilder::default()
.endpoint("not-a-url")
.bucket("test")
.public_base_url("https://cdn.example.com")
.credentials("key", "secret")
.build();
assert!(result.is_err());
}
#[test]
fn test_config_from_secret_dir_reads_required_fields() {
let dir = std::env::temp_dir().join(format!("garage-sdk-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("expected test dir to be created");
std::fs::write(dir.join("endpoint"), "https://s3.example.com")
.expect("expected endpoint file to be written");
std::fs::write(dir.join("bucket"), "test-bucket").expect("expected bucket file");
std::fs::write(dir.join("public_url"), "https://cdn.example.com")
.expect("expected public url file");
std::fs::write(dir.join("access_key_id"), "access_key").expect("expected access key file");
std::fs::write(dir.join("secret_access_key"), "secret_key")
.expect("expected secret key file");
let config = UploaderConfig::from_secret_dir(&dir).expect("expected config to load");
assert_eq!(config.endpoint, "https://s3.example.com");
assert_eq!(config.bucket, "test-bucket");
assert_eq!(config.public_base_url, "https://cdn.example.com");
std::fs::remove_dir_all(&dir).expect("expected test dir cleanup");
}
#[test]
fn test_config_from_secret_dir_with_names() {
let dir = std::env::temp_dir().join(format!("garage-sdk-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("expected test dir to be created");
std::fs::write(dir.join("s3_endpoint"), "https://s3.example.com")
.expect("expected endpoint file to be written");
std::fs::write(dir.join("s3_bucket"), "test-bucket").expect("expected bucket file");
std::fs::write(dir.join("s3_public_url"), "https://cdn.example.com")
.expect("expected public url file");
std::fs::write(dir.join("s3_access_key_id"), "access_key")
.expect("expected access key file");
std::fs::write(dir.join("s3_secret_access_key"), "secret_key")
.expect("expected secret key file");
let names = SecretFileNames {
endpoint: "s3_endpoint".into(),
region: None,
bucket: "s3_bucket".into(),
public_url: "s3_public_url".into(),
key_prefix: None,
access_key_id: "s3_access_key_id".into(),
secret_access_key: "s3_secret_access_key".into(),
};
let config =
UploaderConfig::from_secret_dir_with_names(&dir, &names).expect("expected config");
assert_eq!(config.endpoint, "https://s3.example.com");
assert_eq!(config.bucket, "test-bucket");
assert_eq!(config.public_base_url, "https://cdn.example.com");
std::fs::remove_dir_all(&dir).expect("expected test dir cleanup");
}
#[test]
fn test_secret_file_names_with_prefix() {
let names = SecretFileNames::with_prefix("s3_");
assert_eq!(names.endpoint, "s3_endpoint");
assert_eq!(names.bucket, "s3_bucket");
assert_eq!(names.public_url, "s3_public_url");
assert_eq!(names.access_key_id, "s3_access_key_id");
assert_eq!(names.secret_access_key, "s3_secret_access_key");
assert_eq!(names.region, Some("s3_region".into()));
assert_eq!(names.key_prefix, Some("s3_key_prefix".into()));
}
#[test]
fn test_secret_file_names_with_suffix() {
let names = SecretFileNames::with_suffix("_secret");
assert_eq!(names.endpoint, "endpoint_secret");
assert_eq!(names.bucket, "bucket_secret");
assert_eq!(names.public_url, "public_url_secret");
assert_eq!(names.access_key_id, "access_key_id_secret");
assert_eq!(names.secret_access_key, "secret_access_key_secret");
assert_eq!(names.region, Some("region_secret".into()));
assert_eq!(names.key_prefix, Some("key_prefix_secret".into()));
}
#[test]
fn test_secret_file_names_builder_customizes() {
let names = SecretFileNamesBuilder::new()
.endpoint("s3_endpoint")
.bucket("s3_bucket")
.public_url("s3_public_url")
.access_key_id("s3_access_key_id")
.secret_access_key("s3_secret_access_key")
.region(None::<String>)
.key_prefix(None::<String>)
.build()
.expect("expected builder to succeed");
assert_eq!(names.endpoint, "s3_endpoint");
assert_eq!(names.bucket, "s3_bucket");
assert_eq!(names.public_url, "s3_public_url");
assert_eq!(names.access_key_id, "s3_access_key_id");
assert_eq!(names.secret_access_key, "s3_secret_access_key");
assert_eq!(names.region, None);
assert_eq!(names.key_prefix, None);
}
#[test]
fn test_secret_file_names_builder_from_prefix() {
let names = SecretFileNamesBuilder::from_prefix("s3_")
.region(None::<String>)
.key_prefix(None::<String>)
.build()
.expect("expected builder to succeed");
assert_eq!(names.endpoint, "s3_endpoint");
assert_eq!(names.bucket, "s3_bucket");
assert_eq!(names.public_url, "s3_public_url");
assert_eq!(names.access_key_id, "s3_access_key_id");
assert_eq!(names.secret_access_key, "s3_secret_access_key");
assert_eq!(names.region, None);
assert_eq!(names.key_prefix, None);
}
#[test]
fn test_secret_file_names_builder_with_suffix() {
let names = SecretFileNamesBuilder::new()
.with_suffix("_secret")
.region(None::<String>)
.key_prefix(None::<String>)
.build()
.expect("expected builder to succeed");
assert_eq!(names.endpoint, "endpoint_secret");
assert_eq!(names.bucket, "bucket_secret");
assert_eq!(names.public_url, "public_url_secret");
assert_eq!(names.access_key_id, "access_key_id_secret");
assert_eq!(names.secret_access_key, "secret_access_key_secret");
assert_eq!(names.region, None);
assert_eq!(names.key_prefix, None);
}
#[test]
fn test_secret_file_names_builder_merge_defaults() {
let names = SecretFileNamesBuilder::empty()
.endpoint("custom_endpoint")
.merge_defaults(SecretFileNames::with_prefix("s3_"))
.build()
.expect("expected builder to succeed");
assert_eq!(names.endpoint, "custom_endpoint");
assert_eq!(names.bucket, "s3_bucket");
assert_eq!(names.public_url, "s3_public_url");
assert_eq!(names.access_key_id, "s3_access_key_id");
assert_eq!(names.secret_access_key, "s3_secret_access_key");
}
}