1use std::sync::Arc;
33use std::time::Duration;
34
35use autumn_web::storage::{
36 Blob, BlobFuture, BlobMeta, BlobStore, BlobStoreError, ByteStream, PresignPutResult,
37 StorageS3Config, validate_key,
38};
39use aws_credential_types::Credentials;
40use aws_sdk_s3::Client;
41use aws_sdk_s3::config::{BehaviorVersion, Region};
42use aws_sdk_s3::presigning::PresigningConfig;
43use bytes::Bytes;
44use futures::StreamExt as _;
45use thiserror::Error;
46
47#[derive(Debug, Error)]
49#[non_exhaustive]
50pub enum S3BlobStoreError {
51 #[error("storage.s3.bucket is required but not set")]
53 MissingBucket,
54 #[error("storage.s3.region is required but not set")]
56 MissingRegion,
57 #[error(
61 "partial credential configuration: access_key_id_env and \
62 secret_access_key_env must both be set, or neither"
63 )]
64 PartialCredentialConfig,
65 #[error("credential env var '{var}' is not set in the environment")]
68 MissingCredentialEnvVar {
69 var: String,
71 },
72}
73
74#[derive(Debug, Clone)]
75struct S3Options {
76 provider_id: String,
77 bucket: String,
78}
79
80#[derive(Debug, Clone)]
86pub struct S3BlobStore {
87 client: Client,
88 presign_client: Client,
89 options: Arc<S3Options>,
90}
91
92impl S3BlobStore {
93 pub async fn from_config(cfg: &StorageS3Config) -> Result<Self, S3BlobStoreError> {
113 let bucket = cfg
114 .bucket
115 .as_deref()
116 .filter(|b| !b.trim().is_empty())
117 .ok_or(S3BlobStoreError::MissingBucket)?
118 .to_owned();
119 let region = cfg
120 .region
121 .as_deref()
122 .filter(|r| !r.trim().is_empty())
123 .ok_or(S3BlobStoreError::MissingRegion)?
124 .to_owned();
125
126 if cfg.access_key_id_env.is_some() != cfg.secret_access_key_env.is_some() {
127 return Err(S3BlobStoreError::PartialCredentialConfig);
128 }
129
130 let presign_endpoint = cfg.public_base_url.as_deref().or(cfg.endpoint.as_deref());
131 let (client, presign_client) = if let (Some(key_env), Some(secret_env)) =
132 (&cfg.access_key_id_env, &cfg.secret_access_key_env)
133 {
134 let key =
135 std::env::var(key_env).map_err(|_| S3BlobStoreError::MissingCredentialEnvVar {
136 var: key_env.clone(),
137 })?;
138 let secret = std::env::var(secret_env).map_err(|_| {
139 S3BlobStoreError::MissingCredentialEnvVar {
140 var: secret_env.clone(),
141 }
142 })?;
143 let creds = Credentials::new(key, secret, None, None, "autumn-storage-s3");
144 let mut builder = aws_sdk_s3::Config::builder()
145 .behavior_version(BehaviorVersion::latest())
146 .region(Region::new(region.clone()))
147 .credentials_provider(creds.clone())
148 .force_path_style(cfg.force_path_style);
149 if let Some(endpoint) = &cfg.endpoint {
150 builder = builder.endpoint_url(endpoint);
151 }
152 let client = Client::from_conf(builder.build());
153
154 let mut presign_builder = aws_sdk_s3::Config::builder()
155 .behavior_version(BehaviorVersion::latest())
156 .region(Region::new(region))
157 .credentials_provider(creds)
158 .force_path_style(cfg.force_path_style);
159 if let Some(endpoint) = presign_endpoint {
160 presign_builder = presign_builder.endpoint_url(endpoint);
161 }
162 (client, Client::from_conf(presign_builder.build()))
163 } else {
164 let shared = aws_config::defaults(BehaviorVersion::latest())
165 .region(Region::new(region))
166 .load()
167 .await;
168 let mut builder =
169 aws_sdk_s3::config::Builder::from(&shared).force_path_style(cfg.force_path_style);
170 if let Some(endpoint) = &cfg.endpoint {
171 builder = builder.endpoint_url(endpoint);
172 }
173 let client = Client::from_conf(builder.build());
174
175 let mut presign_builder =
176 aws_sdk_s3::config::Builder::from(&shared).force_path_style(cfg.force_path_style);
177 if let Some(endpoint) = presign_endpoint {
178 presign_builder = presign_builder.endpoint_url(endpoint);
179 }
180 (client, Client::from_conf(presign_builder.build()))
181 };
182
183 Ok(Self {
184 client,
185 presign_client,
186 options: Arc::new(S3Options {
187 provider_id: "s3".to_owned(),
188 bucket,
189 }),
190 })
191 }
192
193 #[must_use]
207 pub fn with_provider_id(mut self, provider_id: impl Into<String>) -> Self {
208 Arc::make_mut(&mut self.options).provider_id = provider_id.into();
209 self
210 }
211}
212
213const MULTIPART_PART_SIZE: usize = 5 * 1024 * 1024;
219
220async fn abort_multipart(client: &Client, bucket: &str, key: &str, upload_id: &str) {
223 let _ = client
224 .abort_multipart_upload()
225 .bucket(bucket)
226 .key(key)
227 .upload_id(upload_id)
228 .send()
229 .await;
230}
231
232fn guard_egress(method: &str) -> Result<(), BlobStoreError> {
237 autumn_web::capsule::guard_egress("s3 blob store", method, "s3 object")
239 .map_err(|refused| BlobStoreError::backend(refused.to_string()))
240}
241
242impl BlobStore for S3BlobStore {
243 fn provider_id(&self) -> &str {
244 &self.options.provider_id
245 }
246
247 fn put<'a>(
248 &'a self,
249 key: &'a str,
250 content_type: &'a str,
251 bytes: Bytes,
252 ) -> BlobFuture<'a, Blob> {
253 let byte_size = bytes.len() as u64;
254 Box::pin(async move {
255 validate_key(key)?;
256 guard_egress("PUT")?;
257 let result = self
258 .client
259 .put_object()
260 .bucket(&self.options.bucket)
261 .key(key)
262 .content_type(content_type)
263 .body(bytes.into())
264 .send()
265 .await
266 .map_err(|e| BlobStoreError::backend(e.to_string()))?;
267 let etag = result.e_tag().map(str::to_owned);
268 let mut blob = Blob::new(&self.options.provider_id, key, content_type, byte_size);
269 if let Some(e) = etag {
270 blob = blob.with_etag(e);
271 }
272 Ok(blob)
273 })
274 }
275
276 fn put_if_absent<'a>(
277 &'a self,
278 key: &'a str,
279 content_type: &'a str,
280 bytes: Bytes,
281 ) -> BlobFuture<'a, Option<Blob>> {
282 let byte_size = bytes.len() as u64;
283 Box::pin(async move {
284 validate_key(key)?;
285 guard_egress("PUT")?;
286 let result = self
290 .client
291 .put_object()
292 .bucket(&self.options.bucket)
293 .key(key)
294 .content_type(content_type)
295 .if_none_match("*")
296 .body(bytes.into())
297 .send()
298 .await;
299 match result {
300 Ok(output) => {
301 let mut blob =
302 Blob::new(&self.options.provider_id, key, content_type, byte_size);
303 if let Some(etag) = output.e_tag() {
304 blob = blob.with_etag(etag.to_owned());
305 }
306 Ok(Some(blob))
307 }
308 Err(e)
309 if e.raw_response()
310 .is_some_and(|r| matches!(r.status().as_u16(), 409 | 412)) =>
311 {
312 Ok(None)
313 }
314 Err(e) => Err(BlobStoreError::backend(e.to_string())),
315 }
316 })
317 }
318
319 #[allow(clippy::too_many_lines)]
320 fn put_stream<'a>(
321 &'a self,
322 key: &'a str,
323 content_type: &'a str,
324 data: ByteStream<'a>,
325 ) -> BlobFuture<'a, Blob> {
326 Box::pin(async move {
327 validate_key(key)?;
328 let mut stream = data;
329 let mut current_part: Vec<u8> = Vec::with_capacity(MULTIPART_PART_SIZE);
330
331 loop {
333 match stream.next().await {
334 Some(Ok(chunk)) => {
335 current_part.extend_from_slice(&chunk);
336 if current_part.len() >= MULTIPART_PART_SIZE {
337 break;
338 }
339 }
340 Some(Err(e)) => return Err(e),
341 None => {
342 return self.put(key, content_type, Bytes::from(current_part)).await;
345 }
346 }
347 }
348
349 guard_egress("PUT")?;
352
353 let create = self
356 .client
357 .create_multipart_upload()
358 .bucket(&self.options.bucket)
359 .key(key)
360 .content_type(content_type)
361 .send()
362 .await
363 .map_err(|e| BlobStoreError::backend(e.to_string()))?;
364
365 let upload_id = create
366 .upload_id()
367 .ok_or_else(|| {
368 BlobStoreError::backend("CreateMultipartUpload returned no upload_id")
369 })?
370 .to_owned();
371
372 let mut completed_parts: Vec<aws_sdk_s3::types::CompletedPart> = Vec::new();
373 let mut part_number = 1_i32;
374 let mut total_bytes: u64 = 0;
375
376 loop {
378 if part_number > 10_000 {
379 abort_multipart(&self.client, &self.options.bucket, key, &upload_id).await;
380 return Err(BlobStoreError::PayloadTooLarge(
381 "S3 multipart upload part limit (10,000) exceeded; \
382 object is too large to upload in 5 MiB chunks"
383 .into(),
384 ));
385 }
386
387 let part_bytes = Bytes::from(std::mem::take(&mut current_part));
388 total_bytes += part_bytes.len() as u64;
389
390 let upload_result = self
391 .client
392 .upload_part()
393 .bucket(&self.options.bucket)
394 .key(key)
395 .upload_id(&upload_id)
396 .part_number(part_number)
397 .body(part_bytes.into())
398 .send()
399 .await;
400
401 let upload_resp = match upload_result {
402 Ok(r) => r,
403 Err(e) => {
404 abort_multipart(&self.client, &self.options.bucket, key, &upload_id).await;
405 return Err(BlobStoreError::backend(e.to_string()));
406 }
407 };
408
409 completed_parts.push(
410 aws_sdk_s3::types::CompletedPart::builder()
411 .part_number(part_number)
412 .set_e_tag(upload_resp.e_tag().map(str::to_owned))
413 .build(),
414 );
415 part_number += 1;
416
417 loop {
419 match stream.next().await {
420 Some(Ok(chunk)) => {
421 current_part.extend_from_slice(&chunk);
422 if current_part.len() >= MULTIPART_PART_SIZE {
423 break;
424 }
425 }
426 Some(Err(e)) => {
427 abort_multipart(&self.client, &self.options.bucket, key, &upload_id)
428 .await;
429 return Err(e);
430 }
431 None => break,
432 }
433 }
434
435 if current_part.is_empty() {
436 break;
437 }
438 }
439
440 let complete_result = self
441 .client
442 .complete_multipart_upload()
443 .bucket(&self.options.bucket)
444 .key(key)
445 .upload_id(&upload_id)
446 .multipart_upload(
447 aws_sdk_s3::types::CompletedMultipartUpload::builder()
448 .set_parts(Some(completed_parts))
449 .build(),
450 )
451 .send()
452 .await;
453
454 match complete_result {
455 Ok(resp) => {
456 let mut blob =
457 Blob::new(&self.options.provider_id, key, content_type, total_bytes);
458 if let Some(etag) = resp.e_tag() {
459 blob = blob.with_etag(etag.to_owned());
460 }
461 Ok(blob)
462 }
463 Err(e) => {
464 abort_multipart(&self.client, &self.options.bucket, key, &upload_id).await;
465 Err(BlobStoreError::backend(e.to_string()))
466 }
467 }
468 })
469 }
470
471 fn get<'a>(&'a self, key: &'a str) -> BlobFuture<'a, Bytes> {
472 Box::pin(async move {
473 validate_key(key)?;
474 guard_egress("GET")?;
475 let result = self
476 .client
477 .get_object()
478 .bucket(&self.options.bucket)
479 .key(key)
480 .send()
481 .await
482 .map_err(|e| {
483 if let aws_sdk_s3::error::SdkError::ServiceError(ref svc) = e
484 && (svc.err().is_no_such_key() || svc.raw().status().as_u16() == 404)
485 {
486 return BlobStoreError::NotFound(key.to_owned());
487 }
488 BlobStoreError::backend(e.to_string())
489 })?;
490 let body = result
491 .body
492 .collect()
493 .await
494 .map_err(|e| BlobStoreError::io(e.to_string()))?;
495 Ok(body.into_bytes())
496 })
497 }
498
499 fn delete<'a>(&'a self, key: &'a str) -> BlobFuture<'a, ()> {
500 Box::pin(async move {
501 validate_key(key)?;
502 guard_egress("DELETE")?;
503 self.client
504 .delete_object()
505 .bucket(&self.options.bucket)
506 .key(key)
507 .send()
508 .await
509 .map_err(|e| BlobStoreError::backend(e.to_string()))?;
510 Ok(())
511 })
512 }
513
514 fn head<'a>(&'a self, key: &'a str) -> BlobFuture<'a, Option<BlobMeta>> {
515 Box::pin(async move {
516 validate_key(key)?;
517 guard_egress("HEAD")?;
518 let result = self
519 .client
520 .head_object()
521 .bucket(&self.options.bucket)
522 .key(key)
523 .send()
524 .await;
525 match result {
526 Ok(resp) => {
527 let content_type = resp
528 .content_type()
529 .unwrap_or("application/octet-stream")
530 .to_owned();
531 let byte_size = resp
532 .content_length()
533 .and_then(|n| u64::try_from(n).ok())
534 .unwrap_or(0);
535 let etag = resp.e_tag().map(str::to_owned);
536 Ok(Some(BlobMeta {
537 key: key.to_owned(),
538 content_type,
539 byte_size,
540 etag,
541 }))
542 }
543 Err(e) => {
544 if let aws_sdk_s3::error::SdkError::ServiceError(ref svc) = e
545 && (svc.err().is_not_found() || svc.raw().status().as_u16() == 404)
546 {
547 return Ok(None);
548 }
549 Err(BlobStoreError::backend(e.to_string()))
550 }
551 }
552 })
553 }
554
555 fn presigned_url<'a>(&'a self, key: &'a str, expires_in: Duration) -> BlobFuture<'a, String> {
556 Box::pin(async move {
557 validate_key(key)?;
558 let presigning = PresigningConfig::expires_in(expires_in)
559 .map_err(|e| BlobStoreError::backend(e.to_string()))?;
560 let req = self
561 .presign_client
562 .get_object()
563 .bucket(&self.options.bucket)
564 .key(key)
565 .presigned(presigning)
566 .await
567 .map_err(|e| BlobStoreError::backend(e.to_string()))?;
568 Ok(req.uri().to_string())
569 })
570 }
571
572 fn presign_put<'a>(
573 &'a self,
574 key: &'a str,
575 content_type: &'a str,
576 expires_in: Duration,
577 ) -> BlobFuture<'a, PresignPutResult> {
578 Box::pin(async move {
579 validate_key(key)?;
580 let presigning = PresigningConfig::expires_in(expires_in)
581 .map_err(|e| BlobStoreError::backend(e.to_string()))?;
582 let req = self
583 .presign_client
584 .put_object()
585 .bucket(&self.options.bucket)
586 .key(key)
587 .content_type(content_type)
588 .presigned(presigning)
589 .await
590 .map_err(|e| BlobStoreError::backend(e.to_string()))?;
591
592 let headers: std::collections::HashMap<String, String> = req
593 .headers()
594 .map(|(k, v)| (k.to_owned(), v.to_owned()))
595 .collect();
596
597 Ok(PresignPutResult {
598 url: req.uri().to_string(),
599 method: "PUT".to_owned(),
600 headers,
601 expires_in,
602 })
603 })
604 }
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610 use std::time::SystemTime;
611
612 fn s3_config(bucket: Option<&str>, region: Option<&str>) -> StorageS3Config {
613 StorageS3Config {
614 bucket: bucket.map(str::to_owned),
615 region: region.map(str::to_owned),
616 ..StorageS3Config::default()
617 }
618 }
619
620 fn query_param<'a>(url: &'a str, name: &str) -> Option<&'a str> {
621 let query = url.split_once('?')?.1;
622 query.split('&').find_map(|part| {
623 let (key, value) = part.split_once('=')?;
624 (key == name).then_some(value)
625 })
626 }
627
628 fn authority(url: &str) -> Option<&str> {
629 let rest = url.split_once("://")?.1;
630 Some(rest.split('/').next().unwrap_or(rest))
631 }
632
633 fn parse_amz_date(value: &str) -> SystemTime {
634 assert_eq!(value.len(), 16, "unexpected X-Amz-Date format");
635 assert_eq!(&value[8..9], "T", "unexpected X-Amz-Date format");
636 assert_eq!(&value[15..16], "Z", "unexpected X-Amz-Date format");
637 let year = value[0..4].parse::<i32>().expect("year");
638 let month = value[4..6].parse::<u32>().expect("month");
639 let day = value[6..8].parse::<u32>().expect("day");
640 let hour = value[9..11].parse::<u64>().expect("hour");
641 let minute = value[11..13].parse::<u64>().expect("minute");
642 let second = value[13..15].parse::<u64>().expect("second");
643
644 let days = days_since_unix_epoch(year, month, day);
645 assert!(days >= 0, "X-Amz-Date before unix epoch");
646 let days = u64::try_from(days).expect("nonnegative day count");
647 SystemTime::UNIX_EPOCH
648 + Duration::from_secs(days * 86_400 + hour * 3_600 + minute * 60 + second)
649 }
650
651 fn days_since_unix_epoch(year: i32, month: u32, day: u32) -> i64 {
652 let year = year - i32::from(month <= 2);
653 let era = if year >= 0 { year } else { year - 399 } / 400;
654 let year_of_era = year - era * 400;
655 let month = i32::try_from(month).expect("month fits i32");
656 let day = i32::try_from(day).expect("day fits i32");
657 let month_prime = month + if month > 2 { -3 } else { 9 };
658 let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
659 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
660 i64::from(era) * 146_097 + i64::from(day_of_era) - 719_468
661 }
662
663 fn test_client(endpoint: &str) -> Client {
664 Client::from_conf(
665 aws_sdk_s3::Config::builder()
666 .behavior_version(BehaviorVersion::latest())
667 .region(Region::new("us-east-1"))
668 .credentials_provider(Credentials::new(
669 "AKIAIOSFODNN7EXAMPLE",
670 "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
671 None,
672 None,
673 "test",
674 ))
675 .force_path_style(true)
676 .endpoint_url(endpoint)
677 .build(),
678 )
679 }
680
681 fn test_store() -> S3BlobStore {
682 let client = test_client("http://127.0.0.1:9");
683 S3BlobStore {
684 client: client.clone(),
685 presign_client: client,
686 options: Arc::new(S3Options {
687 provider_id: "s3".to_owned(),
688 bucket: "test-bucket".to_owned(),
689 }),
690 }
691 }
692
693 async fn serve_one(status: &'static str) -> (String, tokio::task::JoinHandle<String>) {
696 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
697
698 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
699 let endpoint = format!("http://{}", listener.local_addr().unwrap());
700 let task = tokio::spawn(async move {
701 let (mut socket, _) = listener.accept().await.unwrap();
702 let (mut buf, mut chunk) = (Vec::new(), [0_u8; 4096]);
703 let head = loop {
704 let n = socket.read(&mut chunk).await.unwrap();
705 assert!(n > 0, "the request ended early");
706 buf.extend_from_slice(&chunk[..n]);
707 let Some(end) = buf.windows(4).position(|w| w == b"\r\n\r\n") else {
708 continue;
709 };
710 let head = String::from_utf8_lossy(&buf[..end]).to_ascii_lowercase();
711 let body_len = head
712 .lines()
713 .find_map(|l| l.strip_prefix("content-length:"))
714 .map_or(0, |v| v.trim().parse::<usize>().unwrap());
715 if buf.len() >= end + 4 + body_len {
716 break head;
717 }
718 };
719 let body = if status.starts_with("412") {
720 "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Error><Code>PreconditionFailed</Code>\
721 <Message>At least one of the pre-conditions you specified did not hold</Message>\
722 </Error>"
723 } else {
724 ""
725 };
726 let response = format!(
727 "HTTP/1.1 {status}\r\ncontent-length: {}\r\netag: \"e1\"\r\n\
728 connection: close\r\n\r\n{body}",
729 body.len()
730 );
731 socket.write_all(response.as_bytes()).await.unwrap();
732 head
733 });
734 (endpoint, task)
735 }
736
737 fn store_at(endpoint: &str) -> S3BlobStore {
738 let client = test_client(endpoint);
739 S3BlobStore {
740 client: client.clone(),
741 presign_client: client,
742 options: Arc::new(S3Options {
743 provider_id: "s3".to_owned(),
744 bucket: "test-bucket".to_owned(),
745 }),
746 }
747 }
748
749 #[tokio::test]
750 async fn put_if_absent_asks_s3_to_refuse_a_taken_key() {
751 let (endpoint, server) = serve_one("412 Precondition Failed").await;
752 let result = store_at(&endpoint)
753 .put_if_absent("a/b.png", "image/png", Bytes::from_static(b"abc"))
754 .await
755 .expect("412 means the key is taken");
756 assert!(result.is_none(), "{result:?}");
757 let head = server.await.unwrap();
758 assert!(head.starts_with("put /test-bucket/a/b.png"), "{head}");
759 assert!(head.contains("\r\nif-none-match: *"), "{head}");
760 }
761
762 #[tokio::test]
763 async fn put_if_absent_creates_a_free_key() {
764 let (endpoint, server) = serve_one("200 OK").await;
765 let blob = store_at(&endpoint)
766 .put_if_absent("a/b.png", "image/png", Bytes::from_static(b"abc"))
767 .await
768 .expect("created")
769 .expect("the key was free");
770 assert_eq!(blob.byte_size, 3);
771 assert!(server.await.unwrap().contains("\r\nif-none-match: *"));
772 }
773
774 fn assert_invalid_input<T: std::fmt::Debug>(result: Result<T, BlobStoreError>) {
775 match result {
776 Err(BlobStoreError::InvalidInput(_)) => {}
777 other => panic!("expected InvalidInput, got {other:?}"),
778 }
779 }
780
781 #[tokio::test]
782 async fn from_config_requires_bucket() {
783 let cfg = s3_config(None, Some("us-east-1"));
784 let err = S3BlobStore::from_config(&cfg).await.unwrap_err();
785 assert!(matches!(err, S3BlobStoreError::MissingBucket));
786 }
787
788 #[tokio::test]
789 async fn from_config_requires_region() {
790 let cfg = s3_config(Some("my-bucket"), None);
791 let err = S3BlobStore::from_config(&cfg).await.unwrap_err();
792 assert!(matches!(err, S3BlobStoreError::MissingRegion));
793 }
794
795 #[tokio::test]
796 async fn from_config_empty_bucket_is_rejected() {
797 let cfg = s3_config(Some(" "), Some("us-east-1"));
798 let err = S3BlobStore::from_config(&cfg).await.unwrap_err();
799 assert!(matches!(err, S3BlobStoreError::MissingBucket));
800 }
801
802 #[tokio::test]
803 async fn from_config_empty_region_is_rejected() {
804 let cfg = s3_config(Some("my-bucket"), Some(""));
805 let err = S3BlobStore::from_config(&cfg).await.unwrap_err();
806 assert!(matches!(err, S3BlobStoreError::MissingRegion));
807 }
808
809 #[tokio::test]
810 async fn from_config_missing_credential_env_var() {
811 let cfg = StorageS3Config {
812 bucket: Some("b".into()),
813 region: Some("us-east-1".into()),
814 access_key_id_env: Some("__AUTUMN_TEST_KEY_ID_DEFINITELY_NOT_SET__".into()),
815 secret_access_key_env: Some("__AUTUMN_TEST_SECRET_DEFINITELY_NOT_SET__".into()),
816 ..StorageS3Config::default()
817 };
818 let err = S3BlobStore::from_config(&cfg).await.unwrap_err();
819 assert!(matches!(
820 err,
821 S3BlobStoreError::MissingCredentialEnvVar { .. }
822 ));
823 }
824
825 #[tokio::test]
826 async fn from_config_with_static_creds_sets_provider_id() {
827 let cfg = StorageS3Config {
828 bucket: Some("test-bucket".into()),
829 region: Some("us-east-1".into()),
830 access_key_id_env: Some("__AUTUMN_S3_TEST_KEY_ID__".into()),
831 secret_access_key_env: Some("__AUTUMN_S3_TEST_SECRET__".into()),
832 ..StorageS3Config::default()
833 };
834 Box::pin(temp_env::async_with_vars(
835 [
836 ("__AUTUMN_S3_TEST_KEY_ID__", Some("AKIAIOSFODNN7EXAMPLE")),
837 (
838 "__AUTUMN_S3_TEST_SECRET__",
839 Some("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
840 ),
841 ],
842 async {
843 let store = S3BlobStore::from_config(&cfg)
844 .await
845 .expect("should build with static creds");
846 assert_eq!(store.provider_id(), "s3");
847 },
848 ))
849 .await;
850 }
851
852 #[tokio::test]
853 async fn from_config_with_custom_endpoint() {
854 let cfg = StorageS3Config {
855 bucket: Some("my-bucket".into()),
856 region: Some("auto".into()),
857 endpoint: Some("https://example.r2.cloudflarestorage.com".into()),
858 force_path_style: true,
859 access_key_id_env: Some("__AUTUMN_S3_TEST_KEY_ID2__".into()),
860 secret_access_key_env: Some("__AUTUMN_S3_TEST_SECRET2__".into()),
861 ..StorageS3Config::default()
862 };
863 Box::pin(temp_env::async_with_vars(
864 [
865 ("__AUTUMN_S3_TEST_KEY_ID2__", Some("key")),
866 ("__AUTUMN_S3_TEST_SECRET2__", Some("secret")),
867 ],
868 async {
869 let store = S3BlobStore::from_config(&cfg).await.expect("should build");
870 assert_eq!(store.provider_id(), "s3");
871 },
872 ))
873 .await;
874 }
875
876 #[tokio::test]
877 async fn from_config_partial_key_env_is_rejected() {
878 let cfg = StorageS3Config {
879 bucket: Some("b".into()),
880 region: Some("us-east-1".into()),
881 access_key_id_env: Some("SOME_KEY_ENV".into()),
882 secret_access_key_env: None,
883 ..StorageS3Config::default()
884 };
885 let err = S3BlobStore::from_config(&cfg).await.unwrap_err();
886 assert!(matches!(err, S3BlobStoreError::PartialCredentialConfig));
887 }
888
889 #[tokio::test]
890 async fn from_config_partial_secret_env_is_rejected() {
891 let cfg = StorageS3Config {
892 bucket: Some("b".into()),
893 region: Some("us-east-1".into()),
894 access_key_id_env: None,
895 secret_access_key_env: Some("SOME_SECRET_ENV".into()),
896 ..StorageS3Config::default()
897 };
898 let err = S3BlobStore::from_config(&cfg).await.unwrap_err();
899 assert!(matches!(err, S3BlobStoreError::PartialCredentialConfig));
900 }
901
902 #[tokio::test]
903 async fn with_provider_id_overrides_default() {
904 let cfg = StorageS3Config {
905 bucket: Some("b".into()),
906 region: Some("us-east-1".into()),
907 access_key_id_env: Some("__AUTUMN_S3_PROVID_KEY__".into()),
908 secret_access_key_env: Some("__AUTUMN_S3_PROVID_SECRET__".into()),
909 ..StorageS3Config::default()
910 };
911 Box::pin(temp_env::async_with_vars(
912 [
913 ("__AUTUMN_S3_PROVID_KEY__", Some("key")),
914 ("__AUTUMN_S3_PROVID_SECRET__", Some("secret")),
915 ],
916 async {
917 let store = S3BlobStore::from_config(&cfg)
918 .await
919 .expect("build store")
920 .with_provider_id("myapp-prod");
921 assert_eq!(store.provider_id(), "myapp-prod");
922 },
923 ))
924 .await;
925 }
926
927 #[tokio::test]
928 async fn public_base_url_is_used_for_presigning_host() {
929 let cfg = StorageS3Config {
930 bucket: Some("test-bucket".into()),
931 region: Some("us-east-1".into()),
932 endpoint: Some("https://internal.example.local".into()),
933 public_base_url: Some("https://public.example.com".into()),
934 force_path_style: true,
935 access_key_id_env: Some("__AUTUMN_S3_PRESIGN_KEY__".into()),
936 secret_access_key_env: Some("__AUTUMN_S3_PRESIGN_SECRET__".into()),
937 ..StorageS3Config::default()
938 };
939
940 Box::pin(temp_env::async_with_vars(
941 [
942 ("__AUTUMN_S3_PRESIGN_KEY__", Some("AKIAIOSFODNN7EXAMPLE")),
943 (
944 "__AUTUMN_S3_PRESIGN_SECRET__",
945 Some("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
946 ),
947 ],
948 async {
949 let store = S3BlobStore::from_config(&cfg).await.expect("build store");
950 let actual = store
951 .presigned_url("avatars/42.bin", Duration::from_mins(15))
952 .await
953 .expect("presign URL");
954
955 let amz_date = query_param(&actual, "X-Amz-Date").expect("X-Amz-Date");
956 let expected_presigning = PresigningConfig::builder()
957 .start_time(parse_amz_date(amz_date))
958 .expires_in(Duration::from_mins(15))
959 .build()
960 .expect("fixed presigning config");
961 let expected = test_client("https://public.example.com")
962 .get_object()
963 .bucket("test-bucket")
964 .key("avatars/42.bin")
965 .presigned(expected_presigning)
966 .await
967 .expect("expected presigned URL")
968 .uri()
969 .to_string();
970
971 assert_eq!(authority(&actual), Some("public.example.com"));
972 assert_eq!(
973 query_param(&actual, "X-Amz-Signature"),
974 query_param(&expected, "X-Amz-Signature")
975 );
976 },
977 ))
978 .await;
979 }
980
981 #[tokio::test]
982 async fn operations_reject_invalid_keys_before_calling_s3() {
983 let store = test_store();
984
985 assert_invalid_input(store.put("", "text/plain", Bytes::from_static(b"x")).await);
986
987 let stream: ByteStream<'_> = Box::pin(futures::stream::once(async {
988 Ok(Bytes::from_static(b"x"))
989 }));
990 assert_invalid_input(store.put_stream("../x", "text/plain", stream).await);
991
992 assert_invalid_input(store.get("foo.meta").await);
993 assert_invalid_input(store.delete("con/file.txt").await);
994 assert_invalid_input(store.head("avatars/User.png").await);
995 assert_invalid_input(
996 store
997 .presigned_url("avatars//user.png", Duration::from_secs(60))
998 .await,
999 );
1000 }
1001
1002 #[tokio::test]
1005 async fn a_capsule_replay_refuses_s3_egress() {
1006 let store = test_store();
1007 let tape = std::sync::Arc::new(autumn_web::capsule::ReplayEffects::new(
1008 autumn_web::capsule::CapsuleEffects::default(),
1009 ));
1010 let result =
1011 autumn_web::capsule::with_effect_tape(std::sync::Arc::clone(&tape), store.get("a.txt"))
1012 .await;
1013 let error = result.expect_err("a replay refuses S3 egress");
1014 assert!(
1015 error.to_string().contains("blocked during replay"),
1016 "{error}"
1017 );
1018 assert_eq!(tape.divergences().len(), 1);
1019 }
1020
1021 #[tokio::test]
1024 async fn a_stream_error_before_any_upload_is_not_egress() {
1025 let store = test_store();
1026 let tape = std::sync::Arc::new(autumn_web::capsule::ReplayEffects::new(
1027 autumn_web::capsule::CapsuleEffects::default(),
1028 ));
1029 let stream: autumn_web::storage::ByteStream<'_> =
1030 Box::pin(futures::stream::iter(vec![Err(BlobStoreError::backend(
1031 "the upload body failed",
1032 ))]));
1033 let result = autumn_web::capsule::with_effect_tape(
1034 std::sync::Arc::clone(&tape),
1035 store.put_stream("a.txt", "text/plain", stream),
1036 )
1037 .await;
1038 let error = result.expect_err("the stream failed");
1039 assert!(
1040 error.to_string().contains("the upload body failed"),
1041 "{error}"
1042 );
1043 assert!(tape.divergences().is_empty(), "{:?}", tape.divergences());
1044 }
1045
1046 #[test]
1047 fn implements_blob_store_trait() {
1048 fn assert_impl<T: BlobStore>() {}
1049 assert_impl::<S3BlobStore>();
1050 }
1051
1052 #[test]
1053 fn multipart_part_size_is_exactly_5_mib() {
1054 assert_eq!(
1055 MULTIPART_PART_SIZE, 5_242_880,
1056 "MULTIPART_PART_SIZE must be exactly 5 MiB to satisfy S3 minimums"
1057 );
1058 }
1059
1060 #[test]
1061 fn implements_send_sync_clone() {
1062 fn assert_impl<T: Send + Sync + Clone>() {}
1063 assert_impl::<S3BlobStore>();
1064 }
1065
1066 #[tokio::test]
1069 async fn presign_put_returns_put_method() {
1070 let store = test_store();
1071 let result = store
1072 .presign_put(
1073 "uploads/report.pdf",
1074 "application/pdf",
1075 Duration::from_secs(300),
1076 )
1077 .await
1078 .unwrap();
1079 assert_eq!(result.method, "PUT");
1080 }
1081
1082 #[tokio::test]
1083 async fn presign_put_url_uses_expected_bucket_and_key() {
1084 let store = test_store();
1085 let result = store
1086 .presign_put("docs/file.txt", "text/plain", Duration::from_secs(60))
1087 .await
1088 .unwrap();
1089 assert!(
1090 result.url.contains("docs/file.txt"),
1091 "URL must contain the key: {}",
1092 result.url
1093 );
1094 assert!(
1095 result.url.contains("test-bucket"),
1096 "URL must contain the bucket: {}",
1097 result.url
1098 );
1099 }
1100
1101 #[tokio::test]
1102 async fn presign_put_url_contains_sigv4_parameters() {
1103 let store = test_store();
1104 let result = store
1105 .presign_put("img/photo.jpg", "image/jpeg", Duration::from_mins(15))
1106 .await
1107 .unwrap();
1108 assert!(
1109 result.url.contains("X-Amz-Signature") || result.url.contains("x-amz-signature"),
1110 "S3 presigned PUT URL must contain SigV4 signature: {}",
1111 result.url
1112 );
1113 }
1114
1115 #[tokio::test]
1116 async fn presign_put_rejects_invalid_key() {
1117 let store = test_store();
1118 let err = store
1119 .presign_put("../escape", "text/plain", Duration::from_secs(60))
1120 .await
1121 .unwrap_err();
1122 assert!(matches!(err, BlobStoreError::InvalidInput(_)));
1123 }
1124
1125 #[tokio::test]
1126 async fn presign_put_and_presigned_url_produce_different_tokens() {
1127 let store = test_store();
1131 let get_url = store
1132 .presigned_url("shared/key.txt", Duration::from_secs(60))
1133 .await
1134 .unwrap();
1135 let put_result = store
1136 .presign_put("shared/key.txt", "text/plain", Duration::from_secs(60))
1137 .await
1138 .unwrap();
1139 assert_ne!(
1141 get_url, put_result.url,
1142 "GET and PUT presigned URLs for the same key must differ"
1143 );
1144 }
1145}