Skip to main content

datafusion_cli/
object_storage.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
18pub mod instrumented;
19pub(crate) mod stdin;
20
21pub use stdin::{StdinCarriesCommands, is_stdin_location};
22
23use async_trait::async_trait;
24use aws_config::BehaviorVersion;
25use aws_credential_types::provider::{
26    ProvideCredentials, SharedCredentialsProvider, error::CredentialsError,
27};
28use datafusion::{
29    common::{
30        config::ConfigEntry, config::ConfigExtension, config::ConfigField,
31        config::ExtensionOptions, config::TableOptions, config::Visit, config_err,
32        exec_datafusion_err, exec_err,
33    },
34    error::{DataFusionError, Result},
35    execution::context::SessionState,
36};
37use log::debug;
38use object_store::{
39    ClientOptions, CredentialProvider,
40    Error::Generic,
41    ObjectStore,
42    aws::{AmazonS3Builder, AmazonS3ConfigKey, AwsCredential},
43    gcp::GoogleCloudStorageBuilder,
44    http::HttpBuilder,
45};
46use std::{
47    any::Any,
48    error::Error,
49    fmt::{Debug, Display},
50    sync::Arc,
51};
52use url::Url;
53
54#[cfg(not(test))]
55use object_store::aws::resolve_bucket_region;
56
57// Provide a local mock when running tests so we don't make network calls
58#[cfg(test)]
59#[expect(
60    clippy::unused_async,
61    reason = "matches object_store::aws::resolve_bucket_region"
62)]
63async fn resolve_bucket_region(
64    _bucket: &str,
65    _client_options: &ClientOptions,
66) -> object_store::Result<String> {
67    Ok("eu-central-1".to_string())
68}
69
70pub async fn get_s3_object_store_builder(
71    url: &Url,
72    aws_options: &AwsOptions,
73    resolve_region: bool,
74) -> Result<AmazonS3Builder> {
75    // Box the inner future to reduce the future size of this async function,
76    // which is deeply nested in the CLI's async call chain.
77    Box::pin(get_s3_object_store_builder_inner(
78        url,
79        aws_options,
80        resolve_region,
81    ))
82    .await
83}
84
85async fn get_s3_object_store_builder_inner(
86    url: &Url,
87    aws_options: &AwsOptions,
88    resolve_region: bool,
89) -> Result<AmazonS3Builder> {
90    let AwsOptions {
91        access_key_id,
92        secret_access_key,
93        session_token,
94        region,
95        endpoint,
96        allow_http,
97        skip_signature,
98    } = aws_options;
99
100    let bucket_name = get_bucket_name(url)?;
101    let mut builder = AmazonS3Builder::from_env().with_bucket_name(bucket_name);
102
103    if let (Some(access_key_id), Some(secret_access_key)) =
104        (access_key_id, secret_access_key)
105    {
106        debug!("Using explicitly provided S3 access_key_id and secret_access_key");
107        builder = builder
108            .with_access_key_id(access_key_id)
109            .with_secret_access_key(secret_access_key);
110
111        if let Some(session_token) = session_token {
112            builder = builder.with_token(session_token);
113        }
114    } else {
115        debug!("Using AWS S3 SDK to determine credentials");
116        let CredentialsFromConfig {
117            region,
118            credentials,
119        } = CredentialsFromConfig::try_new().await?;
120        if let Some(region) = region {
121            builder = builder.with_region(region);
122        }
123        if let Some(credentials) = credentials {
124            let credentials = Arc::new(S3CredentialProvider { credentials });
125            builder = builder.with_credentials(credentials);
126        } else {
127            debug!("No credentials found, defaulting to skip signature ");
128            builder = builder.with_skip_signature(true);
129        }
130    }
131
132    if let Some(region) = region {
133        builder = builder.with_region(region);
134    }
135
136    // If the region is not set or auto_detect_region is true, resolve the region.
137    if builder
138        .get_config_value(&AmazonS3ConfigKey::Region)
139        .is_none()
140        || resolve_region
141    {
142        let region = resolve_bucket_region(bucket_name, &ClientOptions::new()).await?;
143        builder = builder.with_region(region);
144    }
145
146    if let Some(endpoint) = endpoint {
147        // Make a nicer error if the user hasn't allowed http and the endpoint
148        // is http as the default message is "URL scheme is not allowed"
149        if let Ok(endpoint_url) = Url::try_from(endpoint.as_str())
150            && !matches!(allow_http, Some(true))
151            && endpoint_url.scheme() == "http"
152        {
153            return config_err!(
154                "Invalid endpoint: {endpoint}. \
155                HTTP is not allowed for S3 endpoints. \
156                To allow HTTP, set 'aws.allow_http' to true"
157            );
158        }
159
160        builder = builder.with_endpoint(endpoint);
161    }
162
163    if let Some(allow_http) = allow_http {
164        builder = builder.with_allow_http(*allow_http);
165    }
166
167    if let Some(skip_signature) = skip_signature {
168        builder = builder.with_skip_signature(*skip_signature);
169    }
170
171    Ok(builder)
172}
173
174/// Credentials from the AWS SDK
175struct CredentialsFromConfig {
176    region: Option<String>,
177    credentials: Option<SharedCredentialsProvider>,
178}
179
180impl CredentialsFromConfig {
181    /// Attempt find AWS S3 credentials via the AWS SDK
182    pub async fn try_new() -> Result<Self> {
183        // Loading the SDK config produces a large future, so box it to avoid
184        // potentially triggering the `large_futures` clippy lint.
185        let config =
186            Box::pin(aws_config::defaults(BehaviorVersion::latest()).load()).await;
187        let region = config.region().map(|r| r.to_string());
188
189        let credentials = config
190            .credentials_provider()
191            .ok_or_else(|| {
192                DataFusionError::ObjectStore(Box::new(Generic {
193                    store: "S3",
194                    source: "Failed to get S3 credentials aws_config".into(),
195                }))
196            })?
197            .clone();
198
199        // The credential provider is lazy, so it does not fetch credentials
200        // until they are needed. To ensure that the credentials are valid,
201        // we can call `provide_credentials` here.
202        let credentials = match credentials.provide_credentials().await {
203            Ok(_) => Some(credentials),
204            Err(CredentialsError::CredentialsNotLoaded(_)) => {
205                debug!("Could not use AWS SDK to get credentials");
206                None
207            }
208            // other errors like `CredentialsError::InvalidConfiguration`
209            // should be returned to the user so they can be fixed
210            Err(e) => {
211                // Pass back underlying error to the user, including underlying source
212                let source_message = if let Some(source) = e.source() {
213                    format!(": {source}")
214                } else {
215                    String::new()
216                };
217
218                let message = format!(
219                    "Error getting credentials from provider: {e}{source_message}",
220                );
221
222                return Err(DataFusionError::ObjectStore(Box::new(Generic {
223                    store: "S3",
224                    source: message.into(),
225                })));
226            }
227        };
228        Ok(Self {
229            region,
230            credentials,
231        })
232    }
233}
234
235#[derive(Debug)]
236struct S3CredentialProvider {
237    credentials: SharedCredentialsProvider,
238}
239
240#[async_trait]
241impl CredentialProvider for S3CredentialProvider {
242    type Credential = AwsCredential;
243
244    async fn get_credential(&self) -> object_store::Result<Arc<Self::Credential>> {
245        let creds =
246            self.credentials
247                .provide_credentials()
248                .await
249                .map_err(|e| Generic {
250                    store: "S3",
251                    source: Box::new(e),
252                })?;
253        Ok(Arc::new(AwsCredential {
254            key_id: creds.access_key_id().to_string(),
255            secret_key: creds.secret_access_key().to_string(),
256            token: creds.session_token().map(ToString::to_string),
257        }))
258    }
259}
260
261pub fn get_oss_object_store_builder(
262    url: &Url,
263    aws_options: &AwsOptions,
264) -> Result<AmazonS3Builder> {
265    get_object_store_builder(url, aws_options, true)
266}
267
268pub fn get_cos_object_store_builder(
269    url: &Url,
270    aws_options: &AwsOptions,
271) -> Result<AmazonS3Builder> {
272    get_object_store_builder(url, aws_options, false)
273}
274
275fn get_object_store_builder(
276    url: &Url,
277    aws_options: &AwsOptions,
278    virtual_hosted_style_request: bool,
279) -> Result<AmazonS3Builder> {
280    let bucket_name = get_bucket_name(url)?;
281    let mut builder = AmazonS3Builder::from_env()
282        .with_virtual_hosted_style_request(virtual_hosted_style_request)
283        .with_bucket_name(bucket_name)
284        // oss/cos don't care about the "region" field
285        .with_region("do_not_care");
286
287    if let (Some(access_key_id), Some(secret_access_key)) =
288        (&aws_options.access_key_id, &aws_options.secret_access_key)
289    {
290        builder = builder
291            .with_access_key_id(access_key_id)
292            .with_secret_access_key(secret_access_key);
293    }
294
295    if let Some(endpoint) = &aws_options.endpoint {
296        builder = builder.with_endpoint(endpoint);
297    }
298
299    Ok(builder)
300}
301
302pub fn get_gcs_object_store_builder(
303    url: &Url,
304    gs_options: &GcpOptions,
305) -> Result<GoogleCloudStorageBuilder> {
306    let bucket_name = get_bucket_name(url)?;
307    let mut builder = GoogleCloudStorageBuilder::from_env().with_bucket_name(bucket_name);
308
309    if let Some(service_account_path) = &gs_options.service_account_path {
310        builder = builder.with_service_account_path(service_account_path);
311    }
312
313    if let Some(service_account_key) = &gs_options.service_account_key {
314        builder = builder.with_service_account_key(service_account_key);
315    }
316
317    if let Some(application_credentials_path) = &gs_options.application_credentials_path {
318        builder = builder.with_application_credentials(application_credentials_path);
319    }
320
321    Ok(builder)
322}
323
324fn get_bucket_name(url: &Url) -> Result<&str> {
325    url.host_str().ok_or_else(|| {
326        exec_datafusion_err!("Not able to parse bucket name from url: {}", url.as_str())
327    })
328}
329
330/// This struct encapsulates AWS options one uses when setting up object storage.
331#[derive(Default, Debug, Clone)]
332pub struct AwsOptions {
333    /// Access Key ID
334    pub access_key_id: Option<String>,
335    /// Secret Access Key
336    pub secret_access_key: Option<String>,
337    /// Session token
338    pub session_token: Option<String>,
339    /// AWS Region
340    pub region: Option<String>,
341    /// OSS or COS Endpoint
342    pub endpoint: Option<String>,
343    /// Allow HTTP (otherwise will always use https)
344    pub allow_http: Option<bool>,
345    /// Do not fetch credentials and do not sign requests
346    ///
347    /// This can be useful when interacting with public S3 buckets that deny
348    /// authorized requests
349    pub skip_signature: Option<bool>,
350}
351
352impl ExtensionOptions for AwsOptions {
353    fn as_any(&self) -> &dyn Any {
354        self
355    }
356
357    fn as_any_mut(&mut self) -> &mut dyn Any {
358        self
359    }
360
361    fn cloned(&self) -> Box<dyn ExtensionOptions> {
362        Box::new(self.clone())
363    }
364
365    fn set(&mut self, key: &str, value: &str) -> Result<()> {
366        let (_key, aws_key) = key.split_once('.').unwrap_or((key, ""));
367        let (key, rem) = aws_key.split_once('.').unwrap_or((aws_key, ""));
368        match key {
369            "access_key_id" => {
370                self.access_key_id.set(rem, value)?;
371            }
372            "secret_access_key" => {
373                self.secret_access_key.set(rem, value)?;
374            }
375            "session_token" => {
376                self.session_token.set(rem, value)?;
377            }
378            "region" => {
379                self.region.set(rem, value)?;
380            }
381            "oss" | "cos" | "endpoint" => {
382                self.endpoint.set(rem, value)?;
383            }
384            "allow_http" => {
385                self.allow_http.set(rem, value)?;
386            }
387            "skip_signature" | "nosign" => {
388                self.skip_signature.set(rem, value)?;
389            }
390            _ => {
391                return config_err!("Config value \"{}\" not found on AwsOptions", rem);
392            }
393        }
394        Ok(())
395    }
396
397    fn entries(&self) -> Vec<ConfigEntry> {
398        struct Visitor(Vec<ConfigEntry>);
399
400        impl Visit for Visitor {
401            fn some<V: Display>(
402                &mut self,
403                key: &str,
404                value: V,
405                description: &'static str,
406            ) {
407                self.0.push(ConfigEntry {
408                    key: key.to_string(),
409                    value: Some(value.to_string()),
410                    description,
411                })
412            }
413
414            fn none(&mut self, key: &str, description: &'static str) {
415                self.0.push(ConfigEntry {
416                    key: key.to_string(),
417                    value: None,
418                    description,
419                })
420            }
421        }
422
423        let mut v = Visitor(vec![]);
424        self.access_key_id.visit(&mut v, "access_key_id", "");
425        self.secret_access_key
426            .visit(&mut v, "secret_access_key", "");
427        self.session_token.visit(&mut v, "session_token", "");
428        self.region.visit(&mut v, "region", "");
429        self.endpoint.visit(&mut v, "endpoint", "");
430        self.allow_http.visit(&mut v, "allow_http", "");
431        v.0
432    }
433}
434
435impl ConfigExtension for AwsOptions {
436    const PREFIX: &'static str = "aws";
437}
438
439/// This struct encapsulates GCP options one uses when setting up object storage.
440#[derive(Debug, Clone, Default)]
441pub struct GcpOptions {
442    /// Service account path
443    pub service_account_path: Option<String>,
444    /// Service account key
445    pub service_account_key: Option<String>,
446    /// Application credentials path
447    pub application_credentials_path: Option<String>,
448}
449
450impl ExtensionOptions for GcpOptions {
451    fn as_any(&self) -> &dyn Any {
452        self
453    }
454
455    fn as_any_mut(&mut self) -> &mut dyn Any {
456        self
457    }
458
459    fn cloned(&self) -> Box<dyn ExtensionOptions> {
460        Box::new(self.clone())
461    }
462
463    fn set(&mut self, key: &str, value: &str) -> Result<()> {
464        let (_key, rem) = key.split_once('.').unwrap_or((key, ""));
465        match rem {
466            "service_account_path" => {
467                self.service_account_path.set(rem, value)?;
468            }
469            "service_account_key" => {
470                self.service_account_key.set(rem, value)?;
471            }
472            "application_credentials_path" => {
473                self.application_credentials_path.set(rem, value)?;
474            }
475            _ => {
476                return config_err!("Config value \"{}\" not found on GcpOptions", rem);
477            }
478        }
479        Ok(())
480    }
481
482    fn entries(&self) -> Vec<ConfigEntry> {
483        struct Visitor(Vec<ConfigEntry>);
484
485        impl Visit for Visitor {
486            fn some<V: Display>(
487                &mut self,
488                key: &str,
489                value: V,
490                description: &'static str,
491            ) {
492                self.0.push(ConfigEntry {
493                    key: key.to_string(),
494                    value: Some(value.to_string()),
495                    description,
496                })
497            }
498
499            fn none(&mut self, key: &str, description: &'static str) {
500                self.0.push(ConfigEntry {
501                    key: key.to_string(),
502                    value: None,
503                    description,
504                })
505            }
506        }
507
508        let mut v = Visitor(vec![]);
509        self.service_account_path
510            .visit(&mut v, "service_account_path", "");
511        self.service_account_key
512            .visit(&mut v, "service_account_key", "");
513        self.application_credentials_path.visit(
514            &mut v,
515            "application_credentials_path",
516            "",
517        );
518        v.0
519    }
520}
521
522impl ConfigExtension for GcpOptions {
523    const PREFIX: &'static str = "gcp";
524}
525
526pub(crate) async fn get_object_store(
527    state: &SessionState,
528    scheme: &str,
529    url: &Url,
530    table_options: &TableOptions,
531    resolve_region: bool,
532) -> Result<Arc<dyn ObjectStore>, DataFusionError> {
533    let store: Arc<dyn ObjectStore> = match scheme {
534        "s3" => {
535            let Some(options) = table_options.extensions.get::<AwsOptions>() else {
536                return exec_err!(
537                    "Given table options incompatible with the 's3' scheme"
538                );
539            };
540            let builder =
541                get_s3_object_store_builder(url, options, resolve_region).await?;
542            Arc::new(builder.build()?)
543        }
544        "oss" => {
545            let Some(options) = table_options.extensions.get::<AwsOptions>() else {
546                return exec_err!(
547                    "Given table options incompatible with the 'oss' scheme"
548                );
549            };
550            let builder = get_oss_object_store_builder(url, options)?;
551            Arc::new(builder.build()?)
552        }
553        "cos" => {
554            let Some(options) = table_options.extensions.get::<AwsOptions>() else {
555                return exec_err!(
556                    "Given table options incompatible with the 'cos' scheme"
557                );
558            };
559            let builder = get_cos_object_store_builder(url, options)?;
560            Arc::new(builder.build()?)
561        }
562        "gs" | "gcs" => {
563            let Some(options) = table_options.extensions.get::<GcpOptions>() else {
564                return exec_err!(
565                    "Given table options incompatible with the 'gs'/'gcs' scheme"
566                );
567            };
568            let builder = get_gcs_object_store_builder(url, options)?;
569            Arc::new(builder.build()?)
570        }
571        "http" | "https" => Arc::new(
572            HttpBuilder::new()
573                .with_client_options(ClientOptions::new().with_allow_http(true))
574                .with_url(url.origin().ascii_serialization())
575                .build()?,
576        ),
577        _ if scheme == stdin::StdinUtils::SCHEME => {
578            stdin::StdinUtils::get_or_create(state, url).await?
579        }
580        _ => {
581            // For other types, try to get from `object_store_registry`:
582            state
583                .runtime_env()
584                .object_store_registry
585                .get_store(url)
586                .map_err(|_| {
587                    exec_datafusion_err!("Unsupported object store scheme: {}", scheme)
588                })?
589        }
590    };
591    Ok(store)
592}
593
594#[cfg(test)]
595mod tests {
596    use crate::cli_context::CliSessionContext;
597
598    use super::*;
599
600    use datafusion::{
601        datasource::listing::ListingTableUrl,
602        logical_expr::{DdlStatement, LogicalPlan},
603        prelude::SessionContext,
604    };
605
606    use object_store::{aws::AmazonS3ConfigKey, gcp::GoogleConfigKey};
607
608    #[tokio::test]
609    async fn s3_object_store_builder_default() -> Result<()> {
610        if let Err(DataFusionError::Execution(e)) = check_aws_envs() {
611            // Skip test if AWS envs are not set
612            eprintln!("{e}");
613            return Ok(());
614        }
615
616        let location = "s3://bucket/path/FAKE/file.parquet";
617        // Set it to a non-existent file to avoid reading the default configuration file
618        unsafe {
619            std::env::set_var("AWS_CONFIG_FILE", "data/aws.config");
620            std::env::set_var("AWS_SHARED_CREDENTIALS_FILE", "data/aws.credentials");
621        }
622
623        // No options
624        let table_url = ListingTableUrl::parse(location)?;
625        let scheme = table_url.scheme();
626        let sql =
627            format!("CREATE EXTERNAL TABLE test STORED AS PARQUET LOCATION '{location}'");
628
629        let ctx = SessionContext::new();
630        ctx.register_table_options_extension_from_scheme(scheme);
631        let table_options = get_table_options(&ctx, &sql).await;
632        let aws_options = table_options.extensions.get::<AwsOptions>().unwrap();
633        let builder =
634            get_s3_object_store_builder(table_url.as_ref(), aws_options, false).await?;
635
636        // If the environment variables are set (as they are in CI) use them
637        let expected_access_key_id = std::env::var("AWS_ACCESS_KEY_ID").ok();
638        let expected_secret_access_key = std::env::var("AWS_SECRET_ACCESS_KEY").ok();
639        let expected_region = Some(
640            std::env::var("AWS_REGION").unwrap_or_else(|_| "eu-central-1".to_string()),
641        );
642        let expected_endpoint = std::env::var("AWS_ENDPOINT").ok();
643
644        // get the actual configuration information, then assert_eq!
645        assert_eq!(
646            builder.get_config_value(&AmazonS3ConfigKey::AccessKeyId),
647            expected_access_key_id
648        );
649        assert_eq!(
650            builder.get_config_value(&AmazonS3ConfigKey::SecretAccessKey),
651            expected_secret_access_key
652        );
653        // Default is to skip signature when no credentials are provided
654        let expected_skip_signature =
655            if expected_access_key_id.is_none() && expected_secret_access_key.is_none() {
656                Some(String::from("true"))
657            } else {
658                Some(String::from("false"))
659            };
660        assert_eq!(
661            builder.get_config_value(&AmazonS3ConfigKey::Region),
662            expected_region
663        );
664        assert_eq!(
665            builder.get_config_value(&AmazonS3ConfigKey::Endpoint),
666            expected_endpoint
667        );
668        assert_eq!(builder.get_config_value(&AmazonS3ConfigKey::Token), None);
669        assert_eq!(
670            builder.get_config_value(&AmazonS3ConfigKey::SkipSignature),
671            expected_skip_signature
672        );
673        Ok(())
674    }
675
676    #[tokio::test]
677    async fn s3_object_store_builder() -> Result<()> {
678        // "fake" is uppercase to ensure the values are not lowercased when parsed
679        let access_key_id = "FAKE_access_key_id";
680        let secret_access_key = "FAKE_secret_access_key";
681        let region = "fake_us-east-2";
682        let endpoint = "endpoint33";
683        let session_token = "FAKE_session_token";
684        let location = "s3://bucket/path/FAKE/file.parquet";
685
686        let table_url = ListingTableUrl::parse(location)?;
687        let scheme = table_url.scheme();
688        let sql = format!(
689            "CREATE EXTERNAL TABLE test STORED AS PARQUET OPTIONS\
690            ('aws.access_key_id' '{access_key_id}', \
691            'aws.secret_access_key' '{secret_access_key}', \
692            'aws.region' '{region}', \
693            'aws.session_token' {session_token}, \
694            'aws.endpoint' '{endpoint}'\
695            ) LOCATION '{location}'"
696        );
697
698        let ctx = SessionContext::new();
699        ctx.register_table_options_extension_from_scheme(scheme);
700        let table_options = get_table_options(&ctx, &sql).await;
701        let aws_options = table_options.extensions.get::<AwsOptions>().unwrap();
702        let builder =
703            get_s3_object_store_builder(table_url.as_ref(), aws_options, false).await?;
704        // get the actual configuration information, then assert_eq!
705        let config = [
706            (AmazonS3ConfigKey::AccessKeyId, access_key_id),
707            (AmazonS3ConfigKey::SecretAccessKey, secret_access_key),
708            (AmazonS3ConfigKey::Region, region),
709            (AmazonS3ConfigKey::Endpoint, endpoint),
710            (AmazonS3ConfigKey::Token, session_token),
711        ];
712        for (key, value) in config {
713            assert_eq!(value, builder.get_config_value(&key).unwrap());
714        }
715        // Should not skip signature when credentials are provided
716        assert_eq!(
717            builder.get_config_value(&AmazonS3ConfigKey::SkipSignature),
718            Some("false".into())
719        );
720
721        Ok(())
722    }
723
724    #[tokio::test]
725    async fn s3_object_store_builder_allow_http_error() -> Result<()> {
726        let access_key_id = "fake_access_key_id";
727        let secret_access_key = "fake_secret_access_key";
728        let endpoint = "http://endpoint33";
729        let location = "s3://bucket/path/file.parquet";
730
731        let table_url = ListingTableUrl::parse(location)?;
732        let scheme = table_url.scheme();
733        let sql = format!(
734            "CREATE EXTERNAL TABLE test STORED AS PARQUET OPTIONS\
735            ('aws.access_key_id' '{access_key_id}', \
736            'aws.secret_access_key' '{secret_access_key}', \
737            'aws.endpoint' '{endpoint}'\
738            ) LOCATION '{location}'"
739        );
740
741        let ctx = SessionContext::new();
742        ctx.register_table_options_extension_from_scheme(scheme);
743
744        let table_options = get_table_options(&ctx, &sql).await;
745        let aws_options = table_options.extensions.get::<AwsOptions>().unwrap();
746        let err = get_s3_object_store_builder(table_url.as_ref(), aws_options, false)
747            .await
748            .unwrap_err();
749
750        assert_eq!(
751            err.to_string().lines().next().unwrap_or_default(),
752            "Invalid or Unsupported Configuration: Invalid endpoint: http://endpoint33. HTTP is not allowed for S3 endpoints. To allow HTTP, set 'aws.allow_http' to true"
753        );
754
755        // Now add `allow_http` to the options and check if it works
756        let sql = format!(
757            "CREATE EXTERNAL TABLE test STORED AS PARQUET OPTIONS\
758            ('aws.access_key_id' '{access_key_id}', \
759            'aws.secret_access_key' '{secret_access_key}', \
760            'aws.endpoint' '{endpoint}',\
761            'aws.allow_http' 'true'\
762            ) LOCATION '{location}'"
763        );
764        let table_options = get_table_options(&ctx, &sql).await;
765
766        let aws_options = table_options.extensions.get::<AwsOptions>().unwrap();
767        // ensure this isn't an error
768        get_s3_object_store_builder(table_url.as_ref(), aws_options, false).await?;
769
770        Ok(())
771    }
772
773    #[tokio::test]
774    async fn s3_object_store_builder_resolves_region_when_none_provided() -> Result<()> {
775        if let Err(DataFusionError::Execution(e)) = check_aws_envs() {
776            // Skip test if AWS envs are not set
777            eprintln!("{e}");
778            return Ok(());
779        }
780        let location = "s3://test-bucket/path/file.parquet";
781        // Set it to a non-existent file to avoid reading the default configuration file
782        unsafe {
783            std::env::set_var("AWS_CONFIG_FILE", "data/aws.config");
784        }
785
786        let table_url = ListingTableUrl::parse(location)?;
787        let aws_options = AwsOptions {
788            region: None, // No region specified - should auto-detect
789            ..Default::default()
790        };
791
792        let builder =
793            get_s3_object_store_builder(table_url.as_ref(), &aws_options, false).await?;
794
795        // Verify that the region was auto-detected in test environment
796        assert!(
797            builder
798                .get_config_value(&AmazonS3ConfigKey::Region)
799                .is_some()
800        );
801
802        Ok(())
803    }
804
805    #[tokio::test]
806    async fn s3_object_store_builder_overrides_region_when_resolve_region_enabled()
807    -> Result<()> {
808        if let Err(DataFusionError::Execution(e)) = check_aws_envs() {
809            // Skip test if AWS envs are not set
810            eprintln!("{e}");
811            return Ok(());
812        }
813
814        let original_region = "us-east-1";
815        let expected_region = "eu-central-1"; // This should be the auto-detected region
816        let location = "s3://test-bucket/path/file.parquet";
817
818        let table_url = ListingTableUrl::parse(location)?;
819        let aws_options = AwsOptions {
820            region: Some(original_region.to_string()), // Explicit region provided
821            ..Default::default()
822        };
823
824        let builder =
825            get_s3_object_store_builder(table_url.as_ref(), &aws_options, true).await?;
826
827        // Verify that the region was overridden by auto-detection
828        assert_eq!(
829            builder.get_config_value(&AmazonS3ConfigKey::Region),
830            Some(expected_region.to_string())
831        );
832
833        Ok(())
834    }
835
836    #[tokio::test]
837    async fn oss_object_store_builder() -> Result<()> {
838        let access_key_id = "fake_access_key_id";
839        let secret_access_key = "fake_secret_access_key";
840        let endpoint = "fake_endpoint";
841        let location = "oss://bucket/path/file.parquet";
842
843        let table_url = ListingTableUrl::parse(location)?;
844        let scheme = table_url.scheme();
845        let sql = format!(
846            "CREATE EXTERNAL TABLE test STORED AS PARQUET OPTIONS('aws.access_key_id' '{access_key_id}', 'aws.secret_access_key' '{secret_access_key}', 'aws.oss.endpoint' '{endpoint}') LOCATION '{location}'"
847        );
848
849        let ctx = SessionContext::new();
850        ctx.register_table_options_extension_from_scheme(scheme);
851        let table_options = get_table_options(&ctx, &sql).await;
852
853        let aws_options = table_options.extensions.get::<AwsOptions>().unwrap();
854        let builder = get_oss_object_store_builder(table_url.as_ref(), aws_options)?;
855        // get the actual configuration information, then assert_eq!
856        let config = [
857            (AmazonS3ConfigKey::AccessKeyId, access_key_id),
858            (AmazonS3ConfigKey::SecretAccessKey, secret_access_key),
859            (AmazonS3ConfigKey::Endpoint, endpoint),
860        ];
861        for (key, value) in config {
862            assert_eq!(value, builder.get_config_value(&key).unwrap());
863        }
864
865        Ok(())
866    }
867
868    #[tokio::test]
869    async fn gcs_object_store_builder() -> Result<()> {
870        let service_account_path = "fake_service_account_path";
871        let service_account_key = "{\"private_key\": \"fake_private_key.pem\",\"client_email\":\"fake_client_email\"}";
872        let application_credentials_path = "fake_application_credentials_path";
873        let location = "gcs://bucket/path/file.parquet";
874
875        let table_url = ListingTableUrl::parse(location)?;
876        let scheme = table_url.scheme();
877        let sql = format!(
878            "CREATE EXTERNAL TABLE test STORED AS PARQUET OPTIONS('gcp.service_account_path' '{service_account_path}', 'gcp.service_account_key' '{service_account_key}', 'gcp.application_credentials_path' '{application_credentials_path}') LOCATION '{location}'"
879        );
880
881        let ctx = SessionContext::new();
882        ctx.register_table_options_extension_from_scheme(scheme);
883        let table_options = get_table_options(&ctx, &sql).await;
884
885        let gcp_options = table_options.extensions.get::<GcpOptions>().unwrap();
886        let builder = get_gcs_object_store_builder(table_url.as_ref(), gcp_options)?;
887        // get the actual configuration information, then assert_eq!
888        let config = [
889            (GoogleConfigKey::ServiceAccount, service_account_path),
890            (GoogleConfigKey::ServiceAccountKey, service_account_key),
891            (
892                GoogleConfigKey::ApplicationCredentials,
893                application_credentials_path,
894            ),
895        ];
896        for (key, value) in config {
897            assert_eq!(value, builder.get_config_value(&key).unwrap());
898        }
899
900        Ok(())
901    }
902
903    /// Plans the `CREATE EXTERNAL TABLE` SQL statement and returns the
904    /// resulting resolved `CreateExternalTable` command.
905    async fn get_table_options(ctx: &SessionContext, sql: &str) -> TableOptions {
906        let mut plan = ctx.state().create_logical_plan(sql).await.unwrap();
907
908        let LogicalPlan::Ddl(DdlStatement::CreateExternalTable(cmd)) = &mut plan else {
909            panic!("plan is not a CreateExternalTable");
910        };
911
912        let mut table_options = ctx.state().default_table_options();
913        table_options
914            .alter_with_string_hash_map(&cmd.options)
915            .unwrap();
916        table_options
917    }
918
919    fn check_aws_envs() -> Result<()> {
920        let aws_envs = [
921            "AWS_ACCESS_KEY_ID",
922            "AWS_SECRET_ACCESS_KEY",
923            "AWS_REGION",
924            "AWS_ALLOW_HTTP",
925        ];
926        for aws_env in aws_envs {
927            std::env::var(aws_env).map_err(|_| {
928                exec_datafusion_err!("aws envs not set, skipping s3 tests")
929            })?;
930        }
931        Ok(())
932    }
933}