| // Licensed to the Apache Software Foundation (ASF) under one |
| // or more contributor license agreements. See the NOTICE file |
| // distributed with this work for additional information |
| // regarding copyright ownership. The ASF licenses this file |
| // to you under the Apache License, Version 2.0 (the |
| // "License"); you may not use this file except in compliance |
| // with the License. You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, |
| // software distributed under the License is distributed on an |
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| // KIND, either express or implied. See the License for the |
| // specific language governing permissions and limitations |
| // under the License. |
| |
| use std::collections::HashMap; |
| |
| use opendal::services::S3Config; |
| use opendal::{Configurator, Operator}; |
| use url::Url; |
| |
| use crate::error::Error; |
| use crate::Result; |
| |
| use super::storage_config::normalize_storage_config; |
| |
| /// Configuration key for S3 endpoint. |
| /// |
| /// Compatible with paimon-java's `s3.endpoint` / `fs.s3a.endpoint`. |
| const S3_ENDPOINT: &str = "s3.endpoint"; |
| |
| /// Configuration key for S3 access key ID. |
| /// |
| /// Compatible with paimon-java's `s3.access-key` / `fs.s3a.access.key`. |
| const S3_ACCESS_KEY: &str = "s3.access-key"; |
| |
| /// Configuration key for S3 secret key. |
| /// |
| /// Compatible with paimon-java's `s3.secret-key` / `fs.s3a.secret.key`. |
| const S3_SECRET_KEY: &str = "s3.secret-key"; |
| |
| /// Configuration key for S3 path-style access. |
| /// |
| /// When set to `true`, uses path-style URLs (`https://s3.endpoint/bucket`) |
| /// instead of virtual-hosted style (`https://bucket.s3.endpoint`). |
| /// |
| /// Compatible with paimon-java's `s3.path-style-access` / `fs.s3a.path.style.access`. |
| const S3_PATH_STYLE_ACCESS: &str = "s3.path-style-access"; |
| |
| /// Configuration key for S3 region. |
| /// |
| /// Compatible with paimon-java's `s3.region`. |
| const S3_REGION: &str = "s3.region"; |
| |
| /// Paimon-java config key prefixes that map to S3 configuration. |
| /// |
| /// Java's `S3FileIO` normalizes all of these prefixes to `fs.s3a.*` before |
| /// passing them to Hadoop. Here we extract the suffix after matching prefix |
| /// and map to our canonical `s3.*` keys. |
| /// |
| /// Reference: `S3FileIO.CONFIG_PREFIXES` in Java Paimon. |
| const JAVA_CONFIG_PREFIXES: &[&str] = &["fs.s3a.", "s3a.", "s3."]; |
| |
| /// Mirrored config keys — Java Paimon maps these interchangeably. |
| /// Both directions are applied so users can use either form. |
| /// |
| /// Reference: `S3FileIO.MIRRORED_CONFIG_KEYS` in Java Paimon. |
| const MIRRORED_KEYS: &[(&str, &str)] = &[ |
| ("s3.access-key", "s3.access.key"), |
| ("s3.secret-key", "s3.secret.key"), |
| ("s3.path-style-access", "s3.path.style.access"), |
| ]; |
| |
| /// Parse paimon catalog options into an [`S3Config`]. |
| /// |
| /// Extracts S3-related configuration keys from the provided properties map. |
| /// Supports multiple key prefixes for Java compatibility (`s3.`, `s3a.`, `fs.s3a.`). |
| /// |
| /// By default, virtual-hosted style addressing is enabled (matching AWS |
| /// and Java Paimon behavior). Set `s3.path-style-access=true` to switch |
| /// to path-style for S3-compatible stores like MinIO. |
| #[allow(clippy::field_reassign_with_default)] |
| pub(crate) fn s3_config_parse(props: HashMap<String, String>) -> Result<S3Config> { |
| let normalized = normalize_storage_config(props, JAVA_CONFIG_PREFIXES, "s3.", MIRRORED_KEYS); |
| |
| let mut cfg = S3Config::default(); |
| |
| // Default to virtual-hosted style, matching AWS and Java Paimon. |
| // Only disable when path-style-access is explicitly set to true. |
| let path_style_access = normalized |
| .get(S3_PATH_STYLE_ACCESS) |
| .is_some_and(|v| v.eq_ignore_ascii_case("true")); |
| cfg.enable_virtual_host_style = !path_style_access; |
| |
| // Core connection settings. |
| cfg.endpoint = normalized.get(S3_ENDPOINT).cloned(); |
| cfg.access_key_id = normalized.get(S3_ACCESS_KEY).cloned(); |
| cfg.secret_access_key = normalized.get(S3_SECRET_KEY).cloned(); |
| cfg.region = normalized.get(S3_REGION).cloned(); |
| |
| // Session / assume-role credentials. |
| cfg.session_token = normalized.get("s3.session.token").cloned(); |
| cfg.role_arn = normalized.get("s3.assumed.role.arn").cloned(); |
| cfg.external_id = normalized.get("s3.assumed.role.externalId").cloned(); |
| cfg.role_session_name = normalized.get("s3.assumed.role.session.name").cloned(); |
| |
| // Anonymous access. |
| cfg.allow_anonymous = normalized |
| .get("s3.anonymous") |
| .is_some_and(|v| v.eq_ignore_ascii_case("true")); |
| |
| // Server-side encryption. |
| cfg.server_side_encryption = normalized.get("s3.sse.type").cloned(); |
| cfg.server_side_encryption_aws_kms_key_id = normalized.get("s3.sse.key").cloned(); |
| cfg.server_side_encryption_customer_algorithm = normalized.get("s3.sse-c.algorithm").cloned(); |
| cfg.server_side_encryption_customer_key = normalized.get("s3.sse-c.key").cloned(); |
| cfg.server_side_encryption_customer_key_md5 = normalized.get("s3.sse-c.key.md5").cloned(); |
| |
| // Storage class. |
| cfg.default_storage_class = normalized.get("s3.storage.class").cloned(); |
| |
| Ok(cfg) |
| } |
| |
| /// Build an [`Operator`] for the given S3 path. |
| /// |
| /// Parses the bucket name from the `s3://bucket/key` or `s3a://bucket/key` |
| /// URL and combines it with the provided [`S3Config`] to construct an |
| /// OpenDAL operator. |
| pub(crate) fn s3_config_build(cfg: &S3Config, path: &str) -> Result<Operator> { |
| let url = Url::parse(path).map_err(|_| Error::ConfigInvalid { |
| message: format!("Invalid S3 url: {path}"), |
| })?; |
| |
| let bucket = url.host_str().ok_or_else(|| Error::ConfigInvalid { |
| message: format!("Invalid S3 url: {path}, missing bucket"), |
| })?; |
| |
| let builder = cfg.clone().into_builder().bucket(bucket); |
| Ok(Operator::new(builder)?.finish()) |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| |
| fn make_props(pairs: &[(&str, &str)]) -> HashMap<String, String> { |
| pairs |
| .iter() |
| .map(|(k, v)| (k.to_string(), v.to_string())) |
| .collect() |
| } |
| |
| #[test] |
| fn test_s3_config_parse_canonical_keys() { |
| let props = make_props(&[ |
| ("s3.endpoint", "https://s3.us-east-1.amazonaws.com"), |
| ("s3.access-key", "AKID"), |
| ("s3.secret-key", "SECRET"), |
| ("s3.region", "us-east-1"), |
| ]); |
| |
| let cfg = s3_config_parse(props).unwrap(); |
| assert_eq!( |
| cfg.endpoint.as_deref(), |
| Some("https://s3.us-east-1.amazonaws.com") |
| ); |
| assert_eq!(cfg.access_key_id.as_deref(), Some("AKID")); |
| assert_eq!(cfg.secret_access_key.as_deref(), Some("SECRET")); |
| assert_eq!(cfg.region.as_deref(), Some("us-east-1")); |
| } |
| |
| #[test] |
| fn test_s3_config_parse_hadoop_prefix() { |
| // Keys with `fs.s3a.` prefix should be normalized to `s3.*`. |
| let props = make_props(&[ |
| ("fs.s3a.endpoint", "https://s3.eu-west-1.amazonaws.com"), |
| ("fs.s3a.access.key", "AKID2"), |
| ("fs.s3a.secret.key", "SECRET2"), |
| ]); |
| |
| let cfg = s3_config_parse(props).unwrap(); |
| assert_eq!( |
| cfg.endpoint.as_deref(), |
| Some("https://s3.eu-west-1.amazonaws.com") |
| ); |
| // `fs.s3a.access.key` → `s3.access.key`, then mirrored → `s3.access-key` |
| assert_eq!(cfg.access_key_id.as_deref(), Some("AKID2")); |
| assert_eq!(cfg.secret_access_key.as_deref(), Some("SECRET2")); |
| } |
| |
| #[test] |
| fn test_s3_config_parse_s3a_prefix() { |
| let props = make_props(&[ |
| ("s3a.endpoint", "https://s3.ap-southeast-1.amazonaws.com"), |
| ("s3a.access-key", "AKID3"), |
| ("s3a.secret-key", "SECRET3"), |
| ]); |
| |
| let cfg = s3_config_parse(props).unwrap(); |
| assert_eq!( |
| cfg.endpoint.as_deref(), |
| Some("https://s3.ap-southeast-1.amazonaws.com") |
| ); |
| assert_eq!(cfg.access_key_id.as_deref(), Some("AKID3")); |
| assert_eq!(cfg.secret_access_key.as_deref(), Some("SECRET3")); |
| } |
| |
| #[test] |
| fn test_s3_config_default_virtual_hosted_style() { |
| // Default should be virtual-hosted style (matching AWS behavior). |
| let props = make_props(&[("s3.endpoint", "https://s3.amazonaws.com")]); |
| |
| let cfg = s3_config_parse(props).unwrap(); |
| assert!(cfg.enable_virtual_host_style); |
| } |
| |
| #[test] |
| fn test_s3_config_parse_path_style_access_true() { |
| let props = make_props(&[ |
| ("s3.endpoint", "https://minio.local:9000"), |
| ("s3.path-style-access", "true"), |
| ]); |
| |
| let cfg = s3_config_parse(props).unwrap(); |
| assert!(!cfg.enable_virtual_host_style); |
| } |
| |
| #[test] |
| fn test_s3_config_parse_path_style_access_false() { |
| // Explicit false should keep virtual-hosted style enabled. |
| let props = make_props(&[ |
| ("s3.endpoint", "https://s3.amazonaws.com"), |
| ("s3.path-style-access", "false"), |
| ]); |
| |
| let cfg = s3_config_parse(props).unwrap(); |
| assert!(cfg.enable_virtual_host_style); |
| } |
| |
| #[test] |
| fn test_s3_config_parse_no_credentials() { |
| // Only endpoint, no credentials — valid for IAM role / env var auth. |
| let props = make_props(&[("s3.endpoint", "https://s3.amazonaws.com")]); |
| |
| let cfg = s3_config_parse(props).unwrap(); |
| assert_eq!(cfg.endpoint.as_deref(), Some("https://s3.amazonaws.com")); |
| assert!(cfg.access_key_id.is_none()); |
| assert!(cfg.secret_access_key.is_none()); |
| } |
| |
| #[test] |
| fn test_s3_config_parse_empty_props() { |
| let cfg = s3_config_parse(HashMap::new()).unwrap(); |
| assert!(cfg.endpoint.is_none()); |
| assert!(cfg.access_key_id.is_none()); |
| // Even with empty props, virtual-hosted style should be the default. |
| assert!(cfg.enable_virtual_host_style); |
| } |
| |
| #[test] |
| fn test_s3_config_parse_session_and_role() { |
| let props = make_props(&[ |
| ("s3.endpoint", "https://s3.amazonaws.com"), |
| ("s3.session.token", "TOKEN"), |
| ("s3.assumed.role.arn", "arn:aws:iam::123456:role/test"), |
| ("s3.assumed.role.externalId", "ext-id"), |
| ]); |
| |
| let cfg = s3_config_parse(props).unwrap(); |
| assert_eq!(cfg.session_token.as_deref(), Some("TOKEN")); |
| assert_eq!( |
| cfg.role_arn.as_deref(), |
| Some("arn:aws:iam::123456:role/test") |
| ); |
| assert_eq!(cfg.external_id.as_deref(), Some("ext-id")); |
| } |
| |
| #[test] |
| fn test_s3_config_parse_sse() { |
| let props = make_props(&[("s3.sse.type", "aws:kms"), ("s3.sse.key", "my-kms-key-id")]); |
| |
| let cfg = s3_config_parse(props).unwrap(); |
| assert_eq!(cfg.server_side_encryption.as_deref(), Some("aws:kms")); |
| assert_eq!( |
| cfg.server_side_encryption_aws_kms_key_id.as_deref(), |
| Some("my-kms-key-id") |
| ); |
| } |
| |
| #[test] |
| fn test_s3_config_unrelated_keys_ignored() { |
| // Keys that don't match any S3 prefix should not affect config. |
| let props = make_props(&[ |
| ("fs.oss.endpoint", "https://oss.aliyuncs.com"), |
| ("hive.metastore.uris", "thrift://localhost:9083"), |
| ("s3.endpoint", "https://s3.amazonaws.com"), |
| ]); |
| |
| let cfg = s3_config_parse(props).unwrap(); |
| assert_eq!(cfg.endpoint.as_deref(), Some("https://s3.amazonaws.com")); |
| } |
| |
| #[test] |
| #[allow(clippy::field_reassign_with_default)] |
| fn test_s3_config_build_extracts_bucket() { |
| let mut cfg = S3Config::default(); |
| cfg.endpoint = Some("https://s3.us-east-1.amazonaws.com".to_string()); |
| cfg.region = Some("us-east-1".to_string()); |
| |
| let op = s3_config_build(&cfg, "s3://my-bucket/some/path").unwrap(); |
| assert_eq!(op.info().name(), "my-bucket"); |
| } |
| |
| #[test] |
| #[allow(clippy::field_reassign_with_default)] |
| fn test_s3_config_build_s3a_scheme() { |
| let mut cfg = S3Config::default(); |
| cfg.endpoint = Some("https://s3.us-east-1.amazonaws.com".to_string()); |
| cfg.region = Some("us-east-1".to_string()); |
| |
| let op = s3_config_build(&cfg, "s3a://my-bucket/some/path").unwrap(); |
| assert_eq!(op.info().name(), "my-bucket"); |
| } |
| |
| #[test] |
| fn test_s3_config_build_invalid_url() { |
| let cfg = S3Config::default(); |
| let result = s3_config_build(&cfg, "not-a-valid-url"); |
| assert!(result.is_err()); |
| } |
| |
| #[test] |
| fn test_s3_config_build_missing_bucket() { |
| let cfg = S3Config::default(); |
| let result = s3_config_build(&cfg, "s3:///path/without/bucket"); |
| assert!(result.is_err()); |
| } |
| |
| #[test] |
| fn test_mirrored_keys() { |
| // `s3.access.key` (dot form) should be mirrored from `s3.access-key` (dash form) |
| let props = make_props(&[("s3.access-key", "AKID")]); |
| let normalized = |
| normalize_storage_config(props, JAVA_CONFIG_PREFIXES, "s3.", MIRRORED_KEYS); |
| assert_eq!( |
| normalized.get("s3.access.key").map(|s| s.as_str()), |
| Some("AKID") |
| ); |
| assert_eq!( |
| normalized.get("s3.access-key").map(|s| s.as_str()), |
| Some("AKID") |
| ); |
| } |
| |
| #[test] |
| fn test_canonical_overrides_hadoop_prefix() { |
| // `s3.endpoint` should take priority over `fs.s3a.endpoint`. |
| let props = make_props(&[ |
| ("fs.s3a.endpoint", "https://old.endpoint.com"), |
| ("s3.endpoint", "https://new.endpoint.com"), |
| ]); |
| |
| let cfg = s3_config_parse(props).unwrap(); |
| assert_eq!(cfg.endpoint.as_deref(), Some("https://new.endpoint.com")); |
| } |
| } |