1use std::collections::HashMap;
45
46use crate::error::MiniAppError;
47
48pub const ENV_ENDPOINT: &str = "MINI_APP_S3_ENDPOINT";
50pub const ENV_BUCKET: &str = "MINI_APP_S3_BUCKET";
52pub const ENV_ACCESS_KEY_ID: &str = "MINI_APP_S3_ACCESS_KEY_ID";
54pub const ENV_SECRET_ACCESS_KEY: &str = "MINI_APP_S3_SECRET_ACCESS_KEY";
56pub const ENV_PREFIX: &str = "MINI_APP_S3_PREFIX";
58pub const ENV_REGION: &str = "MINI_APP_S3_REGION";
60pub const ENV_VIRTUAL_HOSTED_STYLE: &str = "MINI_APP_S3_VIRTUAL_HOSTED_STYLE";
62pub const ENV_CHECKSUM: &str = "MINI_APP_S3_CHECKSUM";
64
65pub const DEFAULT_PREFIX: &str = "mini-app-snapshots/";
67
68pub const DEFAULT_REGION: &str = "us-east-1";
77
78pub fn derive_region_from_endpoint(endpoint: &str) -> Option<String> {
86 let host = endpoint
87 .trim_start_matches("https://")
88 .trim_start_matches("http://")
89 .split(['/', ':'])
90 .next()?;
91 let labels: Vec<&str> = host.split('.').collect();
92 if labels.len() >= 4 && labels[0] == "s3" && !labels[1].is_empty() {
95 Some(labels[1].to_string())
96 } else {
97 None
98 }
99}
100
101#[derive(Debug, Clone)]
103pub struct S3UploadConfig {
104 pub endpoint: String,
106 pub bucket: String,
108 pub access_key_id: String,
110 pub secret_access_key: String,
112 pub prefix: String,
114 pub region: Option<String>,
116 pub virtual_hosted_style: bool,
120 pub checksum_sha256: bool,
125}
126
127impl S3UploadConfig {
128 pub fn from_vars(vars: &HashMap<String, String>) -> Result<Self, MiniAppError> {
140 let get = |key: &str| -> Option<String> {
141 vars.get(key)
142 .map(|v| v.trim().to_string())
143 .filter(|v| !v.is_empty())
144 };
145
146 let mut missing: Vec<&str> = Vec::new();
147 let endpoint = get(ENV_ENDPOINT);
148 if endpoint.is_none() {
149 missing.push(ENV_ENDPOINT);
150 }
151 let bucket = get(ENV_BUCKET);
152 if bucket.is_none() {
153 missing.push(ENV_BUCKET);
154 }
155 let access_key_id = get(ENV_ACCESS_KEY_ID);
156 if access_key_id.is_none() {
157 missing.push(ENV_ACCESS_KEY_ID);
158 }
159 let secret_access_key = get(ENV_SECRET_ACCESS_KEY);
160 if secret_access_key.is_none() {
161 missing.push(ENV_SECRET_ACCESS_KEY);
162 }
163
164 if !missing.is_empty() {
165 return Err(MiniAppError::UploadNotConfigured(format!(
166 "missing env: {}",
167 missing.join(", ")
168 )));
169 }
170
171 let virtual_hosted_style = match get(ENV_VIRTUAL_HOSTED_STYLE).as_deref() {
172 None => false,
173 Some(v) if v.eq_ignore_ascii_case("true") || v == "1" => true,
174 Some(v) if v.eq_ignore_ascii_case("false") || v == "0" => false,
175 Some(other) => {
176 return Err(MiniAppError::UploadNotConfigured(format!(
177 "{ENV_VIRTUAL_HOSTED_STYLE} must be true/false, got '{other}'"
178 )));
179 }
180 };
181
182 let checksum_sha256 = match get(ENV_CHECKSUM).as_deref() {
183 None => false,
184 Some(v) if v.eq_ignore_ascii_case("none") => false,
185 Some(v) if v.eq_ignore_ascii_case("sha256") => true,
186 Some(other) => {
187 return Err(MiniAppError::UploadNotConfigured(format!(
188 "{ENV_CHECKSUM} must be none/sha256, got '{other}'"
189 )));
190 }
191 };
192
193 let endpoint = endpoint.unwrap();
196 let region = get(ENV_REGION).or_else(|| derive_region_from_endpoint(&endpoint));
199 Ok(S3UploadConfig {
200 endpoint,
201 bucket: bucket.unwrap(),
202 access_key_id: access_key_id.unwrap(),
203 secret_access_key: secret_access_key.unwrap(),
204 prefix: get(ENV_PREFIX).unwrap_or_else(|| DEFAULT_PREFIX.to_string()),
205 region,
206 virtual_hosted_style,
207 checksum_sha256,
208 })
209 }
210
211 pub fn from_env() -> Result<Self, MiniAppError> {
217 let vars: HashMap<String, String> = std::env::vars().collect();
218 Self::from_vars(&vars)
219 }
220
221 pub fn key_for(&self, file_name: &str) -> String {
224 let trimmed = self.prefix.trim_end_matches('/');
225 if trimmed.is_empty() {
226 file_name.to_string()
227 } else {
228 format!("{}/{}", trimmed, file_name)
229 }
230 }
231}
232
233pub const fn upload_feature_enabled() -> bool {
238 cfg!(feature = "s3-upload")
239}
240
241#[cfg(feature = "s3-upload")]
252pub async fn upload_snapshot(
253 config: &S3UploadConfig,
254 local_path: &std::path::Path,
255 key: &str,
256) -> Result<u64, MiniAppError> {
257 use object_store::ObjectStore;
258 use object_store::aws::AmazonS3Builder;
259
260 let bytes = tokio::fs::read(local_path)
261 .await
262 .map_err(|e| MiniAppError::Upload(format!("cannot read snapshot file: {e}")))?;
263 let len = bytes.len() as u64;
264
265 let mut builder = AmazonS3Builder::new()
266 .with_endpoint(&config.endpoint)
267 .with_bucket_name(&config.bucket)
268 .with_access_key_id(&config.access_key_id)
269 .with_secret_access_key(&config.secret_access_key)
270 .with_region(config.region.as_deref().unwrap_or(DEFAULT_REGION))
273 .with_virtual_hosted_style_request(config.virtual_hosted_style);
274 if config.checksum_sha256 {
275 builder = builder.with_checksum_algorithm(object_store::aws::Checksum::SHA256);
276 }
277 if config.endpoint.starts_with("http://") {
279 builder = builder.with_allow_http(true);
280 }
281 let store = builder
282 .build()
283 .map_err(|e| MiniAppError::Upload(format!("cannot build s3 client: {e}")))?;
284
285 let object_path = object_store::path::Path::from(key);
286 store
287 .put(&object_path, bytes::Bytes::from(bytes).into())
288 .await
289 .map_err(|e| MiniAppError::Upload(format!("put '{key}' failed: {e}")))?;
290
291 Ok(len)
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 fn full_vars() -> HashMap<String, String> {
299 HashMap::from([
300 (
301 ENV_ENDPOINT.to_string(),
302 "https://s3.example.com".to_string(),
303 ),
304 (ENV_BUCKET.to_string(), "my-bucket".to_string()),
305 (ENV_ACCESS_KEY_ID.to_string(), "AKID".to_string()),
306 (ENV_SECRET_ACCESS_KEY.to_string(), "SECRET".to_string()),
307 ])
308 }
309
310 #[test]
312 fn from_vars_resolves_with_defaults() {
313 let config = S3UploadConfig::from_vars(&full_vars()).expect("must resolve");
314 assert_eq!(config.endpoint, "https://s3.example.com");
315 assert_eq!(config.bucket, "my-bucket");
316 assert_eq!(config.prefix, DEFAULT_PREFIX);
317 assert_eq!(config.region, None);
318 assert!(!config.virtual_hosted_style, "default must be path style");
319 assert!(!config.checksum_sha256, "default must send no checksum");
320 }
321
322 #[test]
324 fn from_vars_checksum_parse() {
325 for (raw, expected) in [
326 ("none", false),
327 ("NONE", false),
328 ("sha256", true),
329 ("SHA256", true),
330 ] {
331 let mut vars = full_vars();
332 vars.insert(ENV_CHECKSUM.to_string(), raw.to_string());
333 let config = S3UploadConfig::from_vars(&vars).expect("must resolve");
334 assert_eq!(config.checksum_sha256, expected, "raw value '{raw}'");
335 }
336
337 let mut vars = full_vars();
338 vars.insert(ENV_CHECKSUM.to_string(), "crc32".to_string());
339 let err = S3UploadConfig::from_vars(&vars).expect_err("unsupported algo must fail");
340 let MiniAppError::UploadNotConfigured(msg) = &err else {
341 panic!("expected UploadNotConfigured, got {err:?}");
342 };
343 assert!(
344 msg.contains(ENV_CHECKSUM),
345 "message must name the offending var: {msg}"
346 );
347 }
348
349 #[test]
351 fn from_vars_virtual_hosted_style_parse() {
352 for (raw, expected) in [
353 ("true", true),
354 ("TRUE", true),
355 ("1", true),
356 ("false", false),
357 ("0", false),
358 ] {
359 let mut vars = full_vars();
360 vars.insert(ENV_VIRTUAL_HOSTED_STYLE.to_string(), raw.to_string());
361 let config = S3UploadConfig::from_vars(&vars).expect("must resolve");
362 assert_eq!(config.virtual_hosted_style, expected, "raw value '{raw}'");
363 }
364
365 let mut vars = full_vars();
366 vars.insert(ENV_VIRTUAL_HOSTED_STYLE.to_string(), "maybe".to_string());
367 let err = S3UploadConfig::from_vars(&vars).expect_err("junk value must fail");
368 let MiniAppError::UploadNotConfigured(msg) = &err else {
369 panic!("expected UploadNotConfigured, got {err:?}");
370 };
371 assert!(
372 msg.contains(ENV_VIRTUAL_HOSTED_STYLE),
373 "message must name the offending var: {msg}"
374 );
375 }
376
377 #[test]
379 fn from_vars_resolves_optionals() {
380 let mut vars = full_vars();
381 vars.insert(ENV_PREFIX.to_string(), "backups/mini".to_string());
382 vars.insert(ENV_REGION.to_string(), "us-west-004".to_string());
383 let config = S3UploadConfig::from_vars(&vars).expect("must resolve");
384 assert_eq!(config.prefix, "backups/mini");
385 assert_eq!(config.region.as_deref(), Some("us-west-004"));
386 }
387
388 #[test]
390 fn from_vars_empty_reports_all_missing() {
391 let err = S3UploadConfig::from_vars(&HashMap::new()).expect_err("must fail");
392 let MiniAppError::UploadNotConfigured(msg) = &err else {
393 panic!("expected UploadNotConfigured, got {err:?}");
394 };
395 for var in [
396 ENV_ENDPOINT,
397 ENV_BUCKET,
398 ENV_ACCESS_KEY_ID,
399 ENV_SECRET_ACCESS_KEY,
400 ] {
401 assert!(msg.contains(var), "message must name '{var}': {msg}");
402 }
403 assert_eq!(err.code(), crate::error::codes::UPLOAD_NOT_CONFIGURED);
404 }
405
406 #[test]
408 fn from_vars_empty_string_counts_as_missing() {
409 let mut vars = full_vars();
410 vars.insert(ENV_BUCKET.to_string(), " ".to_string());
411 let err = S3UploadConfig::from_vars(&vars).expect_err("must fail");
412 let MiniAppError::UploadNotConfigured(msg) = &err else {
413 panic!("expected UploadNotConfigured, got {err:?}");
414 };
415 assert!(msg.contains(ENV_BUCKET), "message must name bucket: {msg}");
416 assert!(
417 !msg.contains(ENV_ENDPOINT),
418 "endpoint was provided and must not be listed: {msg}"
419 );
420 }
421
422 #[test]
425 fn from_vars_region_derived_from_endpoint() {
426 let mut vars = full_vars();
427 vars.insert(
428 ENV_ENDPOINT.to_string(),
429 "https://s3.us-east-005.backblazeb2.com".to_string(),
430 );
431 let config = S3UploadConfig::from_vars(&vars).expect("must resolve");
432 assert_eq!(config.region.as_deref(), Some("us-east-005"));
433
434 vars.insert(ENV_REGION.to_string(), "eu-central-003".to_string());
436 let config = S3UploadConfig::from_vars(&vars).expect("must resolve");
437 assert_eq!(config.region.as_deref(), Some("eu-central-003"));
438 }
439
440 #[test]
442 fn derive_region_shapes() {
443 for (endpoint, expected) in [
444 (
445 "https://s3.us-east-005.backblazeb2.com",
446 Some("us-east-005"),
447 ),
448 ("https://s3.us-west-2.amazonaws.com", Some("us-west-2")),
449 (
450 "https://s3.us-west-2.amazonaws.com/extra/path",
451 Some("us-west-2"),
452 ),
453 ("https://s3.amazonaws.com", None),
454 ("http://localhost:9000", None),
455 ("https://account.r2.cloudflarestorage.com", None),
456 ] {
457 assert_eq!(
458 derive_region_from_endpoint(endpoint).as_deref(),
459 expected,
460 "endpoint '{endpoint}'"
461 );
462 }
463 }
464
465 #[test]
467 fn key_for_prefix_join() {
468 let mut config = S3UploadConfig::from_vars(&full_vars()).expect("must resolve");
469
470 config.prefix = "snaps/".to_string();
471 assert_eq!(config.key_for("issue.100.db"), "snaps/issue.100.db");
472
473 config.prefix = "snaps".to_string();
474 assert_eq!(config.key_for("issue.100.db"), "snaps/issue.100.db");
475
476 config.prefix = String::new();
477 assert_eq!(config.key_for("issue.100.db"), "issue.100.db");
478 }
479}