1use 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
64static ENDPOINT_TEMPLATES: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
66 let mut m = HashMap::new();
67 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#[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 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 pub fn bucket(mut self, bucket: &str) -> Self {
111 self.config.bucket = bucket.to_string();
112
113 self
114 }
115
116 pub fn endpoint(mut self, endpoint: &str) -> Self {
129 if !endpoint.is_empty() {
130 self.config.endpoint = Some(endpoint.trim_end_matches('/').to_string())
132 }
133
134 self
135 }
136
137 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn disable_config_load(mut self) -> Self {
421 self.config.disable_config_load = true;
422 self
423 }
424
425 pub fn disable_list_objects_v2(mut self) -> Self {
431 self.config.disable_list_objects_v2 = true;
432 self
433 }
434
435 pub fn enable_request_payer(mut self) -> Self {
439 self.config.enable_request_payer = true;
440 self
441 }
442
443 pub fn disable_ec2_metadata(mut self) -> Self {
448 self.config.disable_ec2_metadata = true;
449 self
450 }
451
452 pub fn skip_signature(mut self) -> Self {
454 self.config.skip_signature = true;
455 self
456 }
457
458 #[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 pub fn enable_virtual_host_style(mut self) -> Self {
474 self.config.enable_virtual_host_style = true;
475 self
476 }
477
478 #[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(
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 pub fn credential_provider_chain(mut self, chain: ProvideCredentialChain<Credential>) -> Self {
498 self.credential_providers = Some(chain);
499 self
500 }
501
502 fn is_bucket_valid(config: &S3Config) -> bool {
506 if config.bucket.is_empty() {
507 return false;
508 }
509 if config.enable_virtual_host_style && config.bucket.contains('.') {
513 return false;
514 }
515 true
516 }
517
518 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 format!("https://{endpoint}")
533 }
534 }
535 None => "https://s3.amazonaws.com".to_string(),
536 };
537
538 endpoint = endpoint.replace(&format!("//{bucket}."), "//");
540
541 if let Ok(url) = Url::parse(&endpoint) {
543 endpoint = url.to_string().trim_end_matches('/').to_string();
545 }
546
547 endpoint = if let Some(template) = ENDPOINT_TEMPLATES.get(endpoint.as_str()) {
549 template.replace("{region}", region)
550 } else {
551 endpoint.to_string()
554 };
555
556 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(
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(
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 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(
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(
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 pub async fn detect_region(endpoint: &str, bucket: &str) -> Option<String> {
731 let endpoint = endpoint.trim_end_matches('/');
733
734 let mut endpoint = if endpoint.starts_with("http") {
736 endpoint.to_string()
737 } else {
738 format!("https://{endpoint}")
740 };
741
742 endpoint = endpoint.replace(&format!("//{bucket}."), "//");
744 let url = format!("{endpoint}/{bucket}");
745
746 debug!("detect region with url: {url}");
747
748 if endpoint.ends_with("r2.cloudflarestorage.com") {
754 return Some("auto".to_string());
755 }
756
757 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 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 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 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 if res.status() == StatusCode::FORBIDDEN || res.status() == StatusCode::OK {
810 return Some("us-east-1".to_string());
811 }
812
813 None
814 }
815
816 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 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 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, ®ion)?;
941 let is_s3_express = s3_express_config.is_some();
942
943 let endpoint = match &s3_express_config {
945 Some(config) => config.endpoint().to_string(),
946 None => Self::build_endpoint(&config, ®ion),
947 };
948 debug!("backend use endpoint: {endpoint}");
949
950 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 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 if let Some(role_arn) = &config.role_arn {
987 let sts_ctx = ctx.clone().with_http_send(HttpTransporter::default());
991 let sts_request_signer = AwsV4Signer::new("sts", ®ion);
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 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", ®ion).with_standard_session_token(),
1028 );
1029 let session_provider =
1030 S3ExpressSessionProvider::new(bucket, provider).with_region(®ion);
1031 let session_signer = Signer::new(
1032 ctx,
1033 session_provider,
1034 AwsV4Signer::new("s3express", ®ion),
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", ®ion)))
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 write_multi_min_size: Some(5 * 1024 * 1024),
1087 write_multi_max_size: if cfg!(target_pointer_width = "64") {
1091 Some(5 * 1024 * 1024 * 1024)
1092 } else {
1093 Some(usize::MAX)
1094 },
1095 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 copy_multi_min_size: Some(5 * 1024 * 1024),
1118 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#[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 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 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 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}