1use async_trait::async_trait;
35use futures_util::stream::BoxStream;
36use futures_util::{StreamExt, TryStreamExt};
37use http::header::{HeaderName, IF_MATCH, IF_NONE_MATCH};
38use http::{Method, StatusCode};
39use std::{sync::Arc, time::Duration};
40use url::Url;
41
42use crate::aws::client::{CompleteMultipartMode, PutPartPayload, RequestError, S3Client};
43use crate::client::CredentialProvider;
44use crate::client::get::GetClientExt;
45use crate::client::list::{ListClient, ListClientExt};
46use crate::multipart::{MultipartStore, PartId};
47use crate::retry::{MultipartRetry, RetryPolicy};
48use crate::signer::{SignedUrlOptions, Signer};
49use crate::util::{STRICT_ENCODE_SET, validate_signed_url_extras};
50use crate::{
51 CopyMode, CopyOptions, Error, GetOptions, GetResult, ListResult, MultipartId, MultipartUpload,
52 ObjectMeta, ObjectStore, Path, PutMode, PutMultipartOptions, PutOptions, PutPayload, PutResult,
53 Result, UploadPart,
54};
55
56static TAGS_HEADER: HeaderName = HeaderName::from_static("x-amz-tagging");
57static COPY_SOURCE_HEADER: HeaderName = HeaderName::from_static("x-amz-copy-source");
58
59mod builder;
60mod checksum;
61mod client;
62mod credential;
63mod precondition;
64
65#[cfg(all(feature = "reqwest", not(target_arch = "wasm32")))]
66mod resolve;
67
68pub use builder::{AmazonS3Builder, AmazonS3ConfigKey};
69pub use checksum::Checksum;
70pub use precondition::{S3ConditionalPut, S3CopyIfNotExists};
71
72#[cfg(all(feature = "reqwest", not(target_arch = "wasm32")))]
73pub use resolve::resolve_bucket_region;
74
75const STRICT_PATH_ENCODE_SET: percent_encoding::AsciiSet = STRICT_ENCODE_SET.remove(b'/');
77
78const STORE: &str = "S3";
79
80pub type AwsCredentialProvider = Arc<dyn CredentialProvider<Credential = AwsCredential>>;
82use crate::client::parts::Parts;
83use crate::list::{PaginatedListOptions, PaginatedListResult, PaginatedListStore};
84pub use credential::{AwsAuthorizer, AwsCredential};
85
86#[derive(Debug, Clone)]
88pub struct AmazonS3 {
89 client: Arc<S3Client>,
90}
91
92impl std::fmt::Display for AmazonS3 {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 write!(f, "AmazonS3({})", self.client.config.bucket)
95 }
96}
97
98impl AmazonS3 {
99 pub fn credentials(&self) -> &AwsCredentialProvider {
101 &self.client.config.credentials
102 }
103
104 fn path_url(&self, path: &Path) -> String {
106 self.client.config.path_url(path)
107 }
108}
109
110#[async_trait]
111impl Signer for AmazonS3 {
112 async fn signed_url(&self, method: Method, path: &Path, expires_in: Duration) -> Result<Url> {
145 self.signed_url_opts(method, path, expires_in, &SignedUrlOptions::default())
146 .await
147 }
148
149 async fn signed_url_opts(
190 &self,
191 method: Method,
192 path: &Path,
193 expires_in: Duration,
194 options: &SignedUrlOptions,
195 ) -> Result<Url> {
196 validate_signed_url_extras(
199 STORE,
200 &options.extra_query,
201 &options.signed_headers,
202 "x-amz-",
203 )?;
204
205 let crypto = self.client.config.crypto()?;
206 let credential = self.credentials().get_credential().await?;
207 let authorizer = AwsAuthorizer::new(&credential, "s3", &self.client.config.region)
208 .with_request_payer(self.client.config.request_payer)
209 .with_crypto(crypto);
210
211 let path_url = self.path_url(path);
212 let mut url = path_url.parse().map_err(|e| Error::Generic {
213 store: STORE,
214 source: format!("Unable to parse url {path_url}: {e}").into(),
215 })?;
216
217 authorizer.sign_with(
218 method,
219 &mut url,
220 &options.extra_query,
221 &options.signed_headers,
222 expires_in,
223 )?;
224
225 Ok(url)
226 }
227}
228
229#[async_trait]
230impl ObjectStore for AmazonS3 {
231 async fn put_opts(
232 &self,
233 location: &Path,
234 payload: PutPayload,
235 opts: PutOptions,
236 ) -> Result<PutResult> {
237 let PutOptions {
238 mode,
239 tags,
240 attributes,
241 extensions,
242 } = opts;
243
244 let request = self
245 .client
246 .request(Method::PUT, location)
247 .with_payload(payload)?
248 .with_attributes(attributes)
249 .with_tags(tags)
250 .with_extensions(extensions)
251 .with_encryption_headers();
252
253 match (mode, &self.client.config.conditional_put) {
254 (PutMode::Overwrite, _) => request.idempotent(true).do_put().await,
255 (PutMode::Create, S3ConditionalPut::Disabled) => Err(Error::NotImplemented {
256 operation:
257 "`put_opts` with mode `PutMode::Create` when conditional put is disabled".into(),
258 implementer: self.to_string(),
259 }),
260 (PutMode::Create, S3ConditionalPut::ETagMatch) => {
261 match request.header(&IF_NONE_MATCH, "*").do_put().await {
262 Err(e @ Error::NotModified { .. } | e @ Error::Precondition { .. }) => {
266 Err(Error::AlreadyExists {
267 path: location.to_string(),
268 source: Box::new(e),
269 })
270 }
271 r => r,
272 }
273 }
274 (PutMode::Update(v), put) => {
275 let etag = v.e_tag.ok_or_else(|| Error::Generic {
276 store: STORE,
277 source: "ETag required for conditional put".to_string().into(),
278 })?;
279 match put {
280 S3ConditionalPut::ETagMatch => {
281 match request
282 .header(&IF_MATCH, etag.as_str())
283 .retry_on_conflict(true)
288 .do_put()
289 .await
290 {
291 Err(Error::NotFound { path, source }) => {
296 Err(Error::Precondition { path, source })
297 }
298 r => r,
299 }
300 }
301 S3ConditionalPut::Disabled => Err(Error::NotImplemented {
302 operation:
303 "`put_opts` with mode `PutMode::Update` when conditional put is disabled"
304 .into(),
305 implementer: self.to_string(),
306 }),
307 }
308 }
309 }
310 }
311
312 async fn put_multipart_opts(
313 &self,
314 location: &Path,
315 opts: PutMultipartOptions,
316 ) -> Result<Box<dyn MultipartUpload>> {
317 let retry_policy = opts.retry_policy();
318 let upload_id = self.client.create_multipart(location, opts).await?;
319
320 Ok(Box::new(S3MultiPartUpload {
321 part_idx: 0,
322 state: Arc::new(UploadState {
323 client: Arc::clone(&self.client),
324 location: location.clone(),
325 upload_id: upload_id.clone(),
326 parts: Default::default(),
327 retry_policy,
328 }),
329 }))
330 }
331
332 async fn get_opts(&self, location: &Path, options: GetOptions) -> Result<GetResult> {
333 self.client.get_opts(location, options).await
334 }
335
336 fn delete_stream(
337 &self,
338 locations: BoxStream<'static, Result<Path>>,
339 ) -> BoxStream<'static, Result<Path>> {
340 let client = Arc::clone(&self.client);
341
342 if client.config.disable_bulk_delete {
347 return locations
348 .map(move |location| {
349 let client = Arc::clone(&client);
350 async move {
351 let location = location?;
352 client.delete_request(&location).await?;
353 Ok(location)
354 }
355 })
356 .buffered(20)
357 .boxed();
358 }
359
360 locations
361 .try_chunks(1_000)
362 .map(move |locations| {
363 let client = Arc::clone(&client);
364 async move {
365 let locations = locations.map_err(|e| e.1)?;
368 client
369 .bulk_delete_request(locations)
370 .await
371 .map(futures_util::stream::iter)
372 }
373 })
374 .buffered(20)
375 .try_flatten()
376 .boxed()
377 }
378
379 fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, Result<ObjectMeta>> {
380 self.client.list(prefix)
381 }
382
383 fn list_with_offset(
384 &self,
385 prefix: Option<&Path>,
386 offset: &Path,
387 ) -> BoxStream<'static, Result<ObjectMeta>> {
388 if self.client.config.is_s3_express() {
389 let offset = offset.clone();
390 return self
392 .client
393 .list(prefix)
394 .try_filter(move |f| futures_util::future::ready(f.location > offset))
395 .boxed();
396 }
397
398 self.client.list_with_offset(prefix, offset)
399 }
400
401 async fn list_with_delimiter(&self, prefix: Option<&Path>) -> Result<ListResult> {
402 self.client.list_with_delimiter(prefix).await
403 }
404
405 async fn copy_opts(&self, from: &Path, to: &Path, options: CopyOptions) -> Result<()> {
406 let CopyOptions {
407 mode,
408 extensions: _,
409 } = options;
410
411 match mode {
412 CopyMode::Overwrite => {
413 self.client
414 .copy_request(from, to)
415 .idempotent(true)
416 .send()
417 .await?;
418 Ok(())
419 }
420 CopyMode::Create => {
421 let (k, v, status) = match &self.client.config.copy_if_not_exists {
422 Some(S3CopyIfNotExists::Header(k, v)) => {
423 (k, v, StatusCode::PRECONDITION_FAILED)
424 }
425 Some(S3CopyIfNotExists::HeaderWithStatus(k, v, status)) => (k, v, *status),
426 Some(S3CopyIfNotExists::Multipart) => {
427 let upload_id = self
428 .client
429 .create_multipart(to, PutMultipartOptions::default())
430 .await?;
431
432 let res = async {
433 let part_id = self
434 .client
435 .put_part(to, &upload_id, 0, PutPartPayload::Copy(from))
436 .await?;
437 match self
438 .client
439 .complete_multipart(
440 to,
441 &upload_id,
442 vec![part_id],
443 CompleteMultipartMode::Create,
444 )
445 .await
446 {
447 Err(e @ Error::Precondition { .. }) => Err(Error::AlreadyExists {
448 path: to.to_string(),
449 source: Box::new(e),
450 }),
451 Ok(_) => Ok(()),
452 Err(e) => Err(e),
453 }
454 }
455 .await;
456
457 if res.is_err() {
462 let _ = self.client.abort_multipart(to, &upload_id).await;
463 }
464
465 return res;
466 }
467 None => {
468 return Err(Error::NotSupported {
469 source: "S3 does not support copy-if-not-exists".to_string().into(),
470 });
471 }
472 };
473
474 let req = self.client.copy_request(from, to);
475 match req.header(k, v).send().await {
476 Err(RequestError::Retry { source, path })
477 if source.status() == Some(status) =>
478 {
479 Err(Error::AlreadyExists {
480 source: Box::new(source),
481 path,
482 })
483 }
484 Err(e) => Err(e.into()),
485 Ok(_) => Ok(()),
486 }
487 }
488 }
489 }
490}
491
492#[derive(Debug)]
493struct S3MultiPartUpload {
494 part_idx: usize,
495 state: Arc<UploadState>,
496}
497
498#[derive(Debug)]
499struct UploadState {
500 parts: Parts,
501 location: Path,
502 upload_id: String,
503 client: Arc<S3Client>,
504 retry_policy: Option<Arc<dyn RetryPolicy>>,
505}
506
507#[async_trait]
508impl MultipartUpload for S3MultiPartUpload {
509 fn put_part(&mut self, data: PutPayload) -> UploadPart {
510 let idx = self.part_idx;
511 self.part_idx += 1;
512 let state = Arc::clone(&self.state);
513 Box::pin(async move {
514 let mut retry = MultipartRetry::new(state.retry_policy.clone());
515 loop {
516 match state
517 .client
518 .put_part(
519 &state.location,
520 &state.upload_id,
521 idx,
522 PutPartPayload::Part(data.clone()),
523 )
524 .await
525 {
526 Ok(part) => {
527 state.parts.put(idx, part);
528 return Ok(());
529 }
530 Err(error) if retry.should_retry(&error).await => continue,
531 Err(error) => return Err(error),
532 }
533 }
534 })
535 }
536
537 async fn complete(&mut self) -> Result<PutResult> {
538 let parts = self.state.parts.finish(self.part_idx)?;
539
540 self.state
541 .client
542 .complete_multipart(
543 &self.state.location,
544 &self.state.upload_id,
545 parts,
546 CompleteMultipartMode::Overwrite,
547 )
548 .await
549 }
550
551 async fn abort(&mut self) -> Result<()> {
552 self.state
553 .client
554 .request(Method::DELETE, &self.state.location)
555 .query(&[("uploadId", &self.state.upload_id)])
556 .idempotent(true)
557 .send()
558 .await?;
559
560 Ok(())
561 }
562}
563
564#[async_trait]
565impl MultipartStore for AmazonS3 {
566 async fn create_multipart(&self, path: &Path) -> Result<MultipartId> {
678 self.client
679 .create_multipart(path, PutMultipartOptions::default())
680 .await
681 }
682
683 async fn create_multipart_opts(
684 &self,
685 path: &Path,
686 opts: PutMultipartOptions,
687 ) -> Result<MultipartId> {
688 self.client.create_multipart(path, opts).await
689 }
690
691 async fn put_part(
692 &self,
693 path: &Path,
694 id: &MultipartId,
695 part_idx: usize,
696 data: PutPayload,
697 ) -> Result<PartId> {
698 self.client
699 .put_part(path, id, part_idx, PutPartPayload::Part(data))
700 .await
701 }
702
703 async fn complete_multipart(
704 &self,
705 path: &Path,
706 id: &MultipartId,
707 parts: Vec<PartId>,
708 ) -> Result<PutResult> {
709 self.client
710 .complete_multipart(path, id, parts, CompleteMultipartMode::Overwrite)
711 .await
712 }
713
714 async fn abort_multipart(&self, path: &Path, id: &MultipartId) -> Result<()> {
715 self.client
716 .request(Method::DELETE, path)
717 .query(&[("uploadId", id)])
718 .send()
719 .await?;
720 Ok(())
721 }
722}
723
724#[async_trait]
725impl PaginatedListStore for AmazonS3 {
726 async fn list_paginated(
727 &self,
728 prefix: Option<&str>,
729 opts: PaginatedListOptions,
730 ) -> Result<PaginatedListResult> {
731 self.client.list_request(prefix, opts).await
732 }
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738 use crate::ClientOptions;
739 use crate::ObjectStoreExt;
740 #[cfg(feature = "reqwest")]
741 use crate::client::SpawnedReqwestConnector;
742 use crate::client::get::GetClient;
743 #[cfg(feature = "reqwest")]
744 use crate::client::mock_server::MockServer;
745 use crate::client::retry::RetryContext;
746 use crate::integration::*;
747 use crate::tests::*;
748 use base64::Engine;
749 use base64::prelude::BASE64_STANDARD;
750 use http::HeaderMap;
751 use http::HeaderValue;
752 #[cfg(feature = "reqwest")]
753 use http::Response;
754 #[cfg(feature = "reqwest")]
755 use http::header::ETAG;
756 #[cfg(feature = "reqwest")]
757 use std::sync::atomic::{AtomicUsize, Ordering};
758
759 const NON_EXISTENT_NAME: &str = "nonexistentname";
760
761 const DEFAULT_SIGNING_BUCKET: &str = "test-bucket-for-signing";
767
768 fn signing_bucket() -> String {
769 std::env::var("OBJECT_STORE_SIGNING_BUCKET")
770 .unwrap_or_else(|_| DEFAULT_SIGNING_BUCKET.to_string())
771 }
772
773 async fn signing_store() -> AmazonS3 {
779 let store = AmazonS3Builder::from_env()
780 .with_bucket_name(signing_bucket())
781 .build()
782 .unwrap();
783
784 let bucket = signing_bucket();
785 let probe = Path::from(".object_store_presign_preflight");
786 if let Err(source) = store.put(&probe, PutPayload::from_static(b"ok")).await {
787 panic!(
788 "presign test preflight failed: the configured credentials cannot write to \
789 bucket `{bucket}`.\n\
790 Verify the bucket exists, is in your configured region, and that the credentials \
791 (from the AWS_* env vars read by `from_env`) can access it. The tests exercise \
792 s3:PutObject, s3:GetObject, and s3:DeleteObject; this preflight checks writes. \
793 Point at a different bucket with OBJECT_STORE_SIGNING_BUCKET.\n\
794 Underlying error: {source}"
795 );
796 }
797 let _ = store.delete(&probe).await;
799
800 store
801 }
802
803 #[cfg(feature = "reqwest")]
804 #[derive(Debug, Default)]
805 struct RetryOnce(AtomicUsize);
806
807 #[cfg(feature = "reqwest")]
808 #[async_trait]
809 impl RetryPolicy for RetryOnce {
810 async fn retry(&self, _: crate::retry::RetryContext) -> bool {
811 self.0.fetch_add(1, Ordering::SeqCst) == 0
812 }
813 }
814
815 #[cfg(feature = "reqwest")]
816 #[tokio::test]
817 async fn retries_multipart_part_operation() {
818 let mock = MockServer::new().await;
819 mock.push(
820 Response::builder()
821 .status(StatusCode::OK)
822 .body(
823 "<InitiateMultipartUploadResult><UploadId>upload-id</UploadId></InitiateMultipartUploadResult>"
824 .to_string(),
825 )
826 .unwrap(),
827 );
828 mock.push(
829 Response::builder()
830 .status(StatusCode::SERVICE_UNAVAILABLE)
831 .body(String::new())
832 .unwrap(),
833 );
834 mock.push(
835 Response::builder()
836 .status(StatusCode::OK)
837 .header(ETAG, "etag")
838 .body(String::new())
839 .unwrap(),
840 );
841
842 let store = AmazonS3Builder::new()
843 .with_endpoint(mock.url())
844 .with_bucket_name("test-bucket")
845 .with_region("us-east-1")
846 .with_allow_http(true)
847 .with_skip_signature(true)
848 .with_retry(crate::RetryConfig {
849 max_retries: 0,
850 ..Default::default()
851 })
852 .build()
853 .unwrap();
854 let policy = Arc::new(RetryOnce::default());
855 let opts = PutMultipartOptions::default()
856 .with_retry_policy(Arc::clone(&policy) as Arc<dyn RetryPolicy>);
857
858 let mut upload = store
859 .put_multipart_opts(&Path::from("multipart"), opts)
860 .await
861 .unwrap();
862 upload.put_part(PutPayload::from("data")).await.unwrap();
863
864 assert_eq!(policy.0.load(Ordering::SeqCst), 1);
865 mock.shutdown().await;
866 }
867
868 #[tokio::test]
869 async fn write_multipart_file_with_signature() {
870 maybe_skip_integration!();
871
872 let bucket = "test-bucket-for-checksum";
873 for checksum in [Checksum::SHA256, Checksum::CRC64NVME] {
874 let store = AmazonS3Builder::from_env()
875 .with_bucket_name(bucket)
876 .with_checksum_algorithm(checksum)
877 .build()
878 .unwrap();
879
880 let str = "test.bin";
881 let path = Path::parse(str).unwrap();
882 let opts = PutMultipartOptions::default();
883 let mut upload = store.put_multipart_opts(&path, opts).await.unwrap();
884
885 upload
886 .put_part(PutPayload::from(vec![0u8; 10_000_000]))
887 .await
888 .unwrap();
889 upload
890 .put_part(PutPayload::from(vec![0u8; 5_000_000]))
891 .await
892 .unwrap();
893
894 let res = upload.complete().await.unwrap();
895 assert!(res.e_tag.is_some(), "Should have valid etag");
896
897 store.delete(&path).await.unwrap();
898 }
899 }
900
901 #[tokio::test]
902 async fn signed_url_multipart_upload() {
903 maybe_skip_integration!();
904
905 let integration = signing_store().await;
909
910 let path = Path::from("test_signed_multipart_upload.bin");
911 let _ = integration.delete(&path).await;
912
913 let upload_id = integration.create_multipart(&path).await.unwrap();
914
915 let part = vec![42u8; 1024];
916 let options = SignedUrlOptions::default()
917 .with_query([("partNumber", "1"), ("uploadId", upload_id.as_str())]);
918 let url = integration
919 .signed_url_opts(Method::PUT, &path, Duration::from_secs(300), &options)
920 .await
921 .unwrap();
922
923 let resp = reqwest::Client::new()
925 .put(url)
926 .body(part.clone())
927 .send()
928 .await
929 .unwrap();
930 assert!(
931 resp.status().is_success(),
932 "UploadPart via presigned URL failed: {resp:?}"
933 );
934 let etag = resp
935 .headers()
936 .get(http::header::ETAG)
937 .expect("ETag in UploadPart response")
938 .to_str()
939 .unwrap()
940 .to_string();
941
942 integration
943 .complete_multipart(&path, &upload_id, vec![PartId { content_id: etag }])
944 .await
945 .unwrap();
946
947 let got = integration.get(&path).await.unwrap().bytes().await.unwrap();
948 assert_eq!(got.as_ref(), part.as_slice());
949
950 integration.delete(&path).await.unwrap();
951 }
952
953 #[tokio::test]
954 async fn signed_url_with_signed_checksum_header_is_enforced() {
955 maybe_skip_integration!();
956 maybe_skip_signature_enforcement!();
957
958 let integration = signing_store().await;
963
964 let path = Path::from("test_signed_checksum.bin");
965 let _ = integration.delete(&path).await;
966
967 let body = b"hello world".to_vec();
968 const CHECKSUM: &str = "uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=";
970
971 let options = SignedUrlOptions::default().with_signed_header(
972 HeaderName::from_static("x-amz-checksum-sha256"),
973 HeaderValue::from_static(CHECKSUM),
974 );
975 let url = integration
976 .signed_url_opts(Method::PUT, &path, Duration::from_secs(300), &options)
977 .await
978 .unwrap();
979
980 let client = reqwest::Client::new();
981
982 let ok = client
984 .put(url.clone())
985 .header("x-amz-checksum-sha256", CHECKSUM)
986 .body(body.clone())
987 .send()
988 .await
989 .unwrap();
990 assert!(
991 ok.status().is_success(),
992 "matching checksum rejected: {ok:?}"
993 );
994
995 let bad_body = client
997 .put(url.clone())
998 .header("x-amz-checksum-sha256", CHECKSUM)
999 .body(b"tampered content".to_vec())
1000 .send()
1001 .await
1002 .unwrap();
1003 assert!(
1004 !bad_body.status().is_success(),
1005 "mismatched body was NOT rejected: {bad_body:?}"
1006 );
1007
1008 let missing_header = client.put(url).body(body.clone()).send().await.unwrap();
1010 assert!(
1011 !missing_header.status().is_success(),
1012 "missing signed header was NOT rejected: {missing_header:?}"
1013 );
1014
1015 let got = integration.get(&path).await.unwrap().bytes().await.unwrap();
1016 assert_eq!(got.as_ref(), body.as_slice());
1017
1018 integration.delete(&path).await.unwrap();
1019 }
1020
1021 #[tokio::test]
1022 async fn signed_url_with_signed_content_type_is_enforced() {
1023 maybe_skip_integration!();
1024 maybe_skip_signature_enforcement!();
1025
1026 let integration = signing_store().await;
1029
1030 let path = Path::from("test_signed_content_type.bin");
1031 let _ = integration.delete(&path).await;
1032
1033 let body = b"some body".to_vec();
1034 const CONTENT_TYPE_VALUE: &str = "text/plain; charset=utf-8";
1035
1036 let options = SignedUrlOptions::default().with_signed_header(
1037 http::header::CONTENT_TYPE,
1038 HeaderValue::from_static(CONTENT_TYPE_VALUE),
1039 );
1040 let url = integration
1041 .signed_url_opts(Method::PUT, &path, Duration::from_secs(300), &options)
1042 .await
1043 .unwrap();
1044
1045 let client = reqwest::Client::new();
1046
1047 let ok = client
1049 .put(url.clone())
1050 .header(http::header::CONTENT_TYPE, CONTENT_TYPE_VALUE)
1051 .body(body.clone())
1052 .send()
1053 .await
1054 .unwrap();
1055 assert!(
1056 ok.status().is_success(),
1057 "matching content-type rejected: {ok:?}"
1058 );
1059
1060 let wrong = client
1062 .put(url)
1063 .header(http::header::CONTENT_TYPE, "application/octet-stream")
1064 .body(body)
1065 .send()
1066 .await
1067 .unwrap();
1068 assert!(
1069 !wrong.status().is_success(),
1070 "mismatched content-type was NOT rejected: {wrong:?}"
1071 );
1072
1073 integration.delete(&path).await.unwrap();
1074 }
1075
1076 #[tokio::test]
1077 async fn signed_url_query_value_with_space() {
1078 maybe_skip_integration!();
1079
1080 let integration = signing_store().await;
1084
1085 let path = Path::from("test_signed_query_space.bin");
1086 let body = b"contents".to_vec();
1087 integration.put(&path, body.clone().into()).await.unwrap();
1088
1089 let options = SignedUrlOptions::default().with_query([(
1090 "response-content-disposition",
1091 "attachment; filename=\"a b.txt\"",
1092 )]);
1093 let url = integration
1094 .signed_url_opts(Method::GET, &path, Duration::from_secs(300), &options)
1095 .await
1096 .unwrap();
1097
1098 let resp = reqwest::Client::new().get(url).send().await.unwrap();
1099 assert!(
1100 resp.status().is_success(),
1101 "GET with space-containing signed query rejected: {resp:?}"
1102 );
1103 let disposition = resp
1105 .headers()
1106 .get(http::header::CONTENT_DISPOSITION)
1107 .and_then(|v| v.to_str().ok())
1108 .unwrap_or_default()
1109 .to_string();
1110 let got = resp.bytes().await.unwrap();
1111 assert_eq!(got.as_ref(), body.as_slice());
1112 assert!(
1113 disposition.contains("a b.txt"),
1114 "unexpected content-disposition: {disposition:?}"
1115 );
1116
1117 integration.delete(&path).await.unwrap();
1118 }
1119
1120 #[tokio::test]
1121 async fn signed_url_baseline_roundtrip_without_options() {
1122 maybe_skip_integration!();
1123
1124 let integration = signing_store().await;
1127 let path = Path::from("test_signed_baseline.bin");
1128 let _ = integration.delete(&path).await;
1129 let body = b"baseline body".to_vec();
1130 let client = reqwest::Client::new();
1131
1132 let put_url = integration
1133 .signed_url(Method::PUT, &path, Duration::from_secs(300))
1134 .await
1135 .unwrap();
1136 let put = client.put(put_url).body(body.clone()).send().await.unwrap();
1137 assert!(put.status().is_success(), "baseline PUT failed: {put:?}");
1138
1139 let get_url = integration
1140 .signed_url(Method::GET, &path, Duration::from_secs(300))
1141 .await
1142 .unwrap();
1143 let got = client.get(get_url).send().await.unwrap();
1144 assert!(got.status().is_success(), "baseline GET failed: {got:?}");
1145 assert_eq!(got.bytes().await.unwrap().as_ref(), body.as_slice());
1146
1147 integration.delete(&path).await.unwrap();
1148 }
1149
1150 #[tokio::test]
1151 async fn signed_url_multipart_multiple_parts_roundtrip() {
1152 maybe_skip_integration!();
1153
1154 let integration = signing_store().await;
1158 let path = Path::from("test_signed_multipart_large.bin");
1159 let _ = integration.delete(&path).await;
1160
1161 const MIB: usize = 1024 * 1024;
1162 let parts_data = [vec![1u8; 6 * MIB], vec![2u8; 6 * MIB], vec![3u8; MIB]];
1163
1164 let upload_id = integration.create_multipart(&path).await.unwrap();
1165 let client = reqwest::Client::new();
1166 let mut part_ids = Vec::new();
1167
1168 for (idx, data) in parts_data.iter().enumerate() {
1169 let part_number = (idx + 1).to_string();
1170 let options = SignedUrlOptions::default().with_query([
1172 ("uploadId", upload_id.as_str()),
1173 ("partNumber", part_number.as_str()),
1174 ]);
1175 let url = integration
1176 .signed_url_opts(Method::PUT, &path, Duration::from_secs(600), &options)
1177 .await
1178 .unwrap();
1179 let resp = client.put(url).body(data.clone()).send().await.unwrap();
1180 assert!(
1181 resp.status().is_success(),
1182 "UploadPart {part_number} failed: {resp:?}"
1183 );
1184 let etag = resp
1185 .headers()
1186 .get(http::header::ETAG)
1187 .expect("ETag")
1188 .to_str()
1189 .unwrap()
1190 .to_string();
1191 part_ids.push(PartId { content_id: etag });
1192 }
1193
1194 integration
1195 .complete_multipart(&path, &upload_id, part_ids)
1196 .await
1197 .unwrap();
1198
1199 let got = integration.get(&path).await.unwrap().bytes().await.unwrap();
1200 let expected: Vec<u8> = parts_data.concat();
1201 assert_eq!(got.len(), expected.len(), "assembled length mismatch");
1202 assert_eq!(
1203 got.as_ref(),
1204 expected.as_slice(),
1205 "assembled bytes mismatch"
1206 );
1207
1208 integration.delete(&path).await.unwrap();
1209 }
1210
1211 #[tokio::test]
1212 async fn signed_url_tampering_is_rejected() {
1213 maybe_skip_integration!();
1214 maybe_skip_signature_enforcement!();
1215
1216 let integration = signing_store().await;
1220 let path = Path::from("test_signed_tamper.bin");
1221 let _ = integration.delete(&path).await;
1222 let client = reqwest::Client::new();
1223
1224 let upload_id = integration.create_multipart(&path).await.unwrap();
1226 let options = SignedUrlOptions::default()
1227 .with_query([("partNumber", "1"), ("uploadId", upload_id.as_str())]);
1228 let url = integration
1229 .signed_url_opts(Method::PUT, &path, Duration::from_secs(300), &options)
1230 .await
1231 .unwrap();
1232 let url_str = url.as_str();
1233
1234 let tampered_part = Url::parse(&url_str.replace("partNumber=1", "partNumber=2")).unwrap();
1236 let resp = client
1237 .put(tampered_part)
1238 .body(vec![0u8; 16])
1239 .send()
1240 .await
1241 .unwrap();
1242 assert_eq!(
1243 resp.status().as_u16(),
1244 403,
1245 "tampered partNumber was accepted: {resp:?}"
1246 );
1247
1248 let bogus = format!("{}XXXX", upload_id);
1250 let tampered_id = Url::parse(&url_str.replace(upload_id.as_str(), bogus.as_str())).unwrap();
1251 let resp = client
1252 .put(tampered_id)
1253 .body(vec![0u8; 16])
1254 .send()
1255 .await
1256 .unwrap();
1257 assert_eq!(
1258 resp.status().as_u16(),
1259 403,
1260 "tampered uploadId was accepted: {resp:?}"
1261 );
1262
1263 let resp = client
1266 .put(url)
1267 .header("x-custom-unsigned", "anything")
1268 .body(vec![0u8; 16])
1269 .send()
1270 .await
1271 .unwrap();
1272 assert!(
1273 resp.status().is_success(),
1274 "extra unsigned header was rejected: {resp:?}"
1275 );
1276
1277 const CHECKSUM: &str = "uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=";
1279 let options = SignedUrlOptions::default().with_signed_header(
1280 HeaderName::from_static("x-amz-checksum-sha256"),
1281 HeaderValue::from_static(CHECKSUM),
1282 );
1283 let url = integration
1284 .signed_url_opts(Method::PUT, &path, Duration::from_secs(300), &options)
1285 .await
1286 .unwrap();
1287 let resp = client
1290 .put(url)
1291 .header(
1292 "x-amz-checksum-sha256",
1293 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
1294 )
1295 .body(b"hello world".to_vec())
1296 .send()
1297 .await
1298 .unwrap();
1299 assert_eq!(
1300 resp.status().as_u16(),
1301 403,
1302 "tampered signed header value was accepted: {resp:?}"
1303 );
1304
1305 integration.abort_multipart(&path, &upload_id).await.ok();
1306 let _ = integration.delete(&path).await;
1307 }
1308
1309 #[tokio::test]
1310 async fn signed_url_special_character_key() {
1311 maybe_skip_integration!();
1312
1313 let integration = signing_store().await;
1317 let path = Path::from("test signed/a b=c/π.bin");
1318 let _ = integration.delete(&path).await;
1319
1320 let upload_id = integration.create_multipart(&path).await.unwrap();
1321 let body = vec![7u8; 2048];
1322 let options = SignedUrlOptions::default()
1323 .with_query([("partNumber", "1"), ("uploadId", upload_id.as_str())]);
1324 let url = integration
1325 .signed_url_opts(Method::PUT, &path, Duration::from_secs(300), &options)
1326 .await
1327 .unwrap();
1328 let resp = reqwest::Client::new()
1329 .put(url)
1330 .body(body.clone())
1331 .send()
1332 .await
1333 .unwrap();
1334 assert!(
1335 resp.status().is_success(),
1336 "special-key UploadPart failed: {resp:?}"
1337 );
1338 let etag = resp
1339 .headers()
1340 .get(http::header::ETAG)
1341 .expect("ETag")
1342 .to_str()
1343 .unwrap()
1344 .to_string();
1345 integration
1346 .complete_multipart(&path, &upload_id, vec![PartId { content_id: etag }])
1347 .await
1348 .unwrap();
1349
1350 let got = integration.get(&path).await.unwrap().bytes().await.unwrap();
1353 assert_eq!(got.as_ref(), body.as_slice());
1354
1355 integration.delete(&path).await.unwrap();
1356 }
1357
1358 #[tokio::test]
1359 async fn signed_url_expires() {
1360 maybe_skip_integration!();
1361 maybe_skip_signature_enforcement!();
1362
1363 let integration = signing_store().await;
1366 let path = Path::from("test_signed_expiry.bin");
1367 let _ = integration.delete(&path).await;
1368
1369 let url = integration
1370 .signed_url(Method::PUT, &path, Duration::from_secs(1))
1371 .await
1372 .unwrap();
1373 std::thread::sleep(Duration::from_secs(3));
1374 let resp = reqwest::Client::new()
1375 .put(url)
1376 .body(b"too late".to_vec())
1377 .send()
1378 .await
1379 .unwrap();
1380 let status = resp.status();
1381 let _ = integration.delete(&path).await;
1384 assert_eq!(
1385 status.as_u16(),
1386 403,
1387 "expired signed URL was accepted: {resp:?}"
1388 );
1389 }
1390
1391 #[tokio::test]
1392 async fn signed_url_conditional_create_blocks_overwrite() {
1393 maybe_skip_integration!();
1394
1395 let integration = signing_store().await;
1398 let path = Path::from("test_signed_conditional.bin");
1399 let _ = integration.delete(&path).await;
1400 let client = reqwest::Client::new();
1401
1402 let options = SignedUrlOptions::default()
1403 .with_signed_header(IF_NONE_MATCH, HeaderValue::from_static("*"));
1404 let url = integration
1405 .signed_url_opts(Method::PUT, &path, Duration::from_secs(300), &options)
1406 .await
1407 .unwrap();
1408
1409 let first = client
1410 .put(url.clone())
1411 .header(IF_NONE_MATCH, "*")
1412 .body(b"first write".to_vec())
1413 .send()
1414 .await
1415 .unwrap();
1416 assert!(
1417 first.status().is_success(),
1418 "conditional create on absent object failed: {first:?}"
1419 );
1420
1421 let replay = client
1422 .put(url)
1423 .header(IF_NONE_MATCH, "*")
1424 .body(b"overwrite attempt".to_vec())
1425 .send()
1426 .await
1427 .unwrap();
1428 assert_eq!(
1429 replay.status().as_u16(),
1430 412,
1431 "replay overwrite was not blocked: {replay:?}"
1432 );
1433
1434 integration.delete(&path).await.unwrap();
1435 }
1436
1437 #[tokio::test]
1438 async fn copy_multipart_file_with_signature() {
1439 maybe_skip_integration!();
1440
1441 let bucket = "test-bucket-for-copy-if-not-exists";
1442 for checksum in [Checksum::SHA256, Checksum::CRC64NVME] {
1443 let store = AmazonS3Builder::from_env()
1444 .with_bucket_name(bucket)
1445 .with_checksum_algorithm(checksum)
1446 .with_copy_if_not_exists(S3CopyIfNotExists::Multipart)
1447 .build()
1448 .unwrap();
1449
1450 let src = Path::parse("src.bin").unwrap();
1451 let dst = Path::parse("dst.bin").unwrap();
1452 store
1453 .put(&src, PutPayload::from(vec![0u8; 100_000]))
1454 .await
1455 .unwrap();
1456 if store.head(&dst).await.is_ok() {
1457 store.delete(&dst).await.unwrap();
1458 }
1459 store.copy_if_not_exists(&src, &dst).await.unwrap();
1460 store.delete(&src).await.unwrap();
1461 store.delete(&dst).await.unwrap();
1462 }
1463 }
1464
1465 #[tokio::test]
1466 async fn copy_multipart_file_with_signature_change_checksum() {
1467 maybe_skip_integration!();
1468
1469 let bucket = "test-bucket-for-copy-if-not-exists";
1470 let checksum_src = Checksum::SHA256;
1471 let checksum_dst = Checksum::CRC64NVME;
1472
1473 let src = Path::parse("change_checksum_src.bin").unwrap();
1474 let dst = Path::parse("change_checksum_dst.bin").unwrap();
1475
1476 let store = AmazonS3Builder::from_env()
1477 .with_bucket_name(bucket)
1478 .with_checksum_algorithm(checksum_src)
1479 .build()
1480 .unwrap();
1481
1482 store
1483 .put(&src, PutPayload::from(vec![0u8; 100_000]))
1484 .await
1485 .unwrap();
1486 if store.head(&dst).await.is_ok() {
1487 store.delete(&dst).await.unwrap();
1488 }
1489
1490 let store = AmazonS3Builder::from_env()
1491 .with_bucket_name(bucket)
1492 .with_checksum_algorithm(checksum_dst)
1493 .with_copy_if_not_exists(S3CopyIfNotExists::Multipart)
1494 .build()
1495 .unwrap();
1496
1497 store.copy_if_not_exists(&src, &dst).await.unwrap();
1498 store.delete(&src).await.unwrap();
1499 store.delete(&dst).await.unwrap();
1500 }
1501
1502 #[tokio::test]
1503 async fn write_multipart_file_with_signature_object_lock() {
1504 maybe_skip_integration!();
1505
1506 for checksum in [Checksum::SHA256, Checksum::CRC64NVME] {
1507 let bucket = "test-object-lock";
1508 let store = AmazonS3Builder::from_env()
1509 .with_bucket_name(bucket)
1510 .with_checksum_algorithm(checksum)
1511 .build()
1512 .unwrap();
1513
1514 let str = "test.bin";
1515 let path = Path::parse(str).unwrap();
1516 let opts = PutMultipartOptions::default();
1517 let mut upload = store.put_multipart_opts(&path, opts).await.unwrap();
1518
1519 upload
1520 .put_part(PutPayload::from(vec![0u8; 10_000_000]))
1521 .await
1522 .unwrap();
1523 upload
1524 .put_part(PutPayload::from(vec![0u8; 5_000_000]))
1525 .await
1526 .unwrap();
1527
1528 let res = upload.complete().await.unwrap();
1529 assert!(res.e_tag.is_some(), "Should have valid etag");
1530
1531 store.delete(&path).await.unwrap();
1532 }
1533 }
1534
1535 #[tokio::test]
1536 async fn s3_test() {
1537 maybe_skip_integration!();
1538 let config =
1541 AmazonS3Builder::from_env().with_http_connector(MarkerHttpConnector::default());
1542
1543 let integration = config.build().unwrap();
1544 let config = &integration.client.config;
1545 let test_not_exists = config.copy_if_not_exists.is_some();
1546 let test_conditional_put = config.conditional_put != S3ConditionalPut::Disabled;
1547
1548 put_get_delete_list(&integration).await;
1549 list_with_offset_exclusivity(&integration).await;
1550 get_opts(&integration).await;
1551 list_uses_directories_correctly(&integration).await;
1552 list_with_delimiter(&integration).await;
1553 rename_and_copy(&integration).await;
1554 stream_get(&integration).await;
1555 multipart(&integration, &integration).await;
1556 multipart_with_opts(&integration, &integration).await;
1557 multipart_put_part_out_of_order(&integration, &integration).await;
1558 multipart_race_condition(&integration, true).await;
1559 multipart_out_of_order(&integration).await;
1560 signing(&integration).await;
1561 s3_encryption(&integration).await;
1562 put_get_attributes(&integration).await;
1563 list_paginated(&integration, &integration).await;
1564 response_extensions(&integration, true).await;
1565
1566 if config.session_provider.is_none() {
1568 tagging(
1569 Arc::new(AmazonS3 {
1570 client: Arc::clone(&integration.client),
1571 }),
1572 !config.disable_tagging,
1573 |p| {
1574 let client = Arc::clone(&integration.client);
1575 async move { client.get_object_tagging(&p).await }
1576 },
1577 )
1578 .await;
1579 }
1580
1581 if test_not_exists {
1582 copy_if_not_exists(&integration).await;
1583 }
1584 if test_conditional_put {
1585 put_opts(&integration, true).await;
1586 }
1587
1588 let builder = AmazonS3Builder::from_env().with_unsigned_payload(true);
1590 let integration = builder.build().unwrap();
1591 put_get_delete_list(&integration).await;
1592
1593 let builder = AmazonS3Builder::from_env().with_checksum_algorithm(Checksum::SHA256);
1595 let integration = builder.build().unwrap();
1596 put_get_delete_list(&integration).await;
1597
1598 let builder = AmazonS3Builder::from_env().with_checksum_algorithm(Checksum::CRC64NVME);
1600 let integration = builder.build().unwrap();
1601 put_get_delete_list(&integration).await;
1602 }
1603
1604 #[tokio::test]
1605 async fn s3_test_get_nonexistent_location() {
1606 maybe_skip_integration!();
1607 let integration = AmazonS3Builder::from_env().build().unwrap();
1608
1609 let location = Path::from_iter([NON_EXISTENT_NAME]);
1610
1611 let err = get_nonexistent_object(&integration, Some(location))
1612 .await
1613 .unwrap_err();
1614 assert!(matches!(err, crate::Error::NotFound { .. }), "{}", err);
1615 }
1616
1617 #[tokio::test]
1618 async fn s3_test_get_nonexistent_bucket() {
1619 maybe_skip_integration!();
1620 let config = AmazonS3Builder::from_env().with_bucket_name(NON_EXISTENT_NAME);
1621 let integration = config.build().unwrap();
1622
1623 let location = Path::from_iter([NON_EXISTENT_NAME]);
1624
1625 let err = integration.get(&location).await.unwrap_err();
1626 assert!(matches!(err, crate::Error::NotFound { .. }), "{}", err);
1627 }
1628
1629 #[tokio::test]
1630 async fn s3_test_put_nonexistent_bucket() {
1631 maybe_skip_integration!();
1632 let config = AmazonS3Builder::from_env().with_bucket_name(NON_EXISTENT_NAME);
1633 let integration = config.build().unwrap();
1634
1635 let location = Path::from_iter([NON_EXISTENT_NAME]);
1636 let data = PutPayload::from("arbitrary data");
1637
1638 let err = integration.put(&location, data).await.unwrap_err();
1639 assert!(matches!(err, crate::Error::NotFound { .. }), "{}", err);
1640 }
1641
1642 #[tokio::test]
1643 async fn s3_test_delete_nonexistent_location() {
1644 maybe_skip_integration!();
1645 let integration = AmazonS3Builder::from_env().build().unwrap();
1646
1647 let location = Path::from_iter([NON_EXISTENT_NAME]);
1648
1649 integration.delete(&location).await.unwrap();
1650 }
1651
1652 #[tokio::test]
1653 async fn s3_test_delete_nonexistent_bucket() {
1654 maybe_skip_integration!();
1655 let config = AmazonS3Builder::from_env().with_bucket_name(NON_EXISTENT_NAME);
1656 let integration = config.build().unwrap();
1657
1658 let location = Path::from_iter([NON_EXISTENT_NAME]);
1659
1660 let err = integration.delete(&location).await.unwrap_err();
1661 assert!(matches!(err, crate::Error::NotFound { .. }), "{}", err);
1662 }
1663
1664 #[tokio::test]
1665 #[ignore = "Tests shouldn't call use remote services by default"]
1666 async fn test_disable_creds() {
1667 let v1 = AmazonS3Builder::new()
1669 .with_bucket_name("daylight-map-distribution")
1670 .with_region("us-west-1")
1671 .with_access_key_id("local")
1672 .with_secret_access_key("development")
1673 .build()
1674 .unwrap();
1675
1676 let prefix = Path::from("release");
1677
1678 v1.list_with_delimiter(Some(&prefix)).await.unwrap_err();
1679
1680 let v2 = AmazonS3Builder::new()
1681 .with_bucket_name("daylight-map-distribution")
1682 .with_region("us-west-1")
1683 .with_skip_signature(true)
1684 .build()
1685 .unwrap();
1686
1687 v2.list_with_delimiter(Some(&prefix)).await.unwrap();
1688 }
1689
1690 async fn s3_encryption(store: &AmazonS3) {
1691 maybe_skip_integration!();
1692
1693 let data = PutPayload::from(vec![3u8; 1024]);
1694
1695 let encryption_headers: HeaderMap = store.client.config.encryption_headers.clone().into();
1696 let expected_encryption =
1697 if let Some(encryption_type) = encryption_headers.get("x-amz-server-side-encryption") {
1698 encryption_type
1699 } else {
1700 eprintln!("Skipping S3 encryption test - encryption not configured");
1701 return;
1702 };
1703
1704 let locations = [
1705 Path::from("test-encryption-1"),
1706 Path::from("test-encryption-2"),
1707 Path::from("test-encryption-3"),
1708 ];
1709
1710 store.put(&locations[0], data.clone()).await.unwrap();
1711 store.copy(&locations[0], &locations[1]).await.unwrap();
1712
1713 let mut upload = store.put_multipart(&locations[2]).await.unwrap();
1714 upload.put_part(data.clone()).await.unwrap();
1715 upload.complete().await.unwrap();
1716
1717 for location in &locations {
1718 let mut context = RetryContext::new(&store.client.config.retry_config);
1719
1720 let res = store
1721 .client
1722 .get_request(&mut context, location, GetOptions::default())
1723 .await
1724 .unwrap();
1725
1726 let headers = res.headers();
1727 assert_eq!(
1728 headers
1729 .get("x-amz-server-side-encryption")
1730 .expect("object is not encrypted"),
1731 expected_encryption
1732 );
1733
1734 store.delete(location).await.unwrap();
1735 }
1736 }
1737
1738 #[tokio::test]
1740 async fn test_s3_ssec_encryption_with_minio() {
1741 if std::env::var("TEST_S3_SSEC_ENCRYPTION").is_err() {
1742 eprintln!("Skipping S3 SSE-C encryption test");
1743 return;
1744 }
1745 eprintln!("Running S3 SSE-C encryption test");
1746
1747 let customer_key = "1234567890abcdef1234567890abcdef";
1748 let expected_md5 = "JMwgiexXqwuPqIPjYFmIZQ==";
1749
1750 let store = AmazonS3Builder::from_env()
1751 .with_ssec_encryption(BASE64_STANDARD.encode(customer_key))
1752 .with_client_options(ClientOptions::default().with_allow_invalid_certificates(true))
1753 .build()
1754 .unwrap();
1755
1756 let data = PutPayload::from(vec![3u8; 1024]);
1757
1758 let locations = [
1759 Path::from("test-encryption-1"),
1760 Path::from("test-encryption-2"),
1761 Path::from("test-encryption-3"),
1762 ];
1763
1764 store.put(&locations[0], data.clone()).await.unwrap();
1766
1767 store.copy(&locations[0], &locations[1]).await.unwrap();
1769
1770 let mut upload = store.put_multipart(&locations[2]).await.unwrap();
1772 upload.put_part(data.clone()).await.unwrap();
1773 upload.complete().await.unwrap();
1774
1775 for location in &locations {
1777 let mut context = RetryContext::new(&store.client.config.retry_config);
1778
1779 let res = store
1780 .client
1781 .get_request(&mut context, location, GetOptions::default())
1782 .await
1783 .unwrap();
1784
1785 let headers = res.headers();
1786 assert_eq!(
1787 headers
1788 .get("x-amz-server-side-encryption-customer-algorithm")
1789 .expect("object is not encrypted with SSE-C"),
1790 "AES256"
1791 );
1792
1793 assert_eq!(
1794 headers
1795 .get("x-amz-server-side-encryption-customer-key-MD5")
1796 .expect("object is not encrypted with SSE-C"),
1797 expected_md5
1798 );
1799
1800 store.delete(location).await.unwrap();
1801 }
1802 }
1803
1804 #[cfg(feature = "reqwest")]
1807 #[test]
1808 fn s3_alternate_threadpool_spawned_request_connector() {
1809 maybe_skip_integration!();
1810 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
1811
1812 let io_runtime = tokio::runtime::Builder::new_current_thread()
1814 .enable_all() .build()
1816 .unwrap();
1817
1818 let non_io_runtime = tokio::runtime::Builder::new_current_thread()
1820 .build()
1822 .unwrap();
1823
1824 let io_handle = io_runtime.handle().clone();
1826 let thread_handle = std::thread::spawn(move || {
1827 io_runtime.block_on(async move {
1828 shutdown_rx.await.unwrap();
1829 });
1830 });
1831
1832 let store = AmazonS3Builder::from_env()
1833 .with_bucket_name("test-bucket-for-spawn")
1835 .with_http_connector(SpawnedReqwestConnector::new(io_handle))
1836 .build()
1837 .unwrap();
1838
1839 non_io_runtime
1842 .block_on(async move {
1843 let path = Path::from("alternate_threadpool/test.txt");
1844 store.delete(&path).await.ok(); store.put(&path, "foo".into()).await?;
1846 let res = store.get(&path).await?.bytes().await?;
1847 assert_eq!(res.as_ref(), b"foo");
1848 store.delete(&path).await?; Ok(()) as Result<()>
1850 })
1851 .expect("failed to run request on non io runtime");
1852
1853 shutdown_tx.send(()).ok();
1855 thread_handle.join().expect("runtime thread panicked");
1856 }
1857}