Skip to main content

opendal_service_s3/
backend.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
18use std::collections::HashMap;
19use std::fmt::Debug;
20use std::fmt::Write;
21use std::sync::Arc;
22use std::sync::LazyLock;
23
24use base64::Engine;
25use base64::prelude::BASE64_STANDARD;
26use bytes::Buf;
27use http::StatusCode;
28use log::debug;
29use log::warn;
30use md5::Digest;
31use md5::Md5;
32use reqsign_aws_v4::AssumeRoleCredentialProvider;
33use reqsign_aws_v4::Credential;
34use reqsign_aws_v4::DefaultCredentialProvider;
35use reqsign_aws_v4::RequestSigner as AwsV4Signer;
36use reqsign_aws_v4::S3ExpressSessionConfig;
37use reqsign_aws_v4::S3ExpressSessionProvider;
38use reqsign_aws_v4::StaticCredentialProvider;
39use reqsign_core::Context;
40use reqsign_core::OsEnv;
41use reqsign_core::ProvideCredentialChain;
42use reqsign_core::Signer;
43use reqsign_file_read_tokio::TokioFileRead;
44use url::Url;
45
46use crate::S3_SCHEME;
47use crate::config::S3Config;
48use crate::copier::S3Copiers;
49use crate::copier::new_s3_copier;
50use crate::core::parse_error;
51use crate::core::*;
52use crate::deleter::S3Deleter;
53use crate::lister::S3ListerV1;
54use crate::lister::S3ListerV2;
55use crate::lister::S3Listers;
56use crate::lister::S3ObjectVersionsLister;
57use crate::reader::*;
58use crate::writer::S3Writer;
59use crate::writer::S3Writers;
60use opendal_core::raw::oio::Copy;
61use opendal_core::raw::*;
62use opendal_core::*;
63
64/// Allow constructing correct region endpoint if user gives a global endpoint.
65static ENDPOINT_TEMPLATES: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
66    let mut m = HashMap::new();
67    // AWS S3 Service.
68    m.insert(
69        "https://s3.amazonaws.com",
70        "https://s3.{region}.amazonaws.com",
71    );
72    m
73});
74
75const DEFAULT_BATCH_MAX_OPERATIONS: usize = 1000;
76
77/// Aws S3 and compatible services (including minio, digitalocean space, Tencent Cloud Object Storage(COS) and so on) support.
78/// For more information about s3-compatible services, refer to [Compatible Services](#compatible-services).
79#[doc = include_str!("docs.md")]
80#[doc = include_str!("compatible_services.md")]
81#[derive(Default)]
82pub struct S3Builder {
83    pub(super) config: S3Config,
84    pub(super) credential_providers: Option<ProvideCredentialChain<Credential>>,
85}
86
87impl Debug for S3Builder {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_struct("S3Builder")
90            .field("config", &self.config)
91            .finish_non_exhaustive()
92    }
93}
94
95impl S3Builder {
96    /// Set root of this backend.
97    ///
98    /// All operations will happen under this root.
99    pub fn root(mut self, root: &str) -> Self {
100        self.config.root = if root.is_empty() {
101            None
102        } else {
103            Some(root.to_string())
104        };
105
106        self
107    }
108
109    /// Set bucket name of this backend.
110    pub fn bucket(mut self, bucket: &str) -> Self {
111        self.config.bucket = bucket.to_string();
112
113        self
114    }
115
116    /// Set endpoint of this backend.
117    ///
118    /// Endpoint must be full uri, e.g.
119    ///
120    /// - AWS S3: `https://s3.amazonaws.com` or `https://s3.{region}.amazonaws.com`
121    /// - Cloudflare R2: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`
122    /// - Aliyun OSS: `https://{region}.aliyuncs.com`
123    /// - Tencent COS: `https://cos.{region}.myqcloud.com`
124    /// - Minio: `http://127.0.0.1:9000`
125    ///
126    /// If user inputs endpoint without scheme like "s3.amazonaws.com", we
127    /// will prepend "https://" before it.
128    pub fn endpoint(mut self, endpoint: &str) -> Self {
129        if !endpoint.is_empty() {
130            // Trim trailing `/` so that we can accept `http://127.0.0.1:9000/`
131            self.config.endpoint = Some(endpoint.trim_end_matches('/').to_string())
132        }
133
134        self
135    }
136
137    /// Region represent the signing region of this endpoint. This is required
138    /// if you are using the default AWS S3 endpoint.
139    ///
140    /// If using a custom endpoint,
141    /// - If region is set, we will take user's input first.
142    /// - If not, we will try to load it from environment.
143    pub fn region(mut self, region: &str) -> Self {
144        if !region.is_empty() {
145            self.config.region = Some(region.to_string())
146        }
147
148        self
149    }
150
151    /// Set the AWS profile used by the default credential provider chain.
152    ///
153    /// The configured profile takes precedence over the `AWS_PROFILE`
154    /// environment variable and applies to shared AWS config and credentials
155    /// files and SSO.
156    ///
157    /// This setting has no effect when [`Self::disable_config_load`] is set or
158    /// when [`Self::credential_provider_chain`] replaces the default chain.
159    pub fn profile(mut self, profile: &str) -> Self {
160        if !profile.is_empty() {
161            self.config.profile = Some(profile.to_string())
162        }
163
164        self
165    }
166
167    /// Set access_key_id of this backend.
168    ///
169    /// - If access_key_id is set, we will take user's input first.
170    /// - If not, we will try to load it from environment.
171    pub fn access_key_id(mut self, v: &str) -> Self {
172        if !v.is_empty() {
173            self.config.access_key_id = Some(v.to_string())
174        }
175
176        self
177    }
178
179    /// Set secret_access_key of this backend.
180    ///
181    /// - If secret_access_key is set, we will take user's input first.
182    /// - If not, we will try to load it from environment.
183    pub fn secret_access_key(mut self, v: &str) -> Self {
184        if !v.is_empty() {
185            self.config.secret_access_key = Some(v.to_string())
186        }
187
188        self
189    }
190
191    /// Set role_arn for this backend.
192    ///
193    /// If `role_arn` is set, we will use already known config as source
194    /// credential to assume role with `role_arn`.
195    pub fn role_arn(mut self, v: &str) -> Self {
196        if !v.is_empty() {
197            self.config.role_arn = Some(v.to_string())
198        }
199
200        self
201    }
202
203    /// Set external_id for this backend.
204    pub fn external_id(mut self, v: &str) -> Self {
205        if !v.is_empty() {
206            self.config.external_id = Some(v.to_string())
207        }
208
209        self
210    }
211
212    /// Set role_session_name for this backend.
213    pub fn role_session_name(mut self, v: &str) -> Self {
214        if !v.is_empty() {
215            self.config.role_session_name = Some(v.to_string())
216        }
217
218        self
219    }
220
221    /// Set assume_role_duration_seconds for this backend.
222    pub fn assume_role_duration_seconds(mut self, v: u32) -> Self {
223        self.config.assume_role_duration_seconds = Some(v);
224        self
225    }
226
227    /// Set assume_role_session_tags for this backend.
228    pub fn assume_role_session_tags(mut self, tags: HashMap<String, String>) -> Self {
229        self.config.assume_role_session_tags = Some(tags);
230        self
231    }
232
233    /// Set default storage_class for this backend.
234    ///
235    /// Available values:
236    /// - `DEEP_ARCHIVE`
237    /// - `GLACIER`
238    /// - `GLACIER_IR`
239    /// - `INTELLIGENT_TIERING`
240    /// - `ONEZONE_IA`
241    /// - `OUTPOSTS`
242    /// - `REDUCED_REDUNDANCY`
243    /// - `STANDARD`
244    /// - `STANDARD_IA`
245    pub fn default_storage_class(mut self, v: &str) -> Self {
246        if !v.is_empty() {
247            self.config.default_storage_class = Some(v.to_string())
248        }
249
250        self
251    }
252
253    /// Set server_side_encryption for this backend.
254    ///
255    /// Available values: `AES256`, `aws:kms`.
256    ///
257    /// # Note
258    ///
259    /// This function is the low-level setting for SSE related features.
260    ///
261    /// SSE related options should be set carefully to make them works.
262    /// Please use `server_side_encryption_with_*` helpers if even possible.
263    pub fn server_side_encryption(mut self, v: &str) -> Self {
264        if !v.is_empty() {
265            self.config.server_side_encryption = Some(v.to_string())
266        }
267
268        self
269    }
270
271    /// Set server_side_encryption_aws_kms_key_id for this backend
272    ///
273    /// - If `server_side_encryption` set to `aws:kms`, and `server_side_encryption_aws_kms_key_id`
274    ///   is not set, S3 will use aws managed kms key to encrypt data.
275    /// - If `server_side_encryption` set to `aws:kms`, and `server_side_encryption_aws_kms_key_id`
276    ///   is a valid kms key id, S3 will use the provided kms key to encrypt data.
277    /// - If the `server_side_encryption_aws_kms_key_id` is invalid or not found, an error will be
278    ///   returned.
279    /// - If `server_side_encryption` is not `aws:kms`, setting `server_side_encryption_aws_kms_key_id` is a noop.
280    ///
281    /// # Note
282    ///
283    /// This function is the low-level setting for SSE related features.
284    ///
285    /// SSE related options should be set carefully to make them works.
286    /// Please use `server_side_encryption_with_*` helpers if even possible.
287    pub fn server_side_encryption_aws_kms_key_id(mut self, v: &str) -> Self {
288        if !v.is_empty() {
289            self.config.server_side_encryption_aws_kms_key_id = Some(v.to_string())
290        }
291
292        self
293    }
294
295    /// Set server_side_encryption_customer_algorithm for this backend.
296    ///
297    /// Available values: `AES256`.
298    ///
299    /// # Note
300    ///
301    /// This function is the low-level setting for SSE related features.
302    ///
303    /// SSE related options should be set carefully to make them works.
304    /// Please use `server_side_encryption_with_*` helpers if even possible.
305    pub fn server_side_encryption_customer_algorithm(mut self, v: &str) -> Self {
306        if !v.is_empty() {
307            self.config.server_side_encryption_customer_algorithm = Some(v.to_string())
308        }
309
310        self
311    }
312
313    /// Set server_side_encryption_customer_key for this backend.
314    ///
315    /// # Args
316    ///
317    /// `v`: base64 encoded key that matches algorithm specified in
318    /// `server_side_encryption_customer_algorithm`.
319    ///
320    /// # Note
321    ///
322    /// This function is the low-level setting for SSE related features.
323    ///
324    /// SSE related options should be set carefully to make them works.
325    /// Please use `server_side_encryption_with_*` helpers if even possible.
326    pub fn server_side_encryption_customer_key(mut self, v: &str) -> Self {
327        if !v.is_empty() {
328            self.config.server_side_encryption_customer_key = Some(v.to_string())
329        }
330
331        self
332    }
333
334    /// Set server_side_encryption_customer_key_md5 for this backend.
335    ///
336    /// # Args
337    ///
338    /// `v`: MD5 digest of key specified in `server_side_encryption_customer_key`.
339    ///
340    /// # Note
341    ///
342    /// This function is the low-level setting for SSE related features.
343    ///
344    /// SSE related options should be set carefully to make them works.
345    /// Please use `server_side_encryption_with_*` helpers if even possible.
346    pub fn server_side_encryption_customer_key_md5(mut self, v: &str) -> Self {
347        if !v.is_empty() {
348            self.config.server_side_encryption_customer_key_md5 = Some(v.to_string())
349        }
350
351        self
352    }
353
354    /// Enable server side encryption with aws managed kms key
355    ///
356    /// As known as: SSE-KMS
357    ///
358    /// NOTE: This function should not be used along with other `server_side_encryption_with_` functions.
359    pub fn server_side_encryption_with_aws_managed_kms_key(mut self) -> Self {
360        self.config.server_side_encryption = Some("aws:kms".to_string());
361        self
362    }
363
364    /// Enable server side encryption with customer managed kms key
365    ///
366    /// As known as: SSE-KMS
367    ///
368    /// NOTE: This function should not be used along with other `server_side_encryption_with_` functions.
369    pub fn server_side_encryption_with_customer_managed_kms_key(
370        mut self,
371        aws_kms_key_id: &str,
372    ) -> Self {
373        self.config.server_side_encryption = Some("aws:kms".to_string());
374        self.config.server_side_encryption_aws_kms_key_id = Some(aws_kms_key_id.to_string());
375        self
376    }
377
378    /// Enable server side encryption with s3 managed key
379    ///
380    /// As known as: SSE-S3
381    ///
382    /// NOTE: This function should not be used along with other `server_side_encryption_with_` functions.
383    pub fn server_side_encryption_with_s3_key(mut self) -> Self {
384        self.config.server_side_encryption = Some("AES256".to_string());
385        self
386    }
387
388    /// Enable server side encryption with customer key.
389    ///
390    /// As known as: SSE-C
391    ///
392    /// NOTE: This function should not be used along with other `server_side_encryption_with_` functions.
393    pub fn server_side_encryption_with_customer_key(mut self, algorithm: &str, key: &[u8]) -> Self {
394        self.config.server_side_encryption_customer_algorithm = Some(algorithm.to_string());
395        self.config.server_side_encryption_customer_key = Some(BASE64_STANDARD.encode(key));
396        let key_md5 = Md5::digest(key);
397        self.config.server_side_encryption_customer_key_md5 = Some(BASE64_STANDARD.encode(key_md5));
398        self
399    }
400
401    /// Set temporary credential used in AWS S3 connections
402    ///
403    /// # Warning
404    ///
405    /// session token's lifetime is short and requires users to refresh in time.
406    pub fn session_token(mut self, token: &str) -> Self {
407        if !token.is_empty() {
408            self.config.session_token = Some(token.to_string());
409        }
410        self
411    }
412
413    /// Disable config load so that opendal will not load config from
414    /// environment.
415    ///
416    /// For examples:
417    ///
418    /// - envs like `AWS_ACCESS_KEY_ID`
419    /// - files like `~/.aws/config`
420    pub fn disable_config_load(mut self) -> Self {
421        self.config.disable_config_load = true;
422        self
423    }
424
425    /// Disable list objects v2 so that opendal will fall back to the older
426    /// List Objects V1 to list objects.
427    ///
428    /// By default, OpenDAL uses List Objects V2 to list objects. However,
429    /// some legacy services do not yet support V2.
430    pub fn disable_list_objects_v2(mut self) -> Self {
431        self.config.disable_list_objects_v2 = true;
432        self
433    }
434
435    /// Enable request payer so that OpenDAL will send requests with `x-amz-request-payer` header.
436    ///
437    /// With this option the client accepts to pay for the request and data transfer costs.
438    pub fn enable_request_payer(mut self) -> Self {
439        self.config.enable_request_payer = true;
440        self
441    }
442
443    /// Disable load credential from ec2 metadata.
444    ///
445    /// This option is used to disable the default behavior of opendal
446    /// to load credential from ec2 metadata, a.k.a, IMDSv2
447    pub fn disable_ec2_metadata(mut self) -> Self {
448        self.config.disable_ec2_metadata = true;
449        self
450    }
451
452    /// Skip signature will skip loading credentials and signing requests.
453    pub fn skip_signature(mut self) -> Self {
454        self.config.skip_signature = true;
455        self
456    }
457
458    /// Allow anonymous will allow opendal to send request without signing
459    /// when credential is not loaded.
460    #[deprecated(
461        since = "0.57.0",
462        note = "Please use `skip_signature` instead of `allow_anonymous`"
463    )]
464    pub fn allow_anonymous(self) -> Self {
465        self.skip_signature()
466    }
467
468    /// Enable virtual host style so that opendal will send API requests
469    /// in virtual host style instead of path style.
470    ///
471    /// - By default, opendal will send API to `https://s3.us-east-1.amazonaws.com/bucket_name`
472    /// - Enabled, opendal will send API to `https://bucket_name.s3.us-east-1.amazonaws.com`
473    pub fn enable_virtual_host_style(mut self) -> Self {
474        self.config.enable_virtual_host_style = true;
475        self
476    }
477
478    /// Deprecated: S3 stat override capabilities are enabled by default.
479    #[deprecated(
480        since = "0.57.0",
481        note = "S3 stat override capabilities are enabled by default and this option is no longer needed."
482    )]
483    pub fn disable_stat_with_override(self) -> Self {
484        self
485    }
486
487    /// Deprecated: S3 versioning capability is enabled by default.
488    #[deprecated(
489        since = "0.57.0",
490        note = "S3 versioning capability is enabled by default and this option is no longer needed."
491    )]
492    pub fn enable_versioning(self, _enabled: bool) -> Self {
493        self
494    }
495
496    /// Replace the credential providers with a custom chain.
497    pub fn credential_provider_chain(mut self, chain: ProvideCredentialChain<Credential>) -> Self {
498        self.credential_providers = Some(chain);
499        self
500    }
501
502    /// Check if `bucket` is valid.
503    /// `bucket` must be not empty and if `enable_virtual_host_style` is true
504    /// it could not contain dot (.) character.
505    fn is_bucket_valid(config: &S3Config) -> bool {
506        if config.bucket.is_empty() {
507            return false;
508        }
509        // If enable virtual host style, `bucket` will reside in domain part,
510        // for example `https://bucket_name.s3.us-east-1.amazonaws.com`,
511        // so `bucket` with dot can't be recognized correctly for this format.
512        if config.enable_virtual_host_style && config.bucket.contains('.') {
513            return false;
514        }
515        true
516    }
517
518    /// Build endpoint with given region.
519    fn build_endpoint(config: &S3Config, region: &str) -> String {
520        let bucket = {
521            debug_assert!(Self::is_bucket_valid(config), "bucket must be valid");
522
523            config.bucket.as_str()
524        };
525
526        let mut endpoint = match &config.endpoint {
527            Some(endpoint) => {
528                if endpoint.starts_with("http") {
529                    endpoint.to_string()
530                } else {
531                    // Prefix https if endpoint doesn't start with scheme.
532                    format!("https://{endpoint}")
533                }
534            }
535            None => "https://s3.amazonaws.com".to_string(),
536        };
537
538        // If endpoint contains bucket name, we should trim them.
539        endpoint = endpoint.replace(&format!("//{bucket}."), "//");
540
541        // Omit default ports if specified.
542        if let Ok(url) = Url::parse(&endpoint) {
543            // Remove the trailing `/` of root path.
544            endpoint = url.to_string().trim_end_matches('/').to_string();
545        }
546
547        // Update with endpoint templates.
548        endpoint = if let Some(template) = ENDPOINT_TEMPLATES.get(endpoint.as_str()) {
549            template.replace("{region}", region)
550        } else {
551            // If we don't know where about this endpoint, just leave
552            // them as it.
553            endpoint.to_string()
554        };
555
556        // Apply virtual host style.
557        if config.enable_virtual_host_style {
558            endpoint = endpoint.replace("//", &format!("//{bucket}."))
559        } else {
560            write!(endpoint, "/{bucket}").expect("write into string must succeed");
561        };
562
563        endpoint
564    }
565
566    fn invalid_s3_express_config(message: &'static str) -> Error {
567        Error::new(ErrorKind::ConfigInvalid, message)
568            .with_operation("Builder::build")
569            .with_context("service", S3_SCHEME)
570    }
571
572    fn is_aws_host(host: &str) -> bool {
573        host == "s3.amazonaws.com"
574            || host.ends_with(".amazonaws.com")
575            || host.ends_with(".amazonaws.com.cn")
576    }
577
578    fn resolve_s3_express_config(
579        config: &S3Config,
580        region: &str,
581    ) -> Result<Option<S3ExpressSessionConfig>> {
582        if !config.bucket.ends_with("--x-s3") {
583            return Ok(None);
584        }
585
586        let endpoint = match &config.endpoint {
587            Some(endpoint) => {
588                let endpoint = if endpoint.starts_with("http") {
589                    endpoint.to_string()
590                } else {
591                    format!("https://{endpoint}")
592                };
593                let endpoint = Url::parse(&endpoint).map_err(|err| {
594                    Self::invalid_s3_express_config("invalid S3 endpoint").set_source(err)
595                })?;
596                let Some(host) = endpoint.host_str() else {
597                    return Ok(None);
598                };
599                let host = host
600                    .strip_prefix(&format!("{}.", config.bucket))
601                    .unwrap_or(host);
602                if !Self::is_aws_host(host) {
603                    return Ok(None);
604                }
605                if endpoint.scheme() != "https"
606                    || endpoint.path() != "/"
607                    || endpoint.query().is_some()
608                    || endpoint.fragment().is_some()
609                    || endpoint.port().is_some_and(|port| port != 443)
610                    || !endpoint.username().is_empty()
611                    || endpoint.password().is_some()
612                {
613                    return Err(Self::invalid_s3_express_config(
614                        "unsupported AWS endpoint for an S3 directory bucket",
615                    ));
616                }
617                Some(host.to_string())
618            }
619            None => None,
620        };
621
622        let express_config =
623            S3ExpressSessionConfig::from_bucket(&config.bucket, region).map_err(|err| {
624                Self::invalid_s3_express_config("invalid AWS S3 directory bucket configuration")
625                    .set_source(err)
626            })?;
627
628        if let Some(endpoint) = endpoint {
629            let dns_suffix = express_config.partition().dns_suffix();
630            let region = express_config.region();
631            let regional = format!("s3.{region}.{dns_suffix}");
632            let zonal = format!(
633                "s3express-{}.{region}.{dns_suffix}",
634                express_config.zone_id()
635            );
636            let global = (dns_suffix == "amazonaws.com").then_some("s3.amazonaws.com");
637            if endpoint != regional && Some(endpoint.as_str()) != global && endpoint != zonal {
638                return Err(Self::invalid_s3_express_config(
639                    "AWS endpoint does not match the S3 directory bucket",
640                ));
641            }
642        }
643
644        Ok(Some(express_config))
645    }
646
647    /// Deprecated: S3 delete batch capability is enabled by default.
648    #[deprecated(
649        since = "0.57.0",
650        note = "S3 delete batch capability is enabled by default and this option is no longer needed."
651    )]
652    pub fn batch_max_operations(self, _batch_max_operations: usize) -> Self {
653        self
654    }
655
656    /// Deprecated: S3 delete batch capability is enabled by default.
657    #[deprecated(
658        since = "0.57.0",
659        note = "S3 delete batch capability is enabled by default and this option is no longer needed."
660    )]
661    pub fn delete_max_size(self, _delete_max_size: usize) -> Self {
662        self
663    }
664
665    /// Set checksum algorithm of this backend.
666    /// This is necessary when writing to AWS S3 Buckets with Object Lock enabled for example.
667    ///
668    /// Available options:
669    /// - "crc32c"
670    /// - "md5"
671    pub fn checksum_algorithm(mut self, checksum_algorithm: &str) -> Self {
672        self.config.checksum_algorithm = Some(checksum_algorithm.to_string());
673
674        self
675    }
676
677    /// Deprecated: S3 write with If-Match capability is enabled by default.
678    #[deprecated(
679        since = "0.57.0",
680        note = "S3 write with If-Match capability is enabled by default and this option is no longer needed."
681    )]
682    pub fn disable_write_with_if_match(self) -> Self {
683        self
684    }
685
686    /// Deprecated: S3 append capability is enabled by default.
687    #[deprecated(
688        since = "0.57.0",
689        note = "S3 append capability is enabled by default and this option is no longer needed."
690    )]
691    pub fn enable_write_with_append(self) -> Self {
692        self
693    }
694
695    /// Detect region of S3 bucket.
696    ///
697    /// # Args
698    ///
699    /// - endpoint: the endpoint of S3 service
700    /// - bucket: the bucket of S3 service
701    ///
702    /// # Return
703    ///
704    /// - `Some(region)` means we detect the region successfully
705    /// - `None` means we can't detect the region or meeting errors.
706    ///
707    /// # Notes
708    ///
709    /// We will try to detect region by the following methods.
710    ///
711    /// - Match endpoint with given rules to get region
712    ///   - Cloudflare R2
713    ///   - AWS S3
714    ///   - Aliyun OSS
715    /// - Send a `HEAD` request to endpoint with bucket name to get `x-amz-bucket-region`.
716    ///
717    /// # Examples
718    ///
719    /// ```no_run
720    /// use opendal_service_s3::S3;
721    ///
722    /// # async fn example() {
723    /// let region: Option<String> = S3::detect_region("https://s3.amazonaws.com", "example").await;
724    /// # }
725    /// ```
726    ///
727    /// # Reference
728    ///
729    /// - [Amazon S3 HeadBucket API](https://docs.aws.amazon.com/zh_cn/AmazonS3/latest/API/API_HeadBucket.html)
730    pub async fn detect_region(endpoint: &str, bucket: &str) -> Option<String> {
731        // Remove the possible trailing `/` in endpoint.
732        let endpoint = endpoint.trim_end_matches('/');
733
734        // Make sure the endpoint contains the scheme.
735        let mut endpoint = if endpoint.starts_with("http") {
736            endpoint.to_string()
737        } else {
738            // Prefix https if endpoint doesn't start with scheme.
739            format!("https://{endpoint}")
740        };
741
742        // Remove bucket name from endpoint.
743        endpoint = endpoint.replace(&format!("//{bucket}."), "//");
744        let url = format!("{endpoint}/{bucket}");
745
746        debug!("detect region with url: {url}");
747
748        // Try to detect region by endpoint.
749
750        // If this bucket is R2, we can return auto directly.
751        //
752        // Reference: <https://developers.cloudflare.com/r2/api/s3/api/>
753        if endpoint.ends_with("r2.cloudflarestorage.com") {
754            return Some("auto".to_string());
755        }
756
757        // If this bucket is AWS, we can try to match the endpoint.
758        if endpoint == "https://s3.amazonaws.com" {
759            return Some("us-east-1".to_string());
760        }
761
762        if let Some(region) = endpoint
763            .strip_prefix("https://s3.")
764            .and_then(|v| v.strip_suffix(".amazonaws.com"))
765        {
766            return Some(region.to_string());
767        }
768
769        // If this bucket is OSS, we can try to match the endpoint.
770        //
771        // - `oss-ap-southeast-1.aliyuncs.com` => `oss-ap-southeast-1`
772        // - `oss-cn-hangzhou-internal.aliyuncs.com` => `oss-cn-hangzhou`
773        if let Some(v) = endpoint.strip_prefix("https://") {
774            if let Some(region) = v.strip_suffix("-internal.aliyuncs.com") {
775                return Some(region.to_string());
776            }
777            if let Some(region) = v.strip_suffix(".aliyuncs.com") {
778                return Some(region.to_string());
779            }
780        }
781
782        // Try to detect region by HeadBucket.
783        let req = http::Request::head(&url).body(Buffer::new()).ok()?;
784
785        let client = HttpTransporter::default();
786        let res = client
787            .send(req)
788            .await
789            .map_err(|err| warn!("detect region failed for: {err:?}"))
790            .ok()?;
791
792        debug!(
793            "auto detect region got response: status {:?}, header: {:?}",
794            res.status(),
795            res.headers()
796        );
797
798        // Get region from response header no matter status code.
799        if let Some(region) = res
800            .headers()
801            .get("x-amz-bucket-region")
802            .and_then(|header| header.to_str().ok())
803        {
804            return Some(region.to_string());
805        }
806
807        // Status code is 403 or 200 means we already visit the correct
808        // region, we can use the default region directly.
809        if res.status() == StatusCode::FORBIDDEN || res.status() == StatusCode::OK {
810            return Some("us-east-1".to_string());
811        }
812
813        None
814    }
815
816    /// Set default ACL for new objects.
817    pub fn default_acl(mut self, acl: &str) -> Self {
818        self.config.default_acl = Some(acl.to_string());
819        self
820    }
821}
822
823impl Builder for S3Builder {
824    type Config = S3Config;
825
826    fn build(self) -> Result<impl Service> {
827        debug!("backend build started: {:?}", self);
828
829        let S3Builder {
830            mut config,
831            credential_providers,
832        } = self;
833
834        #[allow(deprecated)]
835        if config.allow_anonymous {
836            config.skip_signature = true;
837        }
838
839        let root = normalize_root(&config.root.clone().unwrap_or_default());
840        debug!("backend use root {}", root);
841
842        // Handle bucket name.
843        let bucket = if Self::is_bucket_valid(&config) {
844            Ok(&config.bucket)
845        } else {
846            Err(
847                Error::new(ErrorKind::ConfigInvalid, "The bucket is misconfigured")
848                    .with_context("service", S3_SCHEME),
849            )
850        }?;
851        debug!("backend use bucket {}", bucket);
852
853        let default_storage_class = match &config.default_storage_class {
854            None => None,
855            Some(v) => Some(
856                build_header_value(v).map_err(|err| err.with_context("key", "storage_class"))?,
857            ),
858        };
859
860        let server_side_encryption = match &config.server_side_encryption {
861            None => None,
862            Some(v) => Some(
863                build_header_value(v)
864                    .map_err(|err| err.with_context("key", "server_side_encryption"))?,
865            ),
866        };
867
868        let server_side_encryption_aws_kms_key_id =
869            match &config.server_side_encryption_aws_kms_key_id {
870                None => None,
871                Some(v) => Some(build_header_value(v).map_err(|err| {
872                    err.with_context("key", "server_side_encryption_aws_kms_key_id")
873                })?),
874            };
875
876        let server_side_encryption_customer_algorithm =
877            match &config.server_side_encryption_customer_algorithm {
878                None => None,
879                Some(v) => Some(build_header_value(v).map_err(|err| {
880                    err.with_context("key", "server_side_encryption_customer_algorithm")
881                })?),
882            };
883
884        let server_side_encryption_customer_key =
885            match &config.server_side_encryption_customer_key {
886                None => None,
887                Some(v) => Some(build_header_value(v).map_err(|err| {
888                    err.with_context("key", "server_side_encryption_customer_key")
889                })?),
890            };
891
892        let server_side_encryption_customer_key_md5 =
893            match &config.server_side_encryption_customer_key_md5 {
894                None => None,
895                Some(v) => Some(build_header_value(v).map_err(|err| {
896                    err.with_context("key", "server_side_encryption_customer_key_md5")
897                })?),
898            };
899
900        let checksum_algorithm = match config.checksum_algorithm.as_deref() {
901            Some("crc32c") => Some(ChecksumAlgorithm::Crc32c),
902            Some("md5") => Some(ChecksumAlgorithm::Md5),
903            None => None,
904            v => {
905                return Err(Error::new(
906                    ErrorKind::ConfigInvalid,
907                    format!("{v:?} is not a supported checksum_algorithm."),
908                ));
909            }
910        };
911
912        // Determine the region
913        let region = if let Some(ref v) = config.region {
914            v.to_string()
915        } else {
916            std::env::var("AWS_REGION")
917                .or_else(|_| std::env::var("AWS_DEFAULT_REGION"))
918                .map_err(|_| {
919                    Error::new(
920                        ErrorKind::ConfigInvalid,
921                        "region is missing. Please find it by S3::detect_region() or set them in env.",
922                    )
923                    .with_operation("Builder::build")
924                    .with_context("service", S3_SCHEME)
925                })?
926        };
927        debug!("backend use region: {region}");
928
929        if config.endpoint.is_none() && !config.disable_config_load {
930            let endpoint_from_env = std::env::var("AWS_ENDPOINT_URL")
931                .or_else(|_| std::env::var("AWS_ENDPOINT"))
932                .or_else(|_| std::env::var("AWS_S3_ENDPOINT"))
933                .ok();
934            if let Some(endpoint) = endpoint_from_env {
935                let normalized = endpoint.trim_end_matches('/').to_string();
936                config.endpoint = Some(normalized);
937            }
938        }
939
940        let s3_express_config = Self::resolve_s3_express_config(&config, &region)?;
941        let is_s3_express = s3_express_config.is_some();
942
943        // Building endpoint.
944        let endpoint = match &s3_express_config {
945            Some(config) => config.endpoint().to_string(),
946            None => Self::build_endpoint(&config, &region),
947        };
948        debug!("backend use endpoint: {endpoint}");
949
950        // The base signer context only carries local config readers. HTTP
951        // sending is injected from OperationContext when S3Core signs each
952        // operation.
953        let ctx = Context::new().with_file_read(TokioFileRead).with_env(OsEnv);
954
955        let mut provider = {
956            let mut builder = DefaultCredentialProvider::builder();
957
958            if config.disable_config_load {
959                builder = builder.no_env().no_profile();
960            } else if let Some(profile) = config
961                .profile
962                .as_deref()
963                .filter(|profile| !profile.is_empty())
964            {
965                builder = builder.with_profile(profile);
966            }
967
968            if config.disable_ec2_metadata {
969                builder = builder.no_imds();
970            }
971
972            ProvideCredentialChain::new().push(builder.build())
973        };
974
975        // Insert static key if user provided.
976        if let (Some(ak), Some(sk)) = (&config.access_key_id, &config.secret_access_key) {
977            let static_provider = if let Some(token) = config.session_token.as_deref() {
978                StaticCredentialProvider::new(ak, sk).with_session_token(token)
979            } else {
980                StaticCredentialProvider::new(ak, sk)
981            };
982            provider = provider.push_front(static_provider);
983        }
984
985        // Insert assume role provider if user provided.
986        if let Some(role_arn) = &config.role_arn {
987            // The assume-role provider owns its STS signer, so give it a
988            // concrete HTTP sender instead of relying on a future operation
989            // context.
990            let sts_ctx = ctx.clone().with_http_send(HttpTransporter::default());
991            let sts_request_signer = AwsV4Signer::new("sts", &region);
992            let sts_signer = Signer::new(sts_ctx, provider, sts_request_signer);
993            let mut assume_role_provider =
994                AssumeRoleCredentialProvider::new(role_arn.clone(), sts_signer)
995                    .with_region(region.clone())
996                    .with_regional_sts_endpoint();
997
998            if let Some(external_id) = &config.external_id {
999                assume_role_provider = assume_role_provider.with_external_id(external_id.clone());
1000            }
1001            if let Some(role_session_name) = &config.role_session_name {
1002                assume_role_provider =
1003                    assume_role_provider.with_role_session_name(role_session_name.clone());
1004            }
1005            if let Some(duration_seconds) = config.assume_role_duration_seconds {
1006                assume_role_provider = assume_role_provider.with_duration_seconds(duration_seconds);
1007            }
1008            if let Some(tags) = &config.assume_role_session_tags {
1009                assume_role_provider = assume_role_provider
1010                    .with_tags(tags.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
1011            }
1012            provider = ProvideCredentialChain::new().push(assume_role_provider);
1013        }
1014
1015        // Replace provider if user provide their own.
1016        let provider = if let Some(credential_providers) = credential_providers {
1017            credential_providers
1018        } else {
1019            provider
1020        };
1021
1022        let signers = if is_s3_express {
1023            let provider = Arc::new(provider);
1024            let iam_signer = Signer::new(
1025                ctx.clone(),
1026                provider.clone(),
1027                AwsV4Signer::new("s3express", &region).with_standard_session_token(),
1028            );
1029            let session_provider =
1030                S3ExpressSessionProvider::new(bucket, provider).with_region(&region);
1031            let session_signer = Signer::new(
1032                ctx,
1033                session_provider,
1034                AwsV4Signer::new("s3express", &region),
1035            );
1036            S3Signers::Express {
1037                iam: iam_signer,
1038                session: session_signer,
1039            }
1040        } else {
1041            S3Signers::General(Signer::new(ctx, provider, AwsV4Signer::new("s3", &region)))
1042        };
1043
1044        Ok(S3Backend {
1045            core: Arc::new(S3Core {
1046                info: ServiceInfo::new(S3_SCHEME, &root, bucket),
1047                capability: Capability {
1048                    stat: true,
1049                    stat_with_if_match: true,
1050                    stat_with_if_none_match: true,
1051                    stat_with_if_modified_since: true,
1052                    stat_with_if_unmodified_since: true,
1053                    stat_with_override_cache_control: true,
1054                    stat_with_override_content_disposition: true,
1055                    stat_with_override_content_type: true,
1056                    stat_with_version: !is_s3_express,
1057
1058                    read: true,
1059                    read_with_if_match: true,
1060                    read_with_if_none_match: true,
1061                    read_with_if_modified_since: true,
1062                    read_with_if_unmodified_since: true,
1063                    read_with_override_cache_control: true,
1064                    read_with_override_content_disposition: true,
1065                    read_with_override_content_type: true,
1066                    read_with_version: !is_s3_express,
1067                    read_with_suffix: true,
1068
1069                    write: true,
1070                    write_can_empty: true,
1071                    write_can_multi: true,
1072                    write_can_append: true,
1073                    write_can_copy_from: checksum_algorithm.is_none(),
1074
1075                    write_with_cache_control: true,
1076                    write_with_content_type: true,
1077                    write_with_content_disposition: true,
1078                    write_with_content_encoding: !is_s3_express,
1079                    write_with_if_match: true,
1080                    write_with_if_not_exists: true,
1081                    write_with_user_metadata: true,
1082
1083                    // The min multipart size of S3 is 5 MiB.
1084                    //
1085                    // ref: <https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html>
1086                    write_multi_min_size: Some(5 * 1024 * 1024),
1087                    // The max multipart size of S3 is 5 GiB.
1088                    //
1089                    // ref: <https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html>
1090                    write_multi_max_size: if cfg!(target_pointer_width = "64") {
1091                        Some(5 * 1024 * 1024 * 1024)
1092                    } else {
1093                        Some(usize::MAX)
1094                    },
1095                    // S3 allows at most 10,000 parts and 5 GiB for each part.
1096                    //
1097                    // ref: <https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html>
1098                    write_total_max_size: if cfg!(target_pointer_width = "64") {
1099                        Some(10_000 * 5 * 1024 * 1024 * 1024)
1100                    } else {
1101                        None
1102                    },
1103
1104                    delete: true,
1105                    delete_max_size: Some(DEFAULT_BATCH_MAX_OPERATIONS),
1106                    delete_with_version: !is_s3_express,
1107                    delete_with_if_match: true,
1108
1109                    copy: true,
1110                    copy_can_multi: true,
1111                    copy_with_if_not_exists: true,
1112                    copy_with_if_match: true,
1113                    copy_with_source_version: !is_s3_express,
1114                    // The min multipart size of S3 is 5 MiB.
1115                    //
1116                    // ref: <https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html>
1117                    copy_multi_min_size: Some(5 * 1024 * 1024),
1118                    // The max multipart size of S3 is 5 GiB.
1119                    //
1120                    // ref: <https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html>
1121                    copy_multi_max_size: if cfg!(target_pointer_width = "64") {
1122                        Some(5 * 1024 * 1024 * 1024)
1123                    } else {
1124                        Some(usize::MAX)
1125                    },
1126
1127                    restore: !is_s3_express,
1128                    restore_with_version: !is_s3_express,
1129                    restore_with_if_not_exists: !is_s3_express,
1130
1131                    list: true,
1132                    list_with_limit: true,
1133                    list_with_start_after: !is_s3_express,
1134                    list_with_recursive: !is_s3_express,
1135                    list_with_versions: !is_s3_express,
1136                    list_with_deleted: !is_s3_express,
1137
1138                    presign: true,
1139                    presign_stat: true,
1140                    presign_read: true,
1141                    presign_write: true,
1142                    presign_delete: true,
1143
1144                    shared: true,
1145
1146                    ..Default::default()
1147                },
1148                bucket: bucket.to_string(),
1149                endpoint,
1150                root,
1151                server_side_encryption,
1152                server_side_encryption_aws_kms_key_id,
1153                server_side_encryption_customer_algorithm,
1154                server_side_encryption_customer_key,
1155                server_side_encryption_customer_key_md5,
1156                default_storage_class,
1157                skip_signature: config.skip_signature,
1158                disable_list_objects_v2: config.disable_list_objects_v2,
1159                enable_request_payer: config.enable_request_payer,
1160                signers,
1161                checksum_algorithm,
1162                default_acl: config.default_acl,
1163            }),
1164        })
1165    }
1166}
1167
1168/// Backend for s3 services.
1169#[derive(Debug, Clone)]
1170pub struct S3Backend {
1171    pub(crate) core: Arc<S3Core>,
1172}
1173
1174impl Service for S3Backend {
1175    type Reader = oio::StreamReader<S3Reader>;
1176    type Writer = S3Writers;
1177    type Lister = S3Listers;
1178    type Deleter = oio::BatchDeleter<S3Deleter>;
1179    type Copier = S3Copiers;
1180    type Composer = ();
1181
1182    fn info(&self) -> ServiceInfo {
1183        self.core.info.clone()
1184    }
1185
1186    fn capability(&self) -> Capability {
1187        self.core.capability
1188    }
1189
1190    async fn create_dir(
1191        &self,
1192        _ctx: &OperationContext,
1193        _path: &str,
1194        _args: OpCreateDir,
1195    ) -> Result<RpCreateDir> {
1196        Err(Error::new(
1197            ErrorKind::Unsupported,
1198            "operation is not supported",
1199        ))
1200    }
1201
1202    async fn stat(&self, ctx: &OperationContext, path: &str, args: OpStat) -> Result<RpStat> {
1203        let error_ctx = ErrorContext::new(ServiceOperation("HeadObject"))
1204            .with_caller_condition(args.is_conditional());
1205        let resp = self.core.s3_head_object(ctx, path, args).await?;
1206
1207        let status = resp.status();
1208
1209        match status {
1210            StatusCode::OK => Ok(RpStat::new(parse_into_s3_metadata(path, resp.headers())?)),
1211            _ => Err(parse_error(error_ctx, resp)),
1212        }
1213    }
1214    fn read(&self, ctx: &OperationContext, path: &str, args: OpRead) -> Result<Self::Reader> {
1215        let output: oio::StreamReader<S3Reader> = {
1216            Ok(oio::StreamReader::new(S3Reader::new(
1217                self.clone(),
1218                ctx.clone(),
1219                path,
1220                args,
1221            )))
1222        }?;
1223
1224        Ok(output)
1225    }
1226
1227    fn write(&self, ctx: &OperationContext, path: &str, args: OpWrite) -> Result<Self::Writer> {
1228        let output: S3Writers = {
1229            let writer = S3Writer::new(self.core.clone(), ctx.clone(), path, args.clone());
1230
1231            let w = if args.append() {
1232                S3Writers::Two(oio::AppendWriter::new(writer))
1233            } else {
1234                // Multipart uploads schedule work through the operation
1235                // executor supplied by the caller.
1236                S3Writers::One(oio::MultipartWriter::new(
1237                    ctx.executor().clone(),
1238                    writer,
1239                    args.concurrent(),
1240                ))
1241            };
1242
1243            Ok(w)
1244        }?;
1245
1246        Ok(output)
1247    }
1248
1249    fn delete(&self, ctx: &OperationContext) -> Result<Self::Deleter> {
1250        let output: oio::BatchDeleter<S3Deleter> = {
1251            Ok(oio::BatchDeleter::new(
1252                S3Deleter::new(self.core.clone(), ctx.clone()),
1253                self.core.capability.delete_max_size,
1254            ))
1255        }?;
1256
1257        Ok(output)
1258    }
1259
1260    fn list(&self, ctx: &OperationContext, path: &str, args: OpList) -> Result<Self::Lister> {
1261        let output: S3Listers = {
1262            let l = if args.versions() || args.deleted() {
1263                ThreeWays::Three(oio::PageLister::new(S3ObjectVersionsLister::new(
1264                    self.core.clone(),
1265                    ctx.clone(),
1266                    path,
1267                    args,
1268                )))
1269            } else if self.core.disable_list_objects_v2 {
1270                ThreeWays::One(oio::PageLister::new(S3ListerV1::new(
1271                    self.core.clone(),
1272                    ctx.clone(),
1273                    path,
1274                    args,
1275                )))
1276            } else {
1277                ThreeWays::Two(oio::PageLister::new(S3ListerV2::new(
1278                    self.core.clone(),
1279                    ctx.clone(),
1280                    path,
1281                    args,
1282                )))
1283            };
1284
1285            Ok(l)
1286        }?;
1287
1288        Ok(output)
1289    }
1290
1291    fn copy(
1292        &self,
1293        ctx: &OperationContext,
1294        from: &str,
1295        to: &str,
1296        args: OpCopy,
1297    ) -> Result<Self::Copier> {
1298        let output: S3Copiers = {
1299            let copier = new_s3_copier(self.core.clone(), ctx, from, to, args)?;
1300            Ok(copier)
1301        }?;
1302
1303        Ok(output)
1304    }
1305
1306    async fn rename(
1307        &self,
1308        _ctx: &OperationContext,
1309        _from: &str,
1310        _to: &str,
1311        _args: OpRename,
1312    ) -> Result<RpRename> {
1313        Err(Error::new(
1314            ErrorKind::Unsupported,
1315            "operation is not supported",
1316        ))
1317    }
1318
1319    async fn restore(
1320        &self,
1321        ctx: &OperationContext,
1322        path: &str,
1323        args: OpRestore,
1324    ) -> Result<RpRestore> {
1325        if let Some(version) = args.version() {
1326            let copy_args = OpCopy::from_options(
1327                &self.capability(),
1328                options::CopyOptions {
1329                    source_version: Some(version.to_owned()),
1330                    if_not_exists: args.if_not_exists(),
1331                    ..Default::default()
1332                },
1333            )?;
1334            let mut copier = new_s3_copier(self.core.clone(), ctx, path, path, copy_args)?;
1335
1336            return match copier.close().await {
1337                Ok(_) => Ok(RpRestore::new()),
1338                Err(err) => {
1339                    let _ = copier.abort().await;
1340                    Err(err)
1341                }
1342            };
1343        }
1344
1345        if args.if_not_exists() {
1346            return Err(Error::new(
1347                ErrorKind::ConfigInvalid,
1348                "if_not_exists requires a restore version",
1349            ));
1350        }
1351
1352        let resp = self
1353            .core
1354            .s3_list_object_versions(ctx, path, "", Some(1), "", "")
1355            .await?;
1356        if resp.status() != StatusCode::OK {
1357            return Err(parse_error(
1358                ErrorContext::new(ServiceOperation("ListObjectVersions")),
1359                resp,
1360            ));
1361        }
1362
1363        let output: ListObjectVersionsOutput =
1364            quick_xml::de::from_reader(resp.into_body().reader())
1365                .map_err(new_xml_deserialize_error)
1366                .map_err(Error::set_temporary)?;
1367        let abs_path = build_abs_path(&self.core.root, path);
1368
1369        if output
1370            .version
1371            .iter()
1372            .any(|version| version.key == abs_path && version.is_latest)
1373        {
1374            return Ok(RpRestore::new());
1375        }
1376
1377        let Some(marker) = output
1378            .delete_marker
1379            .into_iter()
1380            .find(|marker| marker.key == abs_path && marker.is_latest)
1381        else {
1382            return Err(Error::new(
1383                ErrorKind::NotFound,
1384                "no live object or current delete marker exists",
1385            ));
1386        };
1387
1388        let delete_args = OpDelete::from_options(
1389            &self.capability(),
1390            options::DeleteOptions {
1391                version: Some(marker.version_id.clone()),
1392                ..Default::default()
1393            },
1394        )?;
1395        let resp = self.core.s3_delete_object(ctx, path, &delete_args).await?;
1396        match resp.status() {
1397            StatusCode::NO_CONTENT | StatusCode::NOT_FOUND => Ok(RpRestore::new()),
1398            _ => Err(parse_error(
1399                ErrorContext::new(ServiceOperation("DeleteObject")),
1400                resp,
1401            )),
1402        }
1403    }
1404
1405    async fn presign(
1406        &self,
1407        ctx: &OperationContext,
1408        path: &str,
1409        args: OpPresign,
1410    ) -> Result<RpPresign> {
1411        let (expire, op) = args.into_parts();
1412        // We will not send this request out, just for signing.
1413        let req = match op {
1414            PresignOperation::Stat(v) => self.core.s3_head_object_request(path, v),
1415            PresignOperation::Read(range, v) => self.core.s3_get_object_request(path, range, &v),
1416            PresignOperation::Write(v) => {
1417                self.core
1418                    .s3_put_object_request(path, None, &v, Buffer::new())
1419            }
1420            PresignOperation::Delete(v) => self.core.s3_delete_object_request(path, &v),
1421            _ => Err(Error::new(
1422                ErrorKind::Unsupported,
1423                "operation is not supported",
1424            )),
1425        };
1426        let req = req?;
1427
1428        let req = self.core.sign_query(ctx, req, expire).await?;
1429
1430        // We don't need this request anymore, consume it directly.
1431        let (parts, _) = req.into_parts();
1432
1433        Ok(RpPresign::new(PresignedRequest::new(
1434            parts.method,
1435            parts.uri,
1436            parts.headers,
1437        )))
1438    }
1439}
1440#[cfg(test)]
1441mod tests {
1442    use std::sync::Arc;
1443    use std::sync::Mutex;
1444
1445    use bytes::Bytes;
1446    use http::HeaderMap;
1447    use http::Method;
1448    use http::Request;
1449    use http::Response;
1450
1451    use super::*;
1452
1453    #[derive(Clone, Copy)]
1454    enum CreateSessionOutcome {
1455        Success,
1456        Body(&'static str),
1457        Status(StatusCode),
1458        TemporaryTransportError,
1459    }
1460
1461    #[derive(Clone)]
1462    struct CapturedRequest {
1463        method: Method,
1464        uri: http::Uri,
1465        headers: HeaderMap,
1466    }
1467
1468    #[derive(Clone)]
1469    struct S3ExpressMockTransport {
1470        outcome: CreateSessionOutcome,
1471        source_content_length: u64,
1472        source_version: Option<&'static str>,
1473        requests: Arc<Mutex<Vec<CapturedRequest>>>,
1474    }
1475
1476    impl S3ExpressMockTransport {
1477        fn new(outcome: CreateSessionOutcome) -> Self {
1478            Self {
1479                outcome,
1480                source_content_length: 1,
1481                source_version: None,
1482                requests: Arc::new(Mutex::new(Vec::new())),
1483            }
1484        }
1485
1486        fn with_source_content_length(mut self, source_content_length: u64) -> Self {
1487            self.source_content_length = source_content_length;
1488            self
1489        }
1490
1491        fn with_source_version(mut self, source_version: &'static str) -> Self {
1492            self.source_version = Some(source_version);
1493            self
1494        }
1495
1496        fn requests(&self) -> Vec<CapturedRequest> {
1497            self.requests.lock().expect("lock poisoned").clone()
1498        }
1499
1500        fn response(status: StatusCode, body: impl Into<Bytes>) -> Response<HttpBody> {
1501            let body = Buffer::from(body.into());
1502            let size = body.len() as u64;
1503            Response::builder()
1504                .status(status)
1505                .body(HttpBody::new(
1506                    futures::stream::iter(vec![Ok(body)]),
1507                    Some(size),
1508                ))
1509                .expect("mock response must build")
1510        }
1511    }
1512
1513    impl HttpTransport for S3ExpressMockTransport {
1514        async fn fetch(&self, req: Request<Buffer>) -> Result<Response<HttpBody>> {
1515            let is_create_session = req.uri().query() == Some("session");
1516            self.requests
1517                .lock()
1518                .expect("lock poisoned")
1519                .push(CapturedRequest {
1520                    method: req.method().clone(),
1521                    uri: req.uri().clone(),
1522                    headers: req.headers().clone(),
1523                });
1524
1525            if is_create_session {
1526                tokio::task::yield_now().await;
1527                return match self.outcome {
1528                    CreateSessionOutcome::Success => Ok(Self::response(
1529                        StatusCode::OK,
1530                        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1531                         <CreateSessionResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
1532                         <Credentials>\
1533                         <SessionToken>session-token</SessionToken>\
1534                         <SecretAccessKey>session-secret-key</SecretAccessKey>\
1535                         <AccessKeyId>session-access-key</AccessKeyId>\
1536                         <Expiration>2099-01-01T00:05:00Z</Expiration>\
1537                         </Credentials>\
1538                         </CreateSessionResult>",
1539                    )),
1540                    CreateSessionOutcome::Body(body) => Ok(Self::response(StatusCode::OK, body)),
1541                    CreateSessionOutcome::Status(status) => {
1542                        Ok(Self::response(status, Bytes::new()))
1543                    }
1544                    CreateSessionOutcome::TemporaryTransportError => Err(Error::new(
1545                        ErrorKind::Unexpected,
1546                        "temporary mock transport failure",
1547                    )
1548                    .set_temporary()),
1549                };
1550            }
1551
1552            if req.method() == Method::HEAD {
1553                let mut response = Self::response(StatusCode::OK, Bytes::new());
1554                response.headers_mut().insert(
1555                    http::header::CONTENT_LENGTH,
1556                    self.source_content_length
1557                        .to_string()
1558                        .parse()
1559                        .expect("source content length must be a valid header"),
1560                );
1561                response.headers_mut().insert(
1562                    http::header::ETAG,
1563                    "\"source-etag\""
1564                        .parse()
1565                        .expect("source ETag must be a valid header"),
1566                );
1567                if let Some(version) = self.source_version {
1568                    response.headers_mut().insert(
1569                        constants::X_AMZ_VERSION_ID,
1570                        version
1571                            .parse()
1572                            .expect("source version must be a valid header"),
1573                    );
1574                }
1575                return Ok(response);
1576            }
1577
1578            let query = req.uri().query().unwrap_or_default();
1579            if req.headers().contains_key(constants::X_AMZ_COPY_SOURCE) {
1580                return Ok(Self::response(
1581                    StatusCode::OK,
1582                    "<CopyObjectResult><ETag>\"etag\"</ETag>\
1583                     <LastModified>2026-08-24T00:00:00Z</LastModified></CopyObjectResult>",
1584                ));
1585            }
1586            if query == "uploads" {
1587                return Ok(Self::response(
1588                    StatusCode::OK,
1589                    "<InitiateMultipartUploadResult><UploadId>upload-id</UploadId>\
1590                     </InitiateMultipartUploadResult>",
1591                ));
1592            }
1593            if query.contains("list-type=2") {
1594                return Ok(Self::response(
1595                    StatusCode::OK,
1596                    "<ListBucketResult><IsTruncated>false</IsTruncated></ListBucketResult>",
1597                ));
1598            }
1599            if req.method() == Method::POST && query.contains("uploadId=") {
1600                return Ok(Self::response(
1601                    StatusCode::OK,
1602                    "<CompleteMultipartUploadResult><Bucket>example</Bucket><Key>target</Key>\
1603                     <Location>https://example.invalid/target</Location><ETag>\"etag\"</ETag>\
1604                     </CompleteMultipartUploadResult>",
1605                ));
1606            }
1607
1608            Ok(Self::response(StatusCode::OK, Bytes::new()))
1609        }
1610    }
1611
1612    fn s3_express_operator(transport: S3ExpressMockTransport) -> Operator {
1613        Operator::new(
1614            S3Builder::default()
1615                .bucket("example--usw2-az1--x-s3")
1616                .region("us-west-2")
1617                .access_key_id("source-access-key")
1618                .secret_access_key("source-secret-key")
1619                .session_token("source-session-token")
1620                .disable_config_load()
1621                .disable_ec2_metadata(),
1622        )
1623        .expect("S3 Express operator must build")
1624        .with_context(OperationContext::new().with_http_transport(HttpTransporter::new(transport)))
1625    }
1626
1627    fn s3_general_operator(transport: S3ExpressMockTransport) -> Operator {
1628        Operator::new(
1629            S3Builder::default()
1630                .bucket("example")
1631                .region("us-west-2")
1632                .access_key_id("source-access-key")
1633                .secret_access_key("source-secret-key")
1634                .session_token("source-session-token")
1635                .disable_config_load()
1636                .disable_ec2_metadata(),
1637        )
1638        .expect("S3 operator must build")
1639        .with_context(OperationContext::new().with_http_transport(HttpTransporter::new(transport)))
1640    }
1641
1642    fn header<'a>(request: &'a CapturedRequest, name: &str) -> &'a str {
1643        request
1644            .headers
1645            .get(name)
1646            .unwrap_or_else(|| panic!("missing {name} header"))
1647            .to_str()
1648            .expect("header must be text")
1649    }
1650
1651    #[tokio::test]
1652    async fn test_s3_express_signing_chain_and_session_reuse() {
1653        let transport = S3ExpressMockTransport::new(CreateSessionOutcome::Success);
1654        let op = s3_express_operator(transport.clone());
1655
1656        let (first, second) = tokio::join!(
1657            op.write("first", "first-body"),
1658            op.write("second", "second-body")
1659        );
1660        first.expect("first write must succeed");
1661        second.expect("second write must succeed");
1662
1663        let requests = transport.requests();
1664        assert_eq!(
1665            requests[0].uri.query(),
1666            Some("session"),
1667            "CreateSession must complete before object requests are signed"
1668        );
1669        let create_sessions = requests
1670            .iter()
1671            .filter(|request| request.uri.query() == Some("session"))
1672            .collect::<Vec<_>>();
1673        assert_eq!(create_sessions.len(), 1, "session must be reused");
1674
1675        let create_session = create_sessions[0];
1676        assert_eq!(create_session.method, Method::GET);
1677        assert_eq!(
1678            header(create_session, "x-amz-create-session-mode"),
1679            "ReadWrite"
1680        );
1681        assert_eq!(
1682            header(create_session, "x-amz-security-token"),
1683            "source-session-token"
1684        );
1685        assert!(!create_session.headers.contains_key("x-amz-s3session-token"));
1686        assert!(header(create_session, "authorization").contains("source-access-key/"));
1687
1688        let puts = requests
1689            .iter()
1690            .filter(|request| request.method == Method::PUT)
1691            .collect::<Vec<_>>();
1692        assert_eq!(puts.len(), 2);
1693        for request in puts {
1694            assert_eq!(header(request, "x-amz-s3session-token"), "session-token");
1695            assert!(!request.headers.contains_key("x-amz-security-token"));
1696            assert!(header(request, "authorization").contains("session-access-key/"));
1697        }
1698    }
1699
1700    #[tokio::test]
1701    async fn test_s3_express_list_uses_session_credentials() {
1702        let transport = S3ExpressMockTransport::new(CreateSessionOutcome::Success);
1703        let op = s3_express_operator(transport.clone());
1704
1705        let entries = op.list("").await.expect("list must succeed");
1706        assert!(entries.is_empty());
1707
1708        let requests = transport.requests();
1709        let list = requests
1710            .iter()
1711            .find(|request| {
1712                request
1713                    .uri
1714                    .query()
1715                    .is_some_and(|query| query.contains("list-type=2"))
1716            })
1717            .expect("ListObjectsV2 request must be captured");
1718        assert_eq!(header(list, "x-amz-s3session-token"), "session-token");
1719        assert!(!list.headers.contains_key("x-amz-security-token"));
1720        assert!(header(list, "authorization").contains("session-access-key/"));
1721    }
1722
1723    #[tokio::test]
1724    async fn test_s3_general_bucket_always_uses_iam_credentials() {
1725        let transport = S3ExpressMockTransport::new(CreateSessionOutcome::Success);
1726        let op = s3_general_operator(transport.clone());
1727
1728        op.write("source", "body")
1729            .await
1730            .expect("write must succeed");
1731        op.copy_with("source", "target")
1732            .source_content_length_hint(1)
1733            .await
1734            .expect("copy must succeed");
1735
1736        let requests = transport.requests();
1737        assert_eq!(requests.len(), 2);
1738        for request in requests {
1739            assert_eq!(
1740                header(&request, "x-amz-security-token"),
1741                "source-session-token"
1742            );
1743            assert!(!request.headers.contains_key("x-amz-s3session-token"));
1744            assert!(header(&request, "authorization").contains("source-access-key/"));
1745        }
1746    }
1747
1748    #[tokio::test]
1749    async fn test_s3_copy_hint_skips_source_snapshot() {
1750        let transport = S3ExpressMockTransport::new(CreateSessionOutcome::Success);
1751        let op = s3_general_operator(transport.clone());
1752
1753        let metadata = op
1754            .copy_with("source", "target")
1755            .source_content_length_hint(7)
1756            .await
1757            .expect("copy must succeed");
1758
1759        assert_eq!(metadata.content_length(), 7);
1760        let requests = transport.requests();
1761        assert_eq!(requests.len(), 1);
1762        assert_eq!(requests[0].method, Method::PUT);
1763        assert!(
1764            !requests[0]
1765                .headers
1766                .contains_key(constants::X_AMZ_COPY_SOURCE_IF_MATCH)
1767        );
1768    }
1769
1770    #[tokio::test]
1771    async fn test_s3_copy_hint_preserves_explicit_source_version() {
1772        let transport = S3ExpressMockTransport::new(CreateSessionOutcome::Success);
1773        let op = s3_general_operator(transport.clone());
1774
1775        op.copy_with("source", "target")
1776            .source_version("source-version")
1777            .source_content_length_hint(7)
1778            .await
1779            .expect("copy must succeed");
1780
1781        let requests = transport.requests();
1782        assert_eq!(requests.len(), 1);
1783        assert!(
1784            header(&requests[0], constants::X_AMZ_COPY_SOURCE).contains("versionId=source-version")
1785        );
1786    }
1787
1788    #[tokio::test]
1789    async fn test_s3_copy_without_hint_pins_source_etag() {
1790        let transport = S3ExpressMockTransport::new(CreateSessionOutcome::Success)
1791            .with_source_content_length(7);
1792        let op = s3_general_operator(transport.clone());
1793
1794        let metadata = op
1795            .copy("source", "target")
1796            .await
1797            .expect("copy must succeed");
1798
1799        assert_eq!(metadata.content_length(), 7);
1800        let requests = transport.requests();
1801        assert_eq!(
1802            requests
1803                .iter()
1804                .filter(|request| request.method == Method::HEAD)
1805                .count(),
1806            1
1807        );
1808        let copy = requests
1809            .iter()
1810            .find(|request| request.headers.contains_key(constants::X_AMZ_COPY_SOURCE))
1811            .expect("CopyObject request must be captured");
1812        assert_eq!(
1813            header(copy, constants::X_AMZ_COPY_SOURCE_IF_MATCH),
1814            "\"source-etag\""
1815        );
1816    }
1817
1818    #[tokio::test]
1819    async fn test_s3_copy_uses_version_from_source_snapshot() {
1820        let transport = S3ExpressMockTransport::new(CreateSessionOutcome::Success)
1821            .with_source_version("source-version");
1822        let op = s3_general_operator(transport.clone());
1823
1824        op.copy_with("source", "target")
1825            .await
1826            .expect("copy must succeed");
1827
1828        let requests = transport.requests();
1829        let copy = requests
1830            .iter()
1831            .find(|request| request.headers.contains_key(constants::X_AMZ_COPY_SOURCE))
1832            .expect("CopyObject request must be captured");
1833        assert!(header(copy, constants::X_AMZ_COPY_SOURCE).contains("versionId=source-version"));
1834        assert_eq!(
1835            header(copy, constants::X_AMZ_COPY_SOURCE_IF_MATCH),
1836            "\"source-etag\""
1837        );
1838    }
1839
1840    #[tokio::test]
1841    async fn test_s3_express_copy_and_presign_use_iam_credentials() {
1842        let transport = S3ExpressMockTransport::new(CreateSessionOutcome::Success);
1843        let op = s3_express_operator(transport.clone());
1844
1845        op.copy_with("source", "target")
1846            .source_content_length_hint(1)
1847            .await
1848            .expect("copy must succeed");
1849        let requests = transport.requests();
1850        assert_eq!(
1851            requests
1852                .iter()
1853                .filter(|request| request.uri.query() == Some("session"))
1854                .count(),
1855            0,
1856            "a source length hint must skip HeadObject"
1857        );
1858        let copy = requests
1859            .iter()
1860            .find(|request| request.headers.contains_key(constants::X_AMZ_COPY_SOURCE))
1861            .expect("CopyObject request must be captured");
1862        assert_eq!(header(copy, "x-amz-security-token"), "source-session-token");
1863        assert!(!copy.headers.contains_key("x-amz-s3session-token"));
1864        assert!(header(copy, "authorization").contains("source-access-key/"));
1865        assert!(
1866            !copy
1867                .headers
1868                .contains_key(constants::X_AMZ_COPY_SOURCE_IF_MATCH)
1869        );
1870
1871        let presigned = op
1872            .presign_read("source", Duration::from_secs(60))
1873            .await
1874            .expect("presign must succeed");
1875        let query = presigned.uri().query().expect("presign query must exist");
1876        assert!(query.contains("X-Amz-Credential=source-access-key%2F"));
1877        assert!(query.contains("X-Amz-Security-Token=source-session-token"));
1878        assert!(!query.contains("session-access-key"));
1879        assert!(!query.contains("x-amz-s3session-token"));
1880        assert_eq!(transport.requests().len(), requests.len());
1881    }
1882
1883    #[tokio::test]
1884    async fn test_s3_express_upload_part_copy_uses_iam_credentials() {
1885        let source_content_length = 5 * 1024 * 1024 + 1;
1886        let transport = S3ExpressMockTransport::new(CreateSessionOutcome::Success)
1887            .with_source_content_length(source_content_length);
1888        let op = s3_express_operator(transport.clone());
1889
1890        op.copy_with("source", "target")
1891            .chunk(5 * 1024 * 1024)
1892            .source_content_length_hint(source_content_length)
1893            .await
1894            .expect("multipart copy must succeed");
1895
1896        let requests = transport.requests();
1897        let part_copies = requests
1898            .iter()
1899            .filter(|request| {
1900                request.headers.contains_key(constants::X_AMZ_COPY_SOURCE)
1901                    && request
1902                        .uri
1903                        .query()
1904                        .is_some_and(|query| query.contains("partNumber="))
1905            })
1906            .collect::<Vec<_>>();
1907        assert_eq!(part_copies.len(), 2);
1908        for request in part_copies {
1909            assert_eq!(
1910                header(request, "x-amz-security-token"),
1911                "source-session-token"
1912            );
1913            assert!(!request.headers.contains_key("x-amz-s3session-token"));
1914            assert!(header(request, "authorization").contains("source-access-key/"));
1915            assert!(
1916                !request
1917                    .headers
1918                    .contains_key(constants::X_AMZ_COPY_SOURCE_IF_MATCH)
1919            );
1920        }
1921        assert_eq!(
1922            requests
1923                .iter()
1924                .filter(|request| request.uri.query() == Some("session"))
1925                .count(),
1926            1,
1927            "session operations in multipart copy must share one session"
1928        );
1929    }
1930
1931    #[tokio::test]
1932    async fn test_s3_multipart_copy_without_hint_pins_every_part() {
1933        let source_content_length = 5 * 1024 * 1024 + 1;
1934        let transport = S3ExpressMockTransport::new(CreateSessionOutcome::Success)
1935            .with_source_content_length(source_content_length);
1936        let op = s3_general_operator(transport.clone());
1937
1938        op.copy_with("source", "target")
1939            .chunk(5 * 1024 * 1024)
1940            .await
1941            .expect("multipart copy must succeed");
1942
1943        let requests = transport.requests();
1944        let part_copies = requests
1945            .iter()
1946            .filter(|request| {
1947                request.headers.contains_key(constants::X_AMZ_COPY_SOURCE)
1948                    && request
1949                        .uri
1950                        .query()
1951                        .is_some_and(|query| query.contains("partNumber="))
1952            })
1953            .collect::<Vec<_>>();
1954        assert_eq!(part_copies.len(), 2);
1955        for request in part_copies {
1956            assert_eq!(
1957                header(request, constants::X_AMZ_COPY_SOURCE_IF_MATCH),
1958                "\"source-etag\""
1959            );
1960        }
1961    }
1962
1963    #[tokio::test]
1964    async fn test_s3_express_writer_copy_from_uses_iam_credentials() {
1965        let transport = S3ExpressMockTransport::new(CreateSessionOutcome::Success);
1966        let op = s3_express_operator(transport.clone());
1967
1968        let mut writer = op.writer("target").await.expect("writer must open");
1969        writer
1970            .copy_from("source", 0..5 * 1024 * 1024_u64)
1971            .await
1972            .expect("writer copy must succeed");
1973        writer.close().await.expect("writer must close");
1974
1975        let requests = transport.requests();
1976        let part_copy = requests
1977            .iter()
1978            .find(|request| {
1979                request.headers.contains_key(constants::X_AMZ_COPY_SOURCE)
1980                    && request
1981                        .uri
1982                        .query()
1983                        .is_some_and(|query| query.contains("partNumber="))
1984            })
1985            .expect("UploadPartCopy request must be captured");
1986        assert_eq!(
1987            header(part_copy, "x-amz-security-token"),
1988            "source-session-token"
1989        );
1990        assert!(!part_copy.headers.contains_key("x-amz-s3session-token"));
1991        assert!(header(part_copy, "authorization").contains("source-access-key/"));
1992        assert_eq!(
1993            requests
1994                .iter()
1995                .filter(|request| request.uri.query() == Some("session"))
1996                .count(),
1997            1,
1998            "writer multipart operations must share one session"
1999        );
2000    }
2001
2002    #[tokio::test]
2003    async fn test_s3_express_create_session_error_mapping() {
2004        for (outcome, kind, temporary) in [
2005            (
2006                CreateSessionOutcome::Status(StatusCode::FORBIDDEN),
2007                ErrorKind::PermissionDenied,
2008                false,
2009            ),
2010            (
2011                CreateSessionOutcome::Status(StatusCode::TOO_MANY_REQUESTS),
2012                ErrorKind::RateLimited,
2013                true,
2014            ),
2015            (
2016                CreateSessionOutcome::Status(StatusCode::INTERNAL_SERVER_ERROR),
2017                ErrorKind::Unexpected,
2018                true,
2019            ),
2020            (
2021                CreateSessionOutcome::TemporaryTransportError,
2022                ErrorKind::Unexpected,
2023                true,
2024            ),
2025            (
2026                CreateSessionOutcome::Body(
2027                    "<CreateSessionResult><Credentials>\
2028                     <SecretAccessKey>session-secret-key</SecretAccessKey>\
2029                     <AccessKeyId>session-access-key</AccessKeyId>\
2030                     <Expiration>2099-01-01T00:05:00Z</Expiration>\
2031                     </Credentials></CreateSessionResult>",
2032                ),
2033                ErrorKind::Unexpected,
2034                false,
2035            ),
2036            (
2037                CreateSessionOutcome::Body(
2038                    "<CreateSessionResult><Credentials>\
2039                     <SessionToken>session-token</SessionToken>\
2040                     <SecretAccessKey>session-secret-key</SecretAccessKey>\
2041                     <AccessKeyId>session-access-key</AccessKeyId>\
2042                     <Expiration>2020-01-01T00:05:00Z</Expiration>\
2043                     </Credentials></CreateSessionResult>",
2044                ),
2045                ErrorKind::Unexpected,
2046                false,
2047            ),
2048        ] {
2049            let transport = S3ExpressMockTransport::new(outcome);
2050            let op = s3_express_operator(transport);
2051            let err = op
2052                .write("test", "body")
2053                .await
2054                .expect_err("CreateSession failure must reach the caller");
2055            assert_eq!(err.kind(), kind);
2056            assert_eq!(err.is_temporary(), temporary);
2057        }
2058    }
2059
2060    #[test]
2061    fn test_profile() {
2062        let builder = S3Builder::default().profile("selected");
2063        assert_eq!(builder.config.profile.as_deref(), Some("selected"));
2064    }
2065
2066    #[test]
2067    fn test_write_can_copy_from_capability() {
2068        let backend = S3Builder::default()
2069            .bucket("test")
2070            .region("us-east-1")
2071            .endpoint("http://127.0.0.1:9000")
2072            .build()
2073            .unwrap();
2074        assert!(backend.capability().write_can_copy_from);
2075
2076        let backend = S3Builder::default()
2077            .bucket("test")
2078            .region("us-east-1")
2079            .endpoint("http://127.0.0.1:9000")
2080            .checksum_algorithm("crc32c")
2081            .build()
2082            .unwrap();
2083        assert!(!backend.capability().write_can_copy_from);
2084    }
2085
2086    #[test]
2087    fn test_is_valid_bucket() {
2088        let bucket_cases = vec![
2089            ("", false, false),
2090            ("test", false, true),
2091            ("test.xyz", false, true),
2092            ("", true, false),
2093            ("test", true, true),
2094            ("test.xyz", true, false),
2095        ];
2096
2097        for (bucket, enable_virtual_host_style, expected) in bucket_cases {
2098            let mut b = S3Builder::default();
2099            b = b.bucket(bucket);
2100            if enable_virtual_host_style {
2101                b = b.enable_virtual_host_style();
2102            }
2103            assert_eq!(S3Builder::is_bucket_valid(&b.config), expected)
2104        }
2105    }
2106
2107    #[test]
2108    fn test_build_endpoint() {
2109        let endpoint_cases = vec![
2110            Some("s3.amazonaws.com"),
2111            Some("https://s3.amazonaws.com"),
2112            Some("https://s3.us-east-2.amazonaws.com"),
2113            None,
2114        ];
2115
2116        for endpoint in &endpoint_cases {
2117            let mut b = S3Builder::default().bucket("test");
2118            if let Some(endpoint) = endpoint {
2119                b = b.endpoint(endpoint);
2120            }
2121
2122            let endpoint = S3Builder::build_endpoint(&b.config, "us-east-2");
2123            assert_eq!(endpoint, "https://s3.us-east-2.amazonaws.com/test");
2124        }
2125
2126        for endpoint in &endpoint_cases {
2127            let mut b = S3Builder::default()
2128                .bucket("test")
2129                .enable_virtual_host_style();
2130            if let Some(endpoint) = endpoint {
2131                b = b.endpoint(endpoint);
2132            }
2133
2134            let endpoint = S3Builder::build_endpoint(&b.config, "us-east-2");
2135            assert_eq!(endpoint, "https://test.s3.us-east-2.amazonaws.com");
2136        }
2137    }
2138
2139    #[test]
2140    fn test_resolve_s3_express_config() {
2141        let directory_bucket = "example--usw2-az1--x-s3";
2142
2143        for endpoint in [
2144            None,
2145            Some("s3.amazonaws.com"),
2146            Some("https://s3.us-west-2.amazonaws.com"),
2147            Some("https://s3.us-west-2.amazonaws.com:443"),
2148            Some("https://example--usw2-az1--x-s3.s3express-usw2-az1.us-west-2.amazonaws.com"),
2149        ] {
2150            let mut builder = S3Builder::default().bucket(directory_bucket);
2151            if let Some(endpoint) = endpoint {
2152                builder = builder.endpoint(endpoint);
2153            }
2154
2155            let config = S3Builder::resolve_s3_express_config(&builder.config, "us-west-2")
2156                .expect("valid directory bucket must resolve")
2157                .expect("AWS directory bucket must enable session authentication");
2158            assert_eq!(
2159                config.endpoint(),
2160                "https://example--usw2-az1--x-s3.s3express-usw2-az1.us-west-2.amazonaws.com"
2161            );
2162        }
2163    }
2164
2165    #[test]
2166    fn test_s3_express_classification_does_not_change_compatible_services() {
2167        let builder = S3Builder::default()
2168            .bucket("example--usw2-az1--x-s3")
2169            .endpoint("https://s3.example.com");
2170
2171        assert!(
2172            S3Builder::resolve_s3_express_config(&builder.config, "us-west-2")
2173                .expect("custom endpoint classification must succeed")
2174                .is_none()
2175        );
2176    }
2177
2178    #[test]
2179    fn test_s3_express_capabilities() {
2180        let backend = S3Builder::default()
2181            .bucket("example--usw2-az1--x-s3")
2182            .region("us-west-2")
2183            .disable_config_load()
2184            .disable_ec2_metadata()
2185            .build()
2186            .expect("S3 Express backend must build");
2187        let capability = backend.capability();
2188
2189        assert!(!capability.stat_with_version);
2190        assert!(!capability.read_with_version);
2191        assert!(!capability.write_with_content_encoding);
2192        assert!(!capability.delete_with_version);
2193        assert!(!capability.copy_with_source_version);
2194        assert!(!capability.restore);
2195        assert!(!capability.restore_with_version);
2196        assert!(!capability.restore_with_if_not_exists);
2197        assert!(!capability.list_with_start_after);
2198        assert!(!capability.list_with_recursive);
2199        assert!(!capability.list_with_versions);
2200        assert!(!capability.list_with_deleted);
2201    }
2202
2203    #[test]
2204    fn test_invalid_aws_directory_bucket_configuration_is_rejected() {
2205        let builder = S3Builder::default().bucket("example--usw2-az1--x-s3");
2206
2207        let err = S3Builder::resolve_s3_express_config(&builder.config, "us-east-1")
2208            .expect_err("zone and region mismatch must be rejected");
2209        assert_eq!(err.kind(), ErrorKind::ConfigInvalid);
2210    }
2211
2212    #[test]
2213    fn test_unsupported_aws_directory_bucket_endpoints_are_rejected() {
2214        for endpoint in [
2215            "https://s3.dualstack.us-west-2.amazonaws.com",
2216            "https://example--usw2-az1--x-s3.s3express-usw2-az1.dualstack.us-west-2.amazonaws.com",
2217            "https://example--usw2-az1--x-s3.s3express-use1-az1.us-west-2.amazonaws.com",
2218            "https://s3.us-west-2.amazonaws.com/path",
2219            "https://s3.us-west-2.amazonaws.com.cn",
2220        ] {
2221            let builder = S3Builder::default()
2222                .bucket("example--usw2-az1--x-s3")
2223                .endpoint(endpoint);
2224
2225            let err = S3Builder::resolve_s3_express_config(&builder.config, "us-west-2")
2226                .expect_err("unsupported AWS directory bucket endpoint must be rejected");
2227            assert_eq!(err.kind(), ErrorKind::ConfigInvalid, "endpoint: {endpoint}");
2228        }
2229    }
2230
2231    #[tokio::test]
2232    async fn test_detect_region() {
2233        let cases = vec![
2234            (
2235                "aws s3 without region in endpoint",
2236                "https://s3.amazonaws.com",
2237                "example",
2238                Some("us-east-1"),
2239            ),
2240            (
2241                "aws s3 with region in endpoint",
2242                "https://s3.us-east-1.amazonaws.com",
2243                "example",
2244                Some("us-east-1"),
2245            ),
2246            (
2247                "oss with public endpoint",
2248                "https://oss-ap-southeast-1.aliyuncs.com",
2249                "example",
2250                Some("oss-ap-southeast-1"),
2251            ),
2252            (
2253                "oss with internal endpoint",
2254                "https://oss-cn-hangzhou-internal.aliyuncs.com",
2255                "example",
2256                Some("oss-cn-hangzhou"),
2257            ),
2258            (
2259                "r2",
2260                "https://abc.xxxxx.r2.cloudflarestorage.com",
2261                "example",
2262                Some("auto"),
2263            ),
2264            (
2265                "invalid service",
2266                "https://opendal.apache.org",
2267                "example",
2268                None,
2269            ),
2270        ];
2271
2272        for (name, endpoint, bucket, expected) in cases {
2273            let region = S3Builder::detect_region(endpoint, bucket).await;
2274            assert_eq!(region.as_deref(), expected, "{name}");
2275        }
2276    }
2277
2278    #[tokio::test]
2279    async fn test_presign_write_preserves_content_type() {
2280        let backend = S3Builder::default()
2281            .bucket("test")
2282            .region("us-east-1")
2283            .skip_signature()
2284            .disable_config_load()
2285            .disable_ec2_metadata()
2286            .build()
2287            .expect("build");
2288
2289        let (op, _) = OpWrite::from_options(
2290            &backend.capability(),
2291            options::WriteOptions {
2292                content_type: Some("application/json".to_owned()),
2293                ..Default::default()
2294            },
2295        )
2296        .unwrap();
2297        let args = OpPresign::new(op, Duration::from_secs(3600));
2298        let ctx = OperationContext::new();
2299        let presigned = backend
2300            .presign(&ctx, "test.txt", args)
2301            .await
2302            .expect("presign")
2303            .into_presigned_request();
2304
2305        assert_eq!(
2306            presigned.header().get(http::header::CONTENT_TYPE).unwrap(),
2307            "application/json"
2308        );
2309    }
2310
2311    #[tokio::test]
2312    async fn test_presign_stat_encodes_version_id() {
2313        let backend = S3Builder::default()
2314            .bucket("test")
2315            .region("us-east-1")
2316            .skip_signature()
2317            .disable_config_load()
2318            .disable_ec2_metadata()
2319            .build()
2320            .expect("build");
2321
2322        let op: OpStat = options::StatOptions {
2323            version: Some("a+b/c=d%25&e".to_owned()),
2324            ..Default::default()
2325        }
2326        .into();
2327        let args = OpPresign::new(op, Duration::from_secs(3600));
2328        let ctx = OperationContext::new();
2329        let presigned = backend
2330            .presign(&ctx, "test.txt", args)
2331            .await
2332            .expect("presign")
2333            .into_presigned_request();
2334
2335        assert_eq!(
2336            presigned.uri().to_string(),
2337            "https://s3.us-east-1.amazonaws.com/test/test.txt?versionId=a%2Bb/c%3Dd%2525%26e"
2338        );
2339    }
2340
2341    #[tokio::test]
2342    async fn test_presign_read_encodes_version_id() {
2343        let backend = S3Builder::default()
2344            .bucket("test")
2345            .region("us-east-1")
2346            .skip_signature()
2347            .disable_config_load()
2348            .disable_ec2_metadata()
2349            .build()
2350            .expect("build");
2351
2352        let (_, op, _) = options::ReadOptions {
2353            version: Some("a+b/c=d%25&e".to_owned()),
2354            ..Default::default()
2355        }
2356        .into();
2357        let args = OpPresign::new(
2358            PresignOperation::Read(BytesRange::default(), op),
2359            Duration::from_secs(3600),
2360        );
2361        let ctx = OperationContext::new();
2362        let presigned = backend
2363            .presign(&ctx, "test.txt", args)
2364            .await
2365            .expect("presign")
2366            .into_presigned_request();
2367
2368        assert_eq!(
2369            presigned.uri().to_string(),
2370            "https://s3.us-east-1.amazonaws.com/test/test.txt?versionId=a%2Bb/c%3Dd%2525%26e"
2371        );
2372    }
2373}