Skip to main content

object_store/aws/
mod.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! An object store implementation for S3
19//!
20//! See the [Feature Flags](crate#feature-flags) section for the difference
21//! between the `aws` and `aws-base` features.
22//!
23//! ## Multipart uploads
24//!
25//! Multipart uploads can be initiated with the [`ObjectStore::put_multipart_opts`] method.
26//!
27//! If the writer fails for any reason, you may have parts uploaded to AWS but not
28//! used that you will be charged for. [`MultipartUpload::abort`] may be invoked to drop
29//! these unneeded parts, however, it is recommended that you consider implementing
30//! [automatic cleanup] of unused parts that are older than some threshold.
31//!
32//! [automatic cleanup]: https://aws.amazon.com/blogs/aws/s3-lifecycle-management-update-support-for-multipart-uploads-and-delete-markers/
33
34use 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
75/// This struct is used to maintain the URI path encoding
76const STRICT_PATH_ENCODE_SET: percent_encoding::AsciiSet = STRICT_ENCODE_SET.remove(b'/');
77
78const STORE: &str = "S3";
79
80/// [`CredentialProvider`] for [`AmazonS3`]
81pub 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/// Interface for [Amazon S3](https://aws.amazon.com/s3/).
87#[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    /// Returns the [`AwsCredentialProvider`] used by [`AmazonS3`]
100    pub fn credentials(&self) -> &AwsCredentialProvider {
101        &self.client.config.credentials
102    }
103
104    /// Create a full URL to the resource specified by `path` with this instance's configuration.
105    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    /// Create a URL containing the relevant [AWS SigV4] query parameters that authorize a request
113    /// via `method` to the resource at `path` valid for the duration specified in `expires_in`.
114    ///
115    /// [AWS SigV4]: https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html
116    ///
117    /// # Example
118    ///
119    /// This example returns a URL that will enable a user to upload a file to
120    /// "some-folder/some-file.txt" in the next hour.
121    ///
122    /// ```
123    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
124    /// # use object_store::{aws::AmazonS3Builder, path::Path, signer::Signer};
125    /// # use http::Method;
126    /// # use std::time::Duration;
127    /// #
128    /// let region = "us-east-1";
129    /// let s3 = AmazonS3Builder::new()
130    ///     .with_region(region)
131    ///     .with_bucket_name("my-bucket")
132    ///     .with_access_key_id("my-access-key-id")
133    ///     .with_secret_access_key("my-secret-access-key")
134    ///     .build()?;
135    ///
136    /// let url = s3.signed_url(
137    ///     Method::PUT,
138    ///     &Path::from("some-folder/some-file.txt"),
139    ///     Duration::from_secs(60 * 60)
140    /// ).await?;
141    /// #     Ok(())
142    /// # }
143    /// ```
144    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    /// Create a signed URL, additionally folding the query parameters and headers in `options`
150    /// into the SigV4 signature.
151    ///
152    /// `extra_query` lets callers sign query parameters that the recipient must send, such as
153    /// `partNumber` and `uploadId` for a multipart `UploadPart`, or a `versionId`. `signed_headers`
154    /// binds specific request headers (e.g. `x-amz-checksum-sha256`, `content-type`, SSE headers)
155    /// to the signature; the recipient must send exactly these headers and values.
156    ///
157    /// # Example
158    ///
159    /// ```
160    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
161    /// # use object_store::{aws::AmazonS3Builder, path::Path, signer::{Signer, SignedUrlOptions}};
162    /// # use http::Method;
163    /// # use std::time::Duration;
164    /// #
165    /// let s3 = AmazonS3Builder::new()
166    ///     .with_region("us-east-1")
167    ///     .with_bucket_name("my-bucket")
168    ///     .with_access_key_id("my-access-key-id")
169    ///     .with_secret_access_key("my-secret-access-key")
170    ///     .build()?;
171    ///
172    /// // Presign a multipart UploadPart request.
173    /// let options = SignedUrlOptions::default()
174    ///     .with_query([("partNumber", "1"), ("uploadId", "abc123")]);
175    /// let url = s3.signed_url_opts(
176    ///     Method::PUT,
177    ///     &Path::from("some-folder/some-file.txt"),
178    ///     Duration::from_secs(60 * 60),
179    ///     &options,
180    /// ).await?;
181    /// #     Ok(())
182    /// # }
183    /// ```
184    ///
185    /// See [`AmazonS3::create_multipart`] for a complete end-to-end example that presigns every
186    /// part of a multipart upload so a credential-less client can upload them.
187    ///
188    /// [`AmazonS3::create_multipart`]: crate::aws::AmazonS3::create_multipart
189    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 the caller-provided extras up front, rejecting reserved query parameters and
197        // headers controlled by the signer.
198        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                    // Technically If-None-Match should return NotModified but some stores,
263                    // such as R2, instead return PreconditionFailed
264                    // https://developers.cloudflare.com/r2/api/s3/extensions/#conditional-operations-in-putobject
265                    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                            // Real S3 will occasionally report 409 Conflict
284                            // if there are concurrent `If-Match` requests
285                            // in flight, so we need to be prepared to retry
286                            // 409 responses.
287                            .retry_on_conflict(true)
288                            .do_put()
289                            .await
290                        {
291                            // Real S3 reports NotFound rather than PreconditionFailed when the
292                            // object doesn't exist. Convert to PreconditionFailed for
293                            // consistency with R2. This also matches what the HTTP spec
294                            // says the behavior should be.
295                            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        // Some S3-compatible providers do not implement
343        // the bulk `DeleteObjects` API (`POST /?delete`). When bulk delete is
344        // disabled, fall back to parallel single-object `DELETE /key` requests,
345        // which are part of the core S3 API supported by every provider.
346        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                    // Early return the error. We ignore the paths that have already been
366                    // collected into the chunk.
367                    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            // S3 Express does not support start-after
391            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 the multipart upload failed, make a best effort attempt to
458                        // clean it up. It's the caller's responsibility to add a
459                        // lifecycle rule if guaranteed cleanup is required, as we
460                        // cannot protect against an ill-timed process crash.
461                        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    /// Create a new multipart upload, returning its [`MultipartId`].
567    ///
568    /// This is the low-level [`MultipartStore`] API, which gives direct control
569    /// over individual parts. See [`ObjectStoreExt::put_multipart`] for a
570    /// higher-level API that handles parts automatically.
571    ///
572    /// # Example
573    ///
574    /// Create a multipart upload, upload two parts, and finalize it by calling
575    /// [`complete_multipart`]:
576    ///
577    /// ```no_run
578    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
579    /// # use object_store::{aws::AmazonS3Builder, multipart::MultipartStore, path::Path, PutPayload};
580    /// #
581    /// let s3 = AmazonS3Builder::new()
582    ///     .with_region("us-east-1")
583    ///     .with_bucket_name("my-bucket")
584    ///     .with_access_key_id("my-access-key-id")
585    ///     .with_secret_access_key("my-secret-access-key")
586    ///     .build()?;
587    ///
588    /// let path = Path::from("data/large_file");
589    ///
590    /// // Start the upload, obtaining an id used to reference it in later calls
591    /// let id = s3.create_multipart(&path).await?;
592    ///
593    /// // Upload the individual parts. Every part except the last must be at least
594    /// // 5 MiB.
595    /// let part0 = s3
596    ///     .put_part(&path, &id, 0, PutPayload::from(vec![0; 5 * 1024 * 1024]))
597    ///     .await?;
598    /// let part1 = s3
599    ///     .put_part(&path, &id, 1, PutPayload::from("the final part"))
600    ///     .await?;
601    ///
602    /// // Finalize the upload. The parts must be provided in `part_idx` order.
603    /// s3.complete_multipart(&path, &id, vec![part0, part1]).await?;
604    /// #     Ok(())
605    /// # }
606    /// ```
607    ///
608    /// Every part except the last must be at least 5 MiB. Parts may be uploaded
609    /// concurrently and in any order, provided each is given the correct
610    /// `part_idx`. See [Amazon S3 multipart upload limits] for the full set of
611    /// size and count constraints.
612    ///
613    /// To discard an upload instead of completing it, call
614    /// [`abort_multipart`] with the same `id`.
615    ///
616    /// # Example: presigned multipart upload
617    ///
618    /// The same upload can be driven by a client that holds no credentials. The credential holder
619    /// starts the upload and presigns an `UploadPart` URL for each part with
620    /// [`Signer::signed_url_opts`]; the client `PUT`s its bytes to that URL and returns the `ETag`
621    /// from the response; the credential holder then completes the upload with those ETags. This
622    /// is how you hand out per-part upload URLs without sharing credentials.
623    ///
624    /// ```no_run
625    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
626    /// # use object_store::{aws::AmazonS3Builder, multipart::{MultipartStore, PartId}, path::Path};
627    /// # use object_store::signer::{Signer, SignedUrlOptions};
628    /// # use http::Method;
629    /// # use std::time::Duration;
630    /// #
631    /// let s3 = AmazonS3Builder::new()
632    ///     .with_region("us-east-1")
633    ///     .with_bucket_name("my-bucket")
634    ///     .with_access_key_id("my-access-key-id")
635    ///     .with_secret_access_key("my-secret-access-key")
636    ///     .build()?;
637    ///
638    /// let path = Path::from("data/large_file");
639    ///
640    /// // Credential holder: start the upload.
641    /// let id = s3.create_multipart(&path).await?;
642    ///
643    /// // Credential holder: presign an `UploadPart` URL for each part. `partNumber` and `uploadId`
644    /// // must be signed query parameters, or S3 rejects the request.
645    /// let mut part_urls = Vec::new();
646    /// for part_number in 1..=3 {
647    ///     let options = SignedUrlOptions::default().with_query([
648    ///         ("partNumber", part_number.to_string()),
649    ///         ("uploadId", id.clone()),
650    ///     ]);
651    ///     let url = s3
652    ///         .signed_url_opts(Method::PUT, &path, Duration::from_secs(60 * 60), &options)
653    ///         .await?;
654    ///     part_urls.push(url);
655    /// }
656    ///
657    /// // Client (no credentials): `PUT` each part's bytes to its URL, read the `ETag` response
658    /// // header, and return the ETags to the credential holder in part order, e.g. with `reqwest`:
659    /// //
660    /// //     let resp = reqwest::Client::new().put(url).body(bytes).send().await?;
661    /// //     let etag = resp.headers()[http::header::ETAG].to_str()?.to_string();
662    /// let etags: Vec<String> = // returned by the client
663    /// #     vec![];
664    ///
665    /// // Credential holder: complete the upload with the collected ETags.
666    /// let parts = etags.into_iter().map(|content_id| PartId { content_id }).collect();
667    /// s3.complete_multipart(&path, &id, parts).await?;
668    /// #     Ok(())
669    /// # }
670    /// ```
671    ///
672    /// [`ObjectStoreExt::put_multipart`]: crate::ObjectStoreExt::put_multipart
673    /// [`complete_multipart`]: MultipartStore::complete_multipart
674    /// [`abort_multipart`]: MultipartStore::abort_multipart
675    /// [`Signer::signed_url_opts`]: crate::signer::Signer::signed_url_opts
676    /// [Amazon S3 multipart upload limits]: https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html
677    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    /// Presigned-URL tests run against a dedicated bucket so their concurrent writes cannot
762    /// contaminate the shared `test-bucket`, whose exact contents `s3_test` asserts.
763    ///
764    /// Set `OBJECT_STORE_SIGNING_BUCKET` to run them against a bucket in your own account
765    /// (e.g. real S3) without editing this source; it defaults to `test-bucket-for-signing`.
766    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    /// Builds the signing store and preflights write access with an authenticated `PutObject`
774    /// probe that fails fast, with an actionable message, if the credentials cannot write to the
775    /// bucket. Without this a permissions problem surfaces as an opaque `403 AccessDenied` panic
776    /// deep inside whichever presign assertion happens to run first (which is exactly what made a
777    /// reviewer's real-S3 run hard to diagnose).
778    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        // Best-effort cleanup; the probe object is harmless if it lingers.
798        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        // Exercises presigning a multipart `UploadPart` request: the `partNumber` and `uploadId`
906        // query parameters must be folded into the signature, and the resulting URL must be
907        // usable by a client that has no access to the credentials.
908        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        // Upload the part using only the presigned URL, as a credential-less client would.
924        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        // Presign a PUT that binds `x-amz-checksum-sha256` to a fixed value. This is the
959        // storage-enforced-checksum path: the server must accept a body matching the signed
960        // checksum and reject one that does not, and the header is part of the signature so it
961        // cannot be omitted.
962        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        // base64(sha256(b"hello world")), computed independently.
969        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        // 1. Matching body + checksum header is accepted.
983        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        // 2. A body that does not match the signed checksum is rejected (server enforces it).
996        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        // 3. Omitting the signed header is rejected (the header is bound to the signature).
1009        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        // Presign a PUT binding a `content-type` (with internal whitespace). The recipient must
1027        // send exactly this value; a different one breaks the signature.
1028        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        // Matching content-type is accepted.
1048        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        // A different content-type is rejected (the header is bound to the signature).
1061        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        // A signed query value containing a space must be `%20`-encoded so the URL bytes match
1081        // the canonical query string; a real server rejects the signature otherwise. Uses a GET
1082        // with a `response-content-disposition` override, whose value contains spaces.
1083        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        // The server honoured the override, echoing it back in the response.
1104        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        // Regression: the no-options path (now routed through `signed_url_opts`) still produces a
1125        // working presigned PUT and GET.
1126        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        // Full multipart round trip with three parts (exercising S3's 5 MiB minimum-part rule)
1155        // and query parameters supplied in non-alphabetical order, proving the signer sorts the
1156        // canonical query string rather than signing in call order.
1157        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            // `uploadId` is supplied before `partNumber` — i.e. not in canonical (sorted) order.
1171            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        // Proves the signed query parameters and headers are actually bound to the signature:
1217        // mutating them must produce SignatureDoesNotMatch, while an extra *unsigned* header is
1218        // accepted (we don't over-constrain).
1219        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        // --- query parameter tampering, in a multipart context ---
1225        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        // Mutating partNumber breaks the signature.
1235        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        // Mutating uploadId breaks the signature.
1249        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        // An extra *unsigned* header is fine — only signed headers are bound. This also actually
1264        // uploads the part, leaving the multipart upload to be reaped by teardown.
1265        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        // --- signed header value tampering ---
1278        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        // Sending a *different* (well-formed) value than was signed breaks the signature itself,
1288        // i.e. 403 rather than a checksum (400) error.
1289        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        // Proves path percent-encoding and signed query parameters coexist: a key containing a
1314        // space, `=`, and a non-ASCII character is presigned for a multipart part and lands at the
1315        // correct key. This is the exact path/query conflation class of bug we fixed.
1316        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        // Read back through the normal (credentialed) path to confirm the object landed at the
1351        // intended key.
1352        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        // A short-lived signed URL is rejected after it expires, confirming the TTL is part of the
1364        // signed policy.
1365        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        // Clean up before asserting: if the PUT was wrongly accepted, the object exists and would
1382        // otherwise leak past the panic into the shared bucket.
1383        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        // A presigned PUT that signs `If-None-Match: *` succeeds when the object is absent and is
1396        // rejected (412) on replay once the object exists — the leaked-URL replay guard.
1397        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        // tag the extensions of every HTTP response with a marker,
1539        // allowing response_extensions to verify their propagation
1540        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        // Object tagging is not supported by S3 Express One Zone
1567        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        // run integration test with unsigned payload enabled
1589        let builder = AmazonS3Builder::from_env().with_unsigned_payload(true);
1590        let integration = builder.build().unwrap();
1591        put_get_delete_list(&integration).await;
1592
1593        // run integration test with checksum set to sha256
1594        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        // run integration test with checksum set to crc64nvme
1599        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        // https://registry.opendata.aws/daylight-osm/
1668        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    /// See CONTRIBUTING.md for the MinIO setup for this test.
1739    #[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        // Test put with sse-c.
1765        store.put(&locations[0], data.clone()).await.unwrap();
1766
1767        // Test copy with sse-c.
1768        store.copy(&locations[0], &locations[1]).await.unwrap();
1769
1770        // Test multipart upload with sse-c.
1771        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        // Test get with sse-c.
1776        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    /// Integration test that ensures I/O is done on an alternate threadpool
1805    /// when using the `SpawnedReqwestConnector`.
1806    #[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        // Runtime with I/O enabled
1813        let io_runtime = tokio::runtime::Builder::new_current_thread()
1814            .enable_all() // <-- turns on IO
1815            .build()
1816            .unwrap();
1817
1818        // Runtime without I/O enabled
1819        let non_io_runtime = tokio::runtime::Builder::new_current_thread()
1820            // note: no call to enable_all
1821            .build()
1822            .unwrap();
1823
1824        // run the io runtime in a different thread
1825        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            // use different bucket to avoid collisions with other tests
1834            .with_bucket_name("test-bucket-for-spawn")
1835            .with_http_connector(SpawnedReqwestConnector::new(io_handle))
1836            .build()
1837            .unwrap();
1838
1839        // run a request on the non io runtime -- will fail if the connector
1840        // does not spawn the request to the io runtime
1841        non_io_runtime
1842            .block_on(async move {
1843                let path = Path::from("alternate_threadpool/test.txt");
1844                store.delete(&path).await.ok(); // remove the file if it exists from prior runs
1845                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?; // cleanup
1849                Ok(()) as Result<()>
1850            })
1851            .expect("failed to run request on non io runtime");
1852
1853        // shutdown the io runtime and thread
1854        shutdown_tx.send(()).ok();
1855        thread_handle.join().expect("runtime thread panicked");
1856    }
1857}