Skip to main content

risingwave_connector/connector_common/iceberg/
mod.rs

1// Copyright 2024 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15pub mod compaction;
16mod jni_catalog;
17mod mock_catalog;
18mod storage_catalog;
19
20use std::collections::HashMap;
21use std::sync::{Arc, LazyLock};
22
23use ::iceberg::io::{
24    S3_ACCESS_KEY_ID, S3_ASSUME_ROLE_ARN, S3_ENDPOINT, S3_REGION, S3_SECRET_ACCESS_KEY,
25};
26use ::iceberg::table::Table;
27use ::iceberg::{Catalog, CatalogBuilder, TableIdent};
28use anyhow::{Context, anyhow};
29use iceberg::io::object_cache::ObjectCache;
30use iceberg::io::{
31    ADLS_ACCOUNT_KEY, ADLS_ACCOUNT_NAME, ADLS_AUTHORITY_HOST, ADLS_CLIENT_ID, ADLS_CLIENT_SECRET,
32    ADLS_TENANT_ID, AZBLOB_ACCOUNT_KEY, AZBLOB_ACCOUNT_NAME, AZBLOB_ENDPOINT, GCS_CREDENTIALS_JSON,
33    GCS_DISABLE_CONFIG_LOAD, S3_DISABLE_CONFIG_LOAD, S3_PATH_STYLE_ACCESS,
34};
35use iceberg_catalog_glue::{AWS_ACCESS_KEY_ID, AWS_REGION_NAME, AWS_SECRET_ACCESS_KEY};
36use moka::future::Cache as MokaCache;
37use phf::{Set, phf_set};
38use risingwave_common::bail;
39use risingwave_common::error::IcebergError;
40use risingwave_common::util::deployment::Deployment;
41use risingwave_common::util::env_var::env_var_is_true;
42use serde::Deserialize;
43use serde_with::serde_as;
44use url::Url;
45use uuid::Uuid;
46use with_options::WithOptions;
47
48use crate::connector_common::common::DISABLE_DEFAULT_CREDENTIAL;
49use crate::connector_common::iceberg::storage_catalog::StorageCatalogConfig;
50use crate::deserialize_optional_bool_from_string;
51use crate::enforce_secret::EnforceSecret;
52use crate::error::ConnectorResult;
53
54#[serde_as]
55#[derive(Debug, Clone, PartialEq, Eq, Deserialize, WithOptions)]
56pub struct IcebergCommon {
57    // Catalog type supported by iceberg, such as "storage", "rest".
58    // If not set, we use "storage" as default.
59    #[serde(rename = "catalog.type")]
60    pub catalog_type: Option<String>,
61    #[serde(rename = "s3.region")]
62    pub s3_region: Option<String>,
63    #[serde(rename = "s3.endpoint")]
64    pub s3_endpoint: Option<String>,
65    #[serde(rename = "s3.access.key")]
66    pub s3_access_key: Option<String>,
67    #[serde(rename = "s3.secret.key")]
68    pub s3_secret_key: Option<String>,
69    #[serde(rename = "s3.iam_role_arn")]
70    pub s3_iam_role_arn: Option<String>,
71
72    #[serde(rename = "glue.access.key")]
73    pub glue_access_key: Option<String>,
74    #[serde(rename = "glue.secret.key")]
75    pub glue_secret_key: Option<String>,
76    #[serde(rename = "glue.iam_role_arn")]
77    pub glue_iam_role_arn: Option<String>,
78    #[serde(rename = "glue.region")]
79    pub glue_region: Option<String>,
80    #[serde(rename = "glue.endpoint")]
81    pub glue_endpoint: Option<String>,
82    /// AWS Client id, can be omitted for storage catalog or when
83    /// caller's AWS account ID matches glue id
84    #[serde(rename = "glue.id")]
85    pub glue_id: Option<String>,
86
87    #[serde(rename = "gcs.credential")]
88    pub gcs_credential: Option<String>,
89
90    #[serde(rename = "azblob.account_name")]
91    pub azblob_account_name: Option<String>,
92    #[serde(rename = "azblob.account_key")]
93    pub azblob_account_key: Option<String>,
94    #[serde(rename = "azblob.endpoint_url")]
95    pub azblob_endpoint_url: Option<String>,
96
97    #[serde(rename = "adlsgen2.account_name")]
98    pub adlsgen2_account_name: Option<String>,
99    #[serde(rename = "adlsgen2.account_key")]
100    pub adlsgen2_account_key: Option<String>,
101    #[serde(rename = "adlsgen2.endpoint")]
102    pub adlsgen2_endpoint: Option<String>,
103    #[serde(rename = "adlsgen2.tenant_id")]
104    pub adlsgen2_tenant_id: Option<String>,
105    #[serde(rename = "adlsgen2.client_id")]
106    pub adlsgen2_client_id: Option<String>,
107    #[serde(rename = "adlsgen2.client_secret")]
108    pub adlsgen2_client_secret: Option<String>,
109    #[serde(rename = "adlsgen2.authority_host")]
110    pub adlsgen2_authority_host: Option<String>,
111
112    /// Path of iceberg warehouse.
113    #[serde(rename = "warehouse.path")]
114    pub warehouse_path: Option<String>,
115    /// Catalog name, default value is risingwave.
116    #[serde(rename = "catalog.name")]
117    pub catalog_name: Option<String>,
118    /// URI of iceberg catalog, only applicable in rest catalog.
119    #[serde(rename = "catalog.uri")]
120    pub catalog_uri: Option<String>,
121    /// Credential for accessing iceberg catalog, only applicable in rest catalog.
122    /// A credential to exchange for a token in the `OAuth2` client credentials flow.
123    #[serde(rename = "catalog.credential")]
124    pub catalog_credential: Option<String>,
125    /// token for accessing iceberg catalog, only applicable in rest catalog.
126    /// A Bearer token which will be used for interaction with the server.
127    #[serde(rename = "catalog.token")]
128    pub catalog_token: Option<String>,
129    /// `oauth2_server_uri` for accessing iceberg catalog, only applicable in rest catalog.
130    /// Token endpoint URI to fetch token from if the Rest Catalog is not the authorization server.
131    #[serde(rename = "catalog.oauth2_server_uri")]
132    pub catalog_oauth2_server_uri: Option<String>,
133    /// scope for accessing iceberg catalog, only applicable in rest catalog.
134    /// Additional scope for `OAuth2`.
135    #[serde(rename = "catalog.scope")]
136    pub catalog_scope: Option<String>,
137
138    /// The signing region to use when signing requests to the REST catalog.
139    #[serde(rename = "catalog.rest.signing_region")]
140    pub rest_signing_region: Option<String>,
141
142    /// The signing name to use when signing requests to the REST catalog.
143    #[serde(rename = "catalog.rest.signing_name")]
144    pub rest_signing_name: Option<String>,
145
146    /// Whether to use `SigV4` for signing requests to the REST catalog.
147    #[serde(
148        rename = "catalog.rest.sigv4_enabled",
149        default,
150        deserialize_with = "deserialize_optional_bool_from_string"
151    )]
152    pub rest_sigv4_enabled: Option<bool>,
153
154    #[serde(
155        rename = "s3.path.style.access",
156        default,
157        deserialize_with = "deserialize_optional_bool_from_string"
158    )]
159    pub s3_path_style_access: Option<bool>,
160    /// Enable config load. This parameter set to true will load warehouse credentials from the environment. Only allowed to be used in a self-hosted environment.
161    #[serde(default, deserialize_with = "deserialize_optional_bool_from_string")]
162    pub enable_config_load: Option<bool>,
163
164    /// This is only used by iceberg engine to enable the hosted catalog.
165    #[serde(
166        rename = "hosted_catalog",
167        default,
168        deserialize_with = "deserialize_optional_bool_from_string"
169    )]
170    pub hosted_catalog: Option<bool>,
171
172    /// The HTTP header to be used in catalog requests.
173    /// Example:
174    /// `catalog.header = "key1=value1;key2=value2;key3=value3"`
175    /// For Google Cloud Lakehouse Iceberg REST catalogs, set
176    /// `catalog.header = "x-goog-user-project=PROJECT_ID"` to specify the billing project.
177    /// Explain the format of the header:
178    /// - Each header is a key-value pair, separated by an '='.
179    /// - Multiple headers can be specified, separated by a ';'.
180    #[serde(rename = "catalog.header")]
181    pub catalog_header: Option<String>,
182
183    /// Enable vended credentials for Iceberg REST catalog.
184    /// For Google Cloud Lakehouse Iceberg REST catalogs, this sends
185    /// `X-Iceberg-Access-Delegation: vended-credentials`.
186    #[serde(default, deserialize_with = "deserialize_optional_bool_from_string")]
187    pub vended_credentials: Option<bool>,
188
189    /// Security type for REST catalog authentication.
190    /// Supported values: `none`, `oauth2`, `google`.
191    /// When set to `google`, uses Iceberg's `GoogleAuthManager` (requires Iceberg 1.10+)
192    /// for authentication using Google Application Default Credentials (ADC).
193    #[serde(rename = "catalog.security")]
194    pub catalog_security: Option<String>,
195
196    /// OAuth-based scopes for Google authentication.
197    /// Comma-separated list of OAuth-based scopes to request.
198    /// Only applicable when `catalog.security` is set to `google`.
199    /// Default: <https://www.googleapis.com/auth/cloud-platform>
200    #[serde(rename = "gcp.auth.scopes")]
201    pub gcp_auth_scopes: Option<String>,
202
203    /// Custom `FileIO` implementation class for the Iceberg catalog.
204    /// Allows specifying a custom `FileIO` implementation instead of the default.
205    /// Examples:
206    /// - `org.apache.iceberg.aws.s3.S3FileIO` for Amazon S3 (default)
207    /// - `org.apache.iceberg.gcp.gcs.GCSFileIO` for Google Cloud Storage
208    /// - `org.apache.iceberg.azure.adlsv2.ADLSFileIO` for Azure Data Lake Storage Gen2
209    /// Google Cloud Lakehouse Iceberg REST catalogs with credential vending require
210    /// `org.apache.iceberg.gcp.gcs.GCSFileIO`.
211    #[serde(rename = "catalog.io_impl")]
212    pub catalog_io_impl: Option<String>,
213}
214
215// Matches iceberg::io::object_cache default size (32MB).
216// TODO: change it after object cache get refactored.
217const DEFAULT_OBJECT_CACHE_SIZE_BYTES: u64 = 32 * 1024 * 1024;
218const SHARED_OBJECT_CACHE_BUDGET_BYTES: u64 = 512 * 1024 * 1024;
219const SHARED_OBJECT_CACHE_MAX_TABLES: u64 =
220    SHARED_OBJECT_CACHE_BUDGET_BYTES / DEFAULT_OBJECT_CACHE_SIZE_BYTES;
221
222/// Default Microsoft Entra (AAD) authority host for public Azure. Sovereign-cloud
223/// users override via `adlsgen2.authority_host`.
224const ADLS_DEFAULT_AUTHORITY_HOST: &str = "https://login.microsoftonline.com";
225
226impl EnforceSecret for IcebergCommon {
227    const ENFORCE_SECRET_PROPERTIES: Set<&'static str> = phf_set! {
228        "s3.access.key",
229        "s3.secret.key",
230        "gcs.credential",
231        "catalog.credential",
232        "catalog.token",
233        "catalog.oauth2_server_uri",
234        "adlsgen2.account_key",
235        "adlsgen2.client_secret",
236        "glue.access.key",
237        "glue.secret.key",
238    };
239}
240
241#[serde_as]
242#[derive(Debug, Clone, PartialEq, Eq, Deserialize, WithOptions)]
243#[serde(deny_unknown_fields)]
244pub struct IcebergTableIdentifier {
245    #[serde(rename = "database.name")]
246    pub database_name: Option<String>,
247    /// Table name or namespace-qualified table name. Dots are treated as
248    /// Iceberg namespace separators.
249    #[serde(rename = "table.name")]
250    pub table_name: String,
251}
252
253impl IcebergTableIdentifier {
254    pub fn database_name(&self) -> Option<&str> {
255        self.database_name.as_deref()
256    }
257
258    pub fn table_name(&self) -> &str {
259        &self.table_name
260    }
261
262    fn identifier_parts(&self) -> ConnectorResult<Vec<&str>> {
263        let mut parts = Vec::new();
264        if let Some(database_name) = &self.database_name {
265            parts.extend(database_name.split('.'));
266        }
267        parts.extend(self.table_name.split('.'));
268
269        if parts.iter().any(|part| part.is_empty()) {
270            bail!(
271                "Invalid iceberg table identifier '{}': identifier parts must not be empty",
272                self.full_identifier()
273            );
274        }
275
276        Ok(parts)
277    }
278
279    fn full_identifier(&self) -> String {
280        match &self.database_name {
281            Some(database_name) => format!("{}.{}", database_name, self.table_name),
282            None => self.table_name.clone(),
283        }
284    }
285
286    pub fn to_table_ident(&self) -> ConnectorResult<TableIdent> {
287        let ret = TableIdent::from_strs(self.identifier_parts()?);
288
289        Ok(ret.context("Failed to create table identifier")?)
290    }
291
292    pub fn validate(&self) -> ConnectorResult<()> {
293        self.identifier_parts().map(|_| ())
294    }
295}
296
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298pub enum IcebergCatalogRuntime {
299    NativeRust,
300    JavaJni,
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub enum IcebergCatalogKind {
305    Storage,
306    Rest(IcebergCatalogRuntime),
307    Glue(IcebergCatalogRuntime),
308    Hive,
309    Jdbc,
310    Snowflake,
311    Mock,
312}
313
314impl IcebergCatalogKind {
315    fn resolve(common: &IcebergCommon) -> ConnectorResult<Self> {
316        let catalog_type = common.catalog_type();
317        let kind = match catalog_type {
318            "storage" => Self::Storage,
319            "rest" if common.vended_credentials() => Self::Rest(IcebergCatalogRuntime::NativeRust),
320            "rest" => Self::Rest(IcebergCatalogRuntime::JavaJni),
321            "rest_rust" => Self::Rest(IcebergCatalogRuntime::NativeRust),
322            "glue" => Self::Glue(IcebergCatalogRuntime::JavaJni),
323            "glue_rust" => Self::Glue(IcebergCatalogRuntime::NativeRust),
324            "hive" => Self::Hive,
325            "jdbc" => Self::Jdbc,
326            "snowflake" => Self::Snowflake,
327            #[cfg(any(test, madsim))]
328            "mock_v3" => Self::Mock,
329            "mock" => Self::Mock,
330            _ => {
331                bail!(
332                    "Unsupported catalog type: {}, only support `storage`, `rest`, `hive`, `jdbc`, `glue`, `snowflake`",
333                    catalog_type
334                )
335            }
336        };
337        Ok(kind)
338    }
339
340    pub fn is_rest(self) -> bool {
341        matches!(self, Self::Rest(_))
342    }
343
344    fn jni_impl(self) -> Option<JniCatalogImpl> {
345        match self {
346            Self::Rest(IcebergCatalogRuntime::JavaJni) => Some(JniCatalogImpl::Rest),
347            Self::Glue(IcebergCatalogRuntime::JavaJni) => Some(JniCatalogImpl::Glue),
348            Self::Hive => Some(JniCatalogImpl::Hive),
349            Self::Jdbc => Some(JniCatalogImpl::Jdbc),
350            Self::Snowflake => Some(JniCatalogImpl::Snowflake),
351            _ => None,
352        }
353    }
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357enum JniCatalogImpl {
358    Hive,
359    Jdbc,
360    Snowflake,
361    Rest,
362    Glue,
363}
364
365impl JniCatalogImpl {
366    fn catalog_type(self) -> &'static str {
367        match self {
368            Self::Hive => "hive",
369            Self::Jdbc => "jdbc",
370            Self::Snowflake => "snowflake",
371            Self::Rest => "rest",
372            Self::Glue => "glue",
373        }
374    }
375
376    fn class_name(self) -> &'static str {
377        match self {
378            Self::Hive => "org.apache.iceberg.hive.HiveCatalog",
379            Self::Jdbc => "org.apache.iceberg.jdbc.JdbcCatalog",
380            Self::Snowflake => "org.apache.iceberg.snowflake.SnowflakeCatalog",
381            Self::Rest => "org.apache.iceberg.rest.RESTCatalog",
382            Self::Glue => "org.apache.iceberg.aws.glue.GlueCatalog",
383        }
384    }
385}
386
387enum CatalogBuildPlan {
388    Storage(StorageCatalogConfig),
389    NativeRest(HashMap<String, String>),
390    NativeGlue(HashMap<String, String>),
391    Jni {
392        file_io_props: HashMap<String, String>,
393        catalog_name: String,
394        catalog_impl: JniCatalogImpl,
395        java_catalog_props: HashMap<String, String>,
396    },
397    Mock,
398}
399
400pub struct ResolvedIcebergCatalogConfig<'a> {
401    common: &'a IcebergCommon,
402    kind: IcebergCatalogKind,
403    java_catalog_props: HashMap<String, String>,
404}
405
406impl<'a> ResolvedIcebergCatalogConfig<'a> {
407    pub fn kind(&self) -> IcebergCatalogKind {
408        self.kind
409    }
410
411    fn build_plan(&self) -> ConnectorResult<CatalogBuildPlan> {
412        match self.kind {
413            IcebergCatalogKind::Storage => self.common.build_storage_catalog_config(),
414            IcebergCatalogKind::Rest(IcebergCatalogRuntime::NativeRust) => {
415                self.common.build_native_rest_catalog_props()
416            }
417            IcebergCatalogKind::Glue(IcebergCatalogRuntime::NativeRust) => {
418                self.common.build_native_glue_catalog_props()
419            }
420            IcebergCatalogKind::Mock => Ok(CatalogBuildPlan::Mock),
421            kind => {
422                let catalog_impl = kind.jni_impl().expect("java catalog kind has JNI impl");
423                let (file_io_props, java_catalog_props) = self
424                    .common
425                    .build_jni_catalog_configs(catalog_impl, &self.java_catalog_props)?;
426                Ok(CatalogBuildPlan::Jni {
427                    file_io_props,
428                    catalog_name: self.common.catalog_name(),
429                    catalog_impl,
430                    java_catalog_props,
431                })
432            }
433        }
434    }
435
436    pub async fn create_catalog(&self) -> ConnectorResult<Arc<dyn Catalog>> {
437        match self.build_plan()? {
438            CatalogBuildPlan::Storage(config) => {
439                let catalog = storage_catalog::StorageCatalog::new(config)?;
440                Ok(Arc::new(catalog))
441            }
442            CatalogBuildPlan::NativeRest(iceberg_configs) => {
443                let catalog = iceberg_catalog_rest::RestCatalogBuilder::default()
444                    .load("rest", iceberg_configs)
445                    .await
446                    .map_err(|e| anyhow!(IcebergError::from(e)))?;
447                Ok(Arc::new(catalog))
448            }
449            CatalogBuildPlan::NativeGlue(iceberg_configs) => {
450                let catalog = iceberg_catalog_glue::GlueCatalogBuilder::default()
451                    .load("glue", iceberg_configs)
452                    .await
453                    .map_err(|e| anyhow!(IcebergError::from(e)))?;
454                Ok(Arc::new(catalog))
455            }
456            CatalogBuildPlan::Jni {
457                file_io_props,
458                catalog_name,
459                catalog_impl,
460                java_catalog_props,
461            } => {
462                jni_catalog::JniCatalog::build_catalog(
463                    file_io_props,
464                    catalog_name,
465                    catalog_impl.class_name(),
466                    java_catalog_props,
467                )
468                .await
469            }
470            CatalogBuildPlan::Mock => Ok(Arc::new(mock_catalog::MockCatalog {})),
471        }
472    }
473
474    pub async fn load_table(&self, table: &IcebergTableIdentifier) -> ConnectorResult<Table> {
475        let catalog = self
476            .create_catalog()
477            .await
478            .context("Unable to load iceberg catalog")?;
479
480        let table_id = table
481            .to_table_ident()
482            .context("Unable to parse table name")?;
483
484        let table = catalog.load_table(&table_id).await?;
485        Ok(rebuild_table_with_shared_cache(table).await)
486    }
487}
488
489pub fn iceberg_java_catalog_props_from_options<'a>(
490    options: impl Iterator<Item = (&'a str, &'a str)>,
491) -> HashMap<String, String> {
492    options
493        .filter(|(k, _v)| {
494            k.starts_with("catalog.")
495                && k != &"catalog.uri"
496                && k != &"catalog.type"
497                && k != &"catalog.name"
498                && k != &"catalog.header"
499        })
500        .map(|(k, v)| (k[8..].to_owned(), v.to_owned()))
501        .collect()
502}
503
504impl IcebergCommon {
505    pub fn catalog_type(&self) -> &str {
506        self.catalog_type.as_deref().unwrap_or("storage")
507    }
508
509    pub fn vended_credentials(&self) -> bool {
510        self.vended_credentials.unwrap_or(false)
511    }
512
513    pub fn resolve_catalog_kind(&self) -> ConnectorResult<IcebergCatalogKind> {
514        IcebergCatalogKind::resolve(self)
515    }
516
517    pub fn is_rest_catalog(&self) -> ConnectorResult<bool> {
518        Ok(self.resolve_catalog_kind()?.is_rest())
519    }
520
521    pub fn resolve_catalog_config(
522        &self,
523        java_catalog_props: HashMap<String, String>,
524    ) -> ConnectorResult<ResolvedIcebergCatalogConfig<'_>> {
525        Ok(ResolvedIcebergCatalogConfig {
526            common: self,
527            kind: self.resolve_catalog_kind()?,
528            java_catalog_props,
529        })
530    }
531
532    fn glue_access_key(&self) -> Option<&str> {
533        self.glue_access_key
534            .as_deref()
535            .or(self.s3_access_key.as_deref())
536    }
537
538    fn glue_secret_key(&self) -> Option<&str> {
539        self.glue_secret_key
540            .as_deref()
541            .or(self.s3_secret_key.as_deref())
542    }
543
544    fn glue_region(&self) -> Option<&str> {
545        self.glue_region.as_deref().or(self.s3_region.as_deref())
546    }
547
548    fn glue_endpoint(&self) -> Option<&str> {
549        self.glue_endpoint
550            .as_deref()
551            .or_else(|| match self.resolve_catalog_kind() {
552                Ok(IcebergCatalogKind::Glue(IcebergCatalogRuntime::JavaJni)) => {
553                    self.catalog_uri.as_deref()
554                }
555                _ => None,
556            })
557    }
558
559    pub fn catalog_name(&self) -> String {
560        self.catalog_name
561            .as_ref()
562            .cloned()
563            .unwrap_or_else(|| "risingwave".to_owned())
564    }
565
566    pub fn headers(&self) -> ConnectorResult<HashMap<String, String>> {
567        let mut headers = HashMap::new();
568        let user_agent = match Deployment::current() {
569            Deployment::Ci => "RisingWave(CI)".to_owned(),
570            Deployment::Cloud => "RisingWave(Cloud)".to_owned(),
571            Deployment::Other => "RisingWave(OSS)".to_owned(),
572        };
573        if self.vended_credentials() {
574            headers.insert(
575                "X-Iceberg-Access-Delegation".to_owned(),
576                "vended-credentials".to_owned(),
577            );
578        }
579        headers.insert("User-Agent".to_owned(), user_agent);
580        if let Some(header) = &self.catalog_header {
581            for pair in header.split(';') {
582                let mut parts = pair.split('=');
583                if let (Some(key), Some(value)) = (parts.next(), parts.next()) {
584                    headers.insert(key.to_owned(), value.to_owned());
585                } else {
586                    bail!("Invalid header format: {}", pair);
587                }
588            }
589        }
590        Ok(headers)
591    }
592
593    pub fn enable_config_load(&self) -> bool {
594        // If the env var is set to true, we disable the default config load. (Cloud environment)
595        if env_var_is_true(DISABLE_DEFAULT_CREDENTIAL) {
596            if matches!(self.enable_config_load, Some(true)) {
597                tracing::warn!(
598                    "`enable_config_load` can't be enabled in SaaS environment, the behavior might be unexpected"
599                );
600            }
601            return false;
602        }
603        self.enable_config_load.unwrap_or(false)
604    }
605
606    fn build_storage_catalog_config(&self) -> ConnectorResult<CatalogBuildPlan> {
607        let warehouse = self
608            .warehouse_path
609            .clone()
610            .ok_or_else(|| anyhow!("`warehouse.path` must be set in storage catalog"))?;
611        let url = Url::parse(warehouse.as_ref())
612            .map_err(|_| anyhow!("Invalid warehouse path: {}", warehouse))?;
613
614        let config = match url.scheme() {
615            "s3" | "s3a" => StorageCatalogConfig::S3(
616                storage_catalog::StorageCatalogS3Config::builder()
617                    .warehouse(warehouse)
618                    .access_key(self.s3_access_key.clone())
619                    .secret_key(self.s3_secret_key.clone())
620                    .region(self.s3_region.clone())
621                    .endpoint(self.s3_endpoint.clone())
622                    .path_style_access(self.s3_path_style_access)
623                    .enable_config_load(Some(self.enable_config_load()))
624                    .build(),
625            ),
626            "gs" | "gcs" => StorageCatalogConfig::Gcs(
627                storage_catalog::StorageCatalogGcsConfig::builder()
628                    .warehouse(warehouse)
629                    .credential(self.gcs_credential.clone())
630                    .enable_config_load(Some(self.enable_config_load()))
631                    .build(),
632            ),
633            "azblob" => StorageCatalogConfig::Azblob(
634                storage_catalog::StorageCatalogAzblobConfig::builder()
635                    .warehouse(warehouse)
636                    .account_name(self.azblob_account_name.clone())
637                    .account_key(self.azblob_account_key.clone())
638                    .endpoint(self.azblob_endpoint_url.clone())
639                    .build(),
640            ),
641            scheme => bail!("Unsupported warehouse scheme: {}", scheme),
642        };
643
644        Ok(CatalogBuildPlan::Storage(config))
645    }
646
647    fn build_native_rest_catalog_props(&self) -> ConnectorResult<CatalogBuildPlan> {
648        let mut iceberg_configs = HashMap::new();
649
650        // check gcs credential or s3 access key and secret key
651        if let Some(gcs_credential) = &self.gcs_credential {
652            iceberg_configs.insert(GCS_CREDENTIALS_JSON.to_owned(), gcs_credential.clone());
653        } else {
654            if let Some(region) = &self.s3_region {
655                iceberg_configs.insert(S3_REGION.to_owned(), region.clone());
656            }
657            if let Some(endpoint) = &self.s3_endpoint {
658                iceberg_configs.insert(S3_ENDPOINT.to_owned(), endpoint.clone());
659            }
660            if let Some(access_key) = &self.s3_access_key {
661                iceberg_configs.insert(S3_ACCESS_KEY_ID.to_owned(), access_key.clone());
662            }
663            if let Some(secret_key) = &self.s3_secret_key {
664                iceberg_configs.insert(S3_SECRET_ACCESS_KEY.to_owned(), secret_key.clone());
665            }
666            if let Some(path_style_access) = &self.s3_path_style_access {
667                iceberg_configs.insert(
668                    S3_PATH_STYLE_ACCESS.to_owned(),
669                    path_style_access.to_string(),
670                );
671            }
672        };
673
674        if let Some(credential) = &self.catalog_credential {
675            iceberg_configs.insert("credential".to_owned(), credential.clone());
676        }
677        if let Some(token) = &self.catalog_token {
678            iceberg_configs.insert("token".to_owned(), token.clone());
679        }
680        if let Some(oauth2_server_uri) = &self.catalog_oauth2_server_uri {
681            iceberg_configs.insert("oauth2-server-uri".to_owned(), oauth2_server_uri.clone());
682        }
683        if let Some(scope) = &self.catalog_scope {
684            iceberg_configs.insert("scope".to_owned(), scope.clone());
685        }
686
687        let headers = self.headers()?;
688        for (header_name, header_value) in headers {
689            iceberg_configs.insert(format!("header.{}", header_name), header_value);
690        }
691
692        iceberg_configs.insert(
693            iceberg_catalog_rest::REST_CATALOG_PROP_URI.to_owned(),
694            self.catalog_uri
695                .clone()
696                .with_context(|| "`catalog.uri` must be set in rest catalog".to_owned())?,
697        );
698        if let Some(warehouse_path) = &self.warehouse_path {
699            iceberg_configs.insert(
700                iceberg_catalog_rest::REST_CATALOG_PROP_WAREHOUSE.to_owned(),
701                warehouse_path.clone(),
702            );
703        }
704
705        Ok(CatalogBuildPlan::NativeRest(iceberg_configs))
706    }
707
708    fn build_native_glue_catalog_props(&self) -> ConnectorResult<CatalogBuildPlan> {
709        let mut iceberg_configs = HashMap::new();
710        // glue
711        if let Some(region) = self.glue_region() {
712            iceberg_configs.insert(AWS_REGION_NAME.to_owned(), region.to_owned());
713        }
714        if let Some(access_key) = self.glue_access_key() {
715            iceberg_configs.insert(AWS_ACCESS_KEY_ID.to_owned(), access_key.to_owned());
716        }
717        if let Some(secret_key) = self.glue_secret_key() {
718            iceberg_configs.insert(AWS_SECRET_ACCESS_KEY.to_owned(), secret_key.to_owned());
719        }
720        // s3
721        if let Some(region) = &self.s3_region {
722            iceberg_configs.insert(S3_REGION.to_owned(), region.clone());
723        }
724        if let Some(endpoint) = &self.s3_endpoint {
725            iceberg_configs.insert(S3_ENDPOINT.to_owned(), endpoint.clone());
726        }
727        if let Some(access_key) = &self.s3_access_key {
728            iceberg_configs.insert(S3_ACCESS_KEY_ID.to_owned(), access_key.clone());
729        }
730        if let Some(secret_key) = &self.s3_secret_key {
731            iceberg_configs.insert(S3_SECRET_ACCESS_KEY.to_owned(), secret_key.clone());
732        }
733        if let Some(role_arn) = &self.s3_iam_role_arn {
734            iceberg_configs.insert(S3_ASSUME_ROLE_ARN.to_owned(), role_arn.clone());
735        }
736        if let Some(path_style_access) = &self.s3_path_style_access {
737            iceberg_configs.insert(
738                S3_PATH_STYLE_ACCESS.to_owned(),
739                path_style_access.to_string(),
740            );
741        }
742        iceberg_configs.insert(
743            iceberg_catalog_glue::GLUE_CATALOG_PROP_WAREHOUSE.to_owned(),
744            self.warehouse_path
745                .clone()
746                .ok_or_else(|| anyhow!("`warehouse.path` must be set in glue catalog"))?,
747        );
748        if let Some(uri) = self.catalog_uri.as_deref() {
749            iceberg_configs.insert(
750                iceberg_catalog_glue::GLUE_CATALOG_PROP_URI.to_owned(),
751                uri.to_owned(),
752            );
753        }
754
755        Ok(CatalogBuildPlan::NativeGlue(iceberg_configs))
756    }
757
758    /// For both V1 and V2.
759    fn build_jni_catalog_configs(
760        &self,
761        catalog_impl: JniCatalogImpl,
762        java_catalog_props: &HashMap<String, String>,
763    ) -> ConnectorResult<(HashMap<String, String>, HashMap<String, String>)> {
764        let mut iceberg_configs = HashMap::new();
765        let enable_config_load = self.enable_config_load();
766        let file_io_props = {
767            let catalog_type = catalog_impl.catalog_type();
768
769            // Non-S3/Glue object-store backends only work with a REST catalog. This
770            // function is only invoked for catalog_type in {hive, snowflake, jdbc, rest,
771            // glue}, so the only accepted value here is "rest".
772            let require_rest = |backend: &str| -> ConnectorResult<()> {
773                if catalog_impl != JniCatalogImpl::Rest {
774                    bail!("{} unsupported in {} catalog", backend, catalog_type);
775                }
776                Ok(())
777            };
778
779            if let Some(region) = &self.s3_region {
780                // iceberg-rust
781                iceberg_configs.insert(S3_REGION.to_owned(), region.clone());
782            }
783
784            if let Some(endpoint) = &self.s3_endpoint {
785                // iceberg-rust
786                iceberg_configs.insert(S3_ENDPOINT.to_owned(), endpoint.clone());
787            }
788
789            // iceberg-rust
790            if let Some(access_key) = &self.s3_access_key {
791                iceberg_configs.insert(S3_ACCESS_KEY_ID.to_owned(), access_key.clone());
792            }
793            if let Some(secret_key) = &self.s3_secret_key {
794                iceberg_configs.insert(S3_SECRET_ACCESS_KEY.to_owned(), secret_key.clone());
795            }
796            if let Some(role_arn) = &self.s3_iam_role_arn {
797                iceberg_configs.insert(S3_ASSUME_ROLE_ARN.to_owned(), role_arn.clone());
798            }
799            if let Some(gcs_credential) = &self.gcs_credential {
800                iceberg_configs.insert(GCS_CREDENTIALS_JSON.to_owned(), gcs_credential.clone());
801                require_rest("gcs")?;
802            }
803
804            if let (
805                Some(azblob_account_name),
806                Some(azblob_account_key),
807                Some(azblob_endpoint_url),
808            ) = (
809                &self.azblob_account_name,
810                &self.azblob_account_key,
811                &self.azblob_endpoint_url,
812            ) {
813                iceberg_configs.insert(AZBLOB_ACCOUNT_NAME.to_owned(), azblob_account_name.clone());
814                iceberg_configs.insert(AZBLOB_ACCOUNT_KEY.to_owned(), azblob_account_key.clone());
815                iceberg_configs.insert(AZBLOB_ENDPOINT.to_owned(), azblob_endpoint_url.clone());
816
817                require_rest("azblob")?;
818            }
819
820            // Validate adlsgen2 auth configuration before populating iceberg_configs.
821            // Treat empty and whitespace-only strings as unset — serde surfaces
822            // `adlsgen2.tenant_id = ''` (or a value with trailing `\n` from a copy-paste)
823            // as `Some("...")` which would pass `is_some()` but break downstream auth.
824            fn nonempty(v: &Option<String>) -> Option<&str> {
825                v.as_deref().filter(|s| !s.trim().is_empty())
826            }
827            let sp_tenant = nonempty(&self.adlsgen2_tenant_id);
828            let sp_client = nonempty(&self.adlsgen2_client_id);
829            let sp_secret = nonempty(&self.adlsgen2_client_secret);
830            let sp_authority = nonempty(&self.adlsgen2_authority_host);
831            let sk_account_name = nonempty(&self.adlsgen2_account_name);
832            let sk_account_key = nonempty(&self.adlsgen2_account_key);
833            let any_sp_field = sp_tenant.is_some()
834                || sp_client.is_some()
835                || sp_secret.is_some()
836                || sp_authority.is_some();
837            let all_sp_required = sp_tenant.is_some() && sp_client.is_some() && sp_secret.is_some();
838
839            if sk_account_key.is_some() && any_sp_field {
840                bail!(
841                    "adlsgen2: cannot configure both shared-key auth \
842                     (adlsgen2.account_key) and service-principal auth \
843                     (adlsgen2.tenant_id / adlsgen2.client_id / adlsgen2.client_secret / \
844                     adlsgen2.authority_host) simultaneously. Specify exactly one auth mode."
845                );
846            }
847            if any_sp_field && !all_sp_required {
848                bail!(
849                    "adlsgen2: service-principal auth requires all three of \
850                     adlsgen2.tenant_id, adlsgen2.client_id, and adlsgen2.client_secret \
851                     to be set. (adlsgen2.authority_host is optional and defaults to the \
852                     public Azure AAD endpoint.)"
853                );
854            }
855            // Defense in depth: reqsign POSTs the OAuth token request — carrying the
856            // client_secret to this host. Require a bare https origin: no userinfo,
857            // no query, no fragment, and no path beyond "/". The value itself is not
858            // echoed into error messages in case a user pasted a secret by mistake.
859            if let Some(host) = sp_authority {
860                let parsed = Url::parse(host).map_err(|_| {
861                    anyhow!(
862                        "adlsgen2.authority_host does not parse as a URL ({} chars)",
863                        host.len()
864                    )
865                })?;
866                if parsed.scheme() != "https" {
867                    bail!(
868                        "adlsgen2.authority_host must use the https scheme, got {}",
869                        parsed.scheme()
870                    );
871                }
872                if !parsed.username().is_empty() || parsed.password().is_some() {
873                    bail!("adlsgen2.authority_host must not contain userinfo");
874                }
875                if parsed.query().is_some() || parsed.fragment().is_some() {
876                    bail!("adlsgen2.authority_host must not contain a query or fragment");
877                }
878                if !matches!(parsed.path(), "" | "/") {
879                    bail!("adlsgen2.authority_host must not contain a path component");
880                }
881            }
882
883            if let (Some(account_name), Some(account_key)) = (sk_account_name, sk_account_key) {
884                iceberg_configs.insert(ADLS_ACCOUNT_NAME.to_owned(), account_name.to_owned());
885                iceberg_configs.insert(ADLS_ACCOUNT_KEY.to_owned(), account_key.to_owned());
886                require_rest("adlsgen2")?;
887            }
888
889            if let (Some(tenant_id), Some(client_id), Some(client_secret)) =
890                (sp_tenant, sp_client, sp_secret)
891            {
892                iceberg_configs.insert(ADLS_TENANT_ID.to_owned(), tenant_id.to_owned());
893                iceberg_configs.insert(ADLS_CLIENT_ID.to_owned(), client_id.to_owned());
894                iceberg_configs.insert(ADLS_CLIENT_SECRET.to_owned(), client_secret.to_owned());
895                // Strip trailing slash to prevent double slash
896                let authority_host = sp_authority
897                    .unwrap_or(ADLS_DEFAULT_AUTHORITY_HOST)
898                    .trim_end_matches('/')
899                    .to_owned();
900                iceberg_configs.insert(ADLS_AUTHORITY_HOST.to_owned(), authority_host);
901                require_rest("adlsgen2")?;
902            }
903
904            match &self.warehouse_path {
905                Some(warehouse_path) => {
906                    let (bucket, _) = {
907                        let is_s3_tables = warehouse_path.starts_with("arn:aws:s3tables");
908                        // Lakehouse Iceberg REST catalog federation uses bq:// prefix for BigQuery-managed Iceberg tables.
909                        let is_bq_catalog_federation = warehouse_path.starts_with("bq://");
910                        let url = Url::parse(warehouse_path);
911                        if (url.is_err() || is_s3_tables || is_bq_catalog_federation)
912                            && catalog_impl == JniCatalogImpl::Rest
913                        {
914                            // If the warehouse path is not a valid URL, it could be:
915                            // - A warehouse name in REST catalog
916                            // - An S3 Tables path (arn:aws:s3tables:...)
917                            // - A Lakehouse path (bq://projects/...) for Google Cloud BigQuery integration
918                            // We allow these to pass through for REST catalogs.
919                            (None, None)
920                        } else {
921                            let url = url.with_context(|| {
922                                format!("Invalid warehouse path: {}", warehouse_path)
923                            })?;
924                            let bucket = url
925                                .host_str()
926                                .with_context(|| {
927                                    format!(
928                                        "Invalid s3 path: {}, bucket is missing",
929                                        warehouse_path
930                                    )
931                                })?
932                                .to_owned();
933                            let root = url.path().trim_start_matches('/').to_owned();
934                            (Some(bucket), Some(root))
935                        }
936                    };
937
938                    if let Some(bucket) = bucket {
939                        iceberg_configs.insert("iceberg.table.io.bucket".to_owned(), bucket);
940                    }
941                }
942                None => {
943                    if catalog_impl != JniCatalogImpl::Rest {
944                        bail!("`warehouse.path` must be set in {} catalog", catalog_type);
945                    }
946                }
947            }
948            iceberg_configs.insert(
949                S3_DISABLE_CONFIG_LOAD.to_owned(),
950                (!enable_config_load).to_string(),
951            );
952
953            iceberg_configs.insert(
954                GCS_DISABLE_CONFIG_LOAD.to_owned(),
955                (!enable_config_load).to_string(),
956            );
957
958            if let Some(path_style_access) = self.s3_path_style_access {
959                iceberg_configs.insert(
960                    S3_PATH_STYLE_ACCESS.to_owned(),
961                    path_style_access.to_string(),
962                );
963            }
964
965            iceberg_configs
966        };
967
968        // Prepare jni configs, for details please see https://iceberg.apache.org/docs/latest/aws/
969        let mut java_catalog_configs = HashMap::new();
970        {
971            if let Some(uri) = self.catalog_uri.as_deref() {
972                java_catalog_configs.insert("uri".to_owned(), uri.to_owned());
973            }
974
975            if let Some(warehouse_path) = &self.warehouse_path {
976                java_catalog_configs.insert("warehouse".to_owned(), warehouse_path.clone());
977            }
978            java_catalog_configs.extend(java_catalog_props.clone());
979
980            // Set io-impl: use custom io-impl if provided, otherwise default to S3FileIO
981            let io_impl = self
982                .catalog_io_impl
983                .clone()
984                .unwrap_or_else(|| "org.apache.iceberg.aws.s3.S3FileIO".to_owned());
985            java_catalog_configs.insert("io-impl".to_owned(), io_impl);
986
987            // suppress log of FileIO like: Unclosed FileIO instance created by...
988            java_catalog_configs.insert("init-creation-stacktrace".to_owned(), "false".to_owned());
989
990            if let Some(region) = &self.s3_region {
991                java_catalog_configs.insert("client.region".to_owned(), region.clone());
992            }
993            if let Some(endpoint) = &self.s3_endpoint {
994                java_catalog_configs.insert("s3.endpoint".to_owned(), endpoint.clone());
995            }
996
997            if let Some(access_key) = &self.s3_access_key {
998                java_catalog_configs.insert("s3.access-key-id".to_owned(), access_key.clone());
999            }
1000            if let Some(secret_key) = &self.s3_secret_key {
1001                java_catalog_configs.insert("s3.secret-access-key".to_owned(), secret_key.clone());
1002            }
1003
1004            if let Some(path_style_access) = &self.s3_path_style_access {
1005                java_catalog_configs.insert(
1006                    "s3.path-style-access".to_owned(),
1007                    path_style_access.to_string(),
1008                );
1009            }
1010
1011            let headers = self.headers()?;
1012            for (header_name, header_value) in headers {
1013                java_catalog_configs.insert(format!("header.{}", header_name), header_value);
1014            }
1015
1016            match catalog_impl {
1017                JniCatalogImpl::Rest => {
1018                    // Handle security type for REST catalog (Iceberg 1.10+)
1019                    if let Some(security) = &self.catalog_security {
1020                        match security.to_lowercase().as_str() {
1021                            "google" => {
1022                                // Google AuthManager (Iceberg 1.10+) - uses Google ADC
1023                                java_catalog_configs.insert(
1024                                    "rest.auth.type".to_owned(),
1025                                    "org.apache.iceberg.gcp.auth.GoogleAuthManager".to_owned(),
1026                                );
1027                                // Set GCP auth scopes if provided
1028                                if let Some(gcp_auth_scopes) = &self.gcp_auth_scopes {
1029                                    java_catalog_configs.insert(
1030                                        "gcp.auth.scopes".to_owned(),
1031                                        gcp_auth_scopes.clone(),
1032                                    );
1033                                }
1034                            }
1035                            "oauth2" => {
1036                                // Standard OAuth2 authentication
1037                                if let Some(credential) = &self.catalog_credential {
1038                                    java_catalog_configs
1039                                        .insert("credential".to_owned(), credential.clone());
1040                                }
1041                                if let Some(token) = &self.catalog_token {
1042                                    java_catalog_configs.insert("token".to_owned(), token.clone());
1043                                }
1044                                if let Some(oauth2_server_uri) = &self.catalog_oauth2_server_uri {
1045                                    java_catalog_configs.insert(
1046                                        "oauth2-server-uri".to_owned(),
1047                                        oauth2_server_uri.clone(),
1048                                    );
1049                                }
1050                                if let Some(scope) = &self.catalog_scope {
1051                                    java_catalog_configs.insert("scope".to_owned(), scope.clone());
1052                                }
1053                            }
1054                            "none" | "" => {
1055                                // No authentication
1056                            }
1057                            _ => {
1058                                tracing::warn!(
1059                                    "Unknown catalog.security value: {}. Supported values: none, oauth2, google",
1060                                    security
1061                                );
1062                            }
1063                        }
1064                    } else {
1065                        // Legacy behavior: use individual OAuth2 properties if security type not specified
1066                        if let Some(credential) = &self.catalog_credential {
1067                            java_catalog_configs
1068                                .insert("credential".to_owned(), credential.clone());
1069                        }
1070                        if let Some(token) = &self.catalog_token {
1071                            java_catalog_configs.insert("token".to_owned(), token.clone());
1072                        }
1073                        if let Some(oauth2_server_uri) = &self.catalog_oauth2_server_uri {
1074                            java_catalog_configs
1075                                .insert("oauth2-server-uri".to_owned(), oauth2_server_uri.clone());
1076                        }
1077                        if let Some(scope) = &self.catalog_scope {
1078                            java_catalog_configs.insert("scope".to_owned(), scope.clone());
1079                        }
1080                    }
1081                    if let Some(rest_signing_region) = &self.rest_signing_region {
1082                        java_catalog_configs.insert(
1083                            "rest.signing-region".to_owned(),
1084                            rest_signing_region.clone(),
1085                        );
1086                    }
1087                    if let Some(rest_signing_name) = &self.rest_signing_name {
1088                        java_catalog_configs
1089                            .insert("rest.signing-name".to_owned(), rest_signing_name.clone());
1090                    }
1091                    if let Some(rest_sigv4_enabled) = self.rest_sigv4_enabled {
1092                        java_catalog_configs.insert(
1093                            "rest.sigv4-enabled".to_owned(),
1094                            rest_sigv4_enabled.to_string(),
1095                        );
1096
1097                        if let Some(access_key) = &self.s3_access_key {
1098                            java_catalog_configs
1099                                .insert("rest.access-key-id".to_owned(), access_key.clone());
1100                        }
1101
1102                        if let Some(secret_key) = &self.s3_secret_key {
1103                            java_catalog_configs
1104                                .insert("rest.secret-access-key".to_owned(), secret_key.clone());
1105                        }
1106                    }
1107                }
1108                JniCatalogImpl::Glue => {
1109                    let glue_access_key = self.glue_access_key();
1110                    let glue_secret_key = self.glue_secret_key();
1111                    let has_glue_credentials =
1112                        glue_access_key.is_some() && glue_secret_key.is_some();
1113                    let should_configure_glue_provider = !enable_config_load
1114                        || has_glue_credentials
1115                        || self.glue_iam_role_arn.is_some();
1116
1117                    if should_configure_glue_provider {
1118                        java_catalog_configs.insert(
1119                            "client.credentials-provider".to_owned(),
1120                            "com.risingwave.connector.catalog.GlueCredentialProvider".to_owned(),
1121                        );
1122                        if let Some(region) = self.glue_region() {
1123                            java_catalog_configs.insert(
1124                                "client.credentials-provider.glue.region".to_owned(),
1125                                region.to_owned(),
1126                            );
1127                        }
1128                        if let Some(access_key) = glue_access_key {
1129                            java_catalog_configs.insert(
1130                                "client.credentials-provider.glue.access-key-id".to_owned(),
1131                                access_key.to_owned(),
1132                            );
1133                        }
1134                        if let Some(secret_key) = glue_secret_key {
1135                            java_catalog_configs.insert(
1136                                "client.credentials-provider.glue.secret-access-key".to_owned(),
1137                                secret_key.to_owned(),
1138                            );
1139                        }
1140                        if let Some(role_arn) = self.glue_iam_role_arn.as_deref() {
1141                            java_catalog_configs.insert(
1142                                "client.credentials-provider.glue.iam-role-arn".to_owned(),
1143                                role_arn.to_owned(),
1144                            );
1145                        }
1146                        if enable_config_load && !has_glue_credentials {
1147                            java_catalog_configs.insert(
1148                                "client.credentials-provider.glue.use-default-credential-chain"
1149                                    .to_owned(),
1150                                "true".to_owned(),
1151                            );
1152                        }
1153                    }
1154
1155                    if let Some(region) = self.glue_region() {
1156                        java_catalog_configs.insert("client.region".to_owned(), region.to_owned());
1157                    }
1158                    let glue_endpoint = self.glue_endpoint().map(str::to_owned).or_else(|| {
1159                        self.glue_region()
1160                            .map(|region| format!("https://glue.{}.amazonaws.com", region))
1161                    });
1162                    if let Some(endpoint) = glue_endpoint {
1163                        java_catalog_configs.insert("glue.endpoint".to_owned(), endpoint);
1164                    }
1165
1166                    if let Some(glue_id) = self.glue_id.as_deref() {
1167                        java_catalog_configs.insert("glue.id".to_owned(), glue_id.to_owned());
1168                    }
1169                    self.apply_java_s3_file_io_assume_role_configs(&mut java_catalog_configs);
1170                }
1171                JniCatalogImpl::Jdbc => {
1172                    self.apply_java_aws_client_assume_role_configs(&mut java_catalog_configs);
1173                }
1174                _ => {}
1175            }
1176        }
1177
1178        Ok((file_io_props, java_catalog_configs))
1179    }
1180
1181    fn apply_java_s3_file_io_assume_role_configs(
1182        &self,
1183        java_catalog_configs: &mut HashMap<String, String>,
1184    ) {
1185        if let Some(iam_role_arn) = &self.s3_iam_role_arn {
1186            java_catalog_configs.insert(
1187                "s3.client-factory-impl".to_owned(),
1188                "com.risingwave.connector.catalog.S3FileIOAssumeRoleAwsClientFactory".to_owned(),
1189            );
1190            java_catalog_configs.insert("s3.iam-role-arn".to_owned(), iam_role_arn.clone());
1191        }
1192    }
1193
1194    fn apply_java_aws_client_assume_role_configs(
1195        &self,
1196        java_catalog_configs: &mut HashMap<String, String>,
1197    ) {
1198        if let Some(iam_role_arn) = &self.s3_iam_role_arn {
1199            java_catalog_configs.insert("client.assume-role.arn".to_owned(), iam_role_arn.clone());
1200            java_catalog_configs.insert(
1201                "client.factory".to_owned(),
1202                "org.apache.iceberg.aws.AssumeRoleAwsClientFactory".to_owned(),
1203            );
1204            if let Some(region) = &self.s3_region {
1205                java_catalog_configs.insert("client.assume-role.region".to_owned(), region.clone());
1206            }
1207        }
1208    }
1209}
1210
1211/// Get a globally shared object cache keyed by table UUID to avoid reuse after drop & recreate.
1212pub(crate) async fn shared_object_cache(
1213    init_object_cache: Arc<ObjectCache>,
1214    table_uuid: Uuid,
1215) -> Arc<ObjectCache> {
1216    static CACHE: LazyLock<MokaCache<Uuid, Arc<ObjectCache>>> = LazyLock::new(|| {
1217        MokaCache::builder()
1218            .max_capacity(SHARED_OBJECT_CACHE_MAX_TABLES)
1219            .build()
1220    });
1221
1222    CACHE
1223        .get_with(table_uuid, async { init_object_cache })
1224        .await
1225}
1226
1227pub async fn rebuild_table_with_shared_cache(table: Table) -> Table {
1228    let table_uuid = table.metadata().uuid();
1229    let init_object_cache = table.object_cache();
1230    let object_cache = shared_object_cache(init_object_cache, table_uuid).await;
1231    table.with_object_cache(object_cache)
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236    use std::collections::HashMap;
1237
1238    use super::*;
1239
1240    fn test_common(catalog_type: &str) -> IcebergCommon {
1241        IcebergCommon {
1242            catalog_type: Some(catalog_type.to_owned()),
1243            s3_region: Some("ap-southeast-2".to_owned()),
1244            s3_endpoint: None,
1245            s3_access_key: None,
1246            s3_secret_key: None,
1247            s3_iam_role_arn: None,
1248            glue_access_key: None,
1249            glue_secret_key: None,
1250            glue_iam_role_arn: None,
1251            glue_region: None,
1252            glue_endpoint: None,
1253            glue_id: None,
1254            gcs_credential: None,
1255            azblob_account_name: None,
1256            azblob_account_key: None,
1257            azblob_endpoint_url: None,
1258            adlsgen2_account_name: None,
1259            adlsgen2_account_key: None,
1260            adlsgen2_endpoint: None,
1261            adlsgen2_tenant_id: None,
1262            adlsgen2_client_id: None,
1263            adlsgen2_client_secret: None,
1264            adlsgen2_authority_host: None,
1265            warehouse_path: Some("s3://bucket/warehouse".to_owned()),
1266            catalog_name: None,
1267            catalog_uri: None,
1268            catalog_credential: None,
1269            catalog_token: None,
1270            catalog_oauth2_server_uri: None,
1271            catalog_scope: None,
1272            rest_signing_region: None,
1273            rest_signing_name: None,
1274            rest_sigv4_enabled: None,
1275            s3_path_style_access: None,
1276            enable_config_load: None,
1277            hosted_catalog: None,
1278            catalog_header: None,
1279            vended_credentials: None,
1280            catalog_security: None,
1281            gcp_auth_scopes: None,
1282            catalog_io_impl: None,
1283        }
1284    }
1285
1286    #[test]
1287    fn test_vended_rest_resolves_to_native_runtime_without_rewriting_catalog_type() {
1288        let common = IcebergCommon {
1289            vended_credentials: Some(true),
1290            ..test_common("rest")
1291        };
1292
1293        assert_eq!(common.catalog_type(), "rest");
1294        assert_eq!(
1295            common.resolve_catalog_kind().unwrap(),
1296            IcebergCatalogKind::Rest(IcebergCatalogRuntime::NativeRust)
1297        );
1298    }
1299
1300    #[test]
1301    fn test_rest_without_vended_credentials_resolves_to_jni_runtime() {
1302        let common = test_common("rest");
1303
1304        assert_eq!(
1305            common.resolve_catalog_kind().unwrap(),
1306            IcebergCatalogKind::Rest(IcebergCatalogRuntime::JavaJni)
1307        );
1308    }
1309
1310    #[test]
1311    fn test_mock_v3_resolves_to_mock_catalog_for_simulation_tests() {
1312        let common = test_common("mock_v3");
1313
1314        assert_eq!(
1315            common.resolve_catalog_kind().unwrap(),
1316            IcebergCatalogKind::Mock
1317        );
1318    }
1319
1320    #[test]
1321    fn test_extract_java_catalog_props_keeps_wire_options_flat() {
1322        let options = HashMap::from([
1323            ("catalog.type".to_owned(), "rest".to_owned()),
1324            ("catalog.uri".to_owned(), "http://localhost:8181".to_owned()),
1325            ("catalog.name".to_owned(), "demo".to_owned()),
1326            ("catalog.header".to_owned(), "x=y".to_owned()),
1327            (
1328                "catalog.rest.signing_region".to_owned(),
1329                "us-east-1".to_owned(),
1330            ),
1331            ("catalog.jdbc.user".to_owned(), "rw".to_owned()),
1332        ]);
1333
1334        let java_props = iceberg_java_catalog_props_from_options(
1335            options
1336                .iter()
1337                .map(|(key, value)| (key.as_str(), value.as_str())),
1338        );
1339
1340        assert_eq!(java_props.get("rest.signing_region").unwrap(), "us-east-1");
1341        assert_eq!(java_props.get("jdbc.user").unwrap(), "rw");
1342        assert!(!java_props.contains_key("type"));
1343        assert!(!java_props.contains_key("uri"));
1344        assert!(!java_props.contains_key("name"));
1345        assert!(!java_props.contains_key("header"));
1346    }
1347
1348    #[test]
1349    fn test_glue_jni_catalog_uses_s3_assume_role_for_file_io() {
1350        let common = IcebergCommon {
1351            s3_iam_role_arn: Some("arn:aws:iam::123456789012:role/risingwave-s3".to_owned()),
1352            ..test_common("glue")
1353        };
1354
1355        let (_, java_catalog_configs) = common
1356            .build_jni_catalog_configs(JniCatalogImpl::Glue, &HashMap::new())
1357            .unwrap();
1358
1359        assert_eq!(
1360            java_catalog_configs.get("s3.client-factory-impl").unwrap(),
1361            "com.risingwave.connector.catalog.S3FileIOAssumeRoleAwsClientFactory"
1362        );
1363        assert_eq!(
1364            java_catalog_configs.get("s3.iam-role-arn").unwrap(),
1365            "arn:aws:iam::123456789012:role/risingwave-s3"
1366        );
1367        assert!(!java_catalog_configs.contains_key("client.factory"));
1368    }
1369
1370    #[test]
1371    fn test_adlsgen2_service_principal_populates_file_io_configs_with_default_authority_host() {
1372        let common = test_adlsgen2_service_principal_common(None);
1373
1374        let (file_io_props, _) = common
1375            .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1376            .unwrap();
1377
1378        assert_eq!(file_io_props.get(ADLS_TENANT_ID).unwrap(), "tenant-uuid");
1379        assert_eq!(file_io_props.get(ADLS_CLIENT_ID).unwrap(), "client-uuid");
1380        assert_eq!(
1381            file_io_props.get(ADLS_CLIENT_SECRET).unwrap(),
1382            "secret-value"
1383        );
1384        assert_eq!(
1385            file_io_props.get(ADLS_AUTHORITY_HOST).unwrap(),
1386            ADLS_DEFAULT_AUTHORITY_HOST
1387        );
1388    }
1389
1390    fn test_adlsgen2_service_principal_common(authority_host: Option<&str>) -> IcebergCommon {
1391        IcebergCommon {
1392            adlsgen2_account_name: Some("acct".to_owned()),
1393            adlsgen2_tenant_id: Some("tenant-uuid".to_owned()),
1394            adlsgen2_client_id: Some("client-uuid".to_owned()),
1395            adlsgen2_client_secret: Some("secret-value".to_owned()),
1396            adlsgen2_authority_host: authority_host.map(str::to_owned),
1397            warehouse_path: Some("abfss://wh@acct.dfs.core.windows.net/wh".to_owned()),
1398            ..test_common("rest")
1399        }
1400    }
1401
1402    #[test]
1403    fn test_adlsgen2_service_principal_authority_host_override_is_respected() {
1404        let common =
1405            test_adlsgen2_service_principal_common(Some("https://login.microsoftonline.us"));
1406
1407        let (file_io_props, _) = common
1408            .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1409            .unwrap();
1410
1411        assert_eq!(
1412            file_io_props.get(ADLS_AUTHORITY_HOST).unwrap(),
1413            "https://login.microsoftonline.us"
1414        );
1415    }
1416
1417    #[test]
1418    fn test_adlsgen2_authority_host_rejects_non_bare_https_origins() {
1419        let cases = [
1420            ("not a url", "does not parse as a URL"),
1421            (
1422                "http://login.microsoftonline.com",
1423                "must use the https scheme",
1424            ),
1425            (
1426                "https://user:pass@login.microsoftonline.com",
1427                "must not contain userinfo",
1428            ),
1429            (
1430                "https://login.microsoftonline.com?bar=baz",
1431                "must not contain a query or fragment",
1432            ),
1433            (
1434                "https://login.microsoftonline.com#frag",
1435                "must not contain a query or fragment",
1436            ),
1437            (
1438                "https://login.microsoftonline.com/foo",
1439                "must not contain a path component",
1440            ),
1441        ];
1442        for (authority_host, expected_error) in cases {
1443            let common = test_adlsgen2_service_principal_common(Some(authority_host));
1444            let err = common
1445                .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1446                .unwrap_err();
1447            assert!(
1448                format!("{:#}", err).contains(expected_error),
1449                "authority_host {authority_host:?}: expected error containing {expected_error:?}, got: {err:#}"
1450            );
1451        }
1452    }
1453
1454    #[test]
1455    fn test_adlsgen2_authority_host_trailing_slash_is_normalized() {
1456        let common =
1457            test_adlsgen2_service_principal_common(Some("https://login.microsoftonline.us/"));
1458
1459        let (file_io_props, _) = common
1460            .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1461            .unwrap();
1462
1463        assert_eq!(
1464            file_io_props.get(ADLS_AUTHORITY_HOST).unwrap(),
1465            "https://login.microsoftonline.us"
1466        );
1467    }
1468
1469    #[test]
1470    fn test_adlsgen2_rejects_mixing_shared_key_and_service_principal() {
1471        let common = IcebergCommon {
1472            adlsgen2_account_key: Some("shared-key".to_owned()),
1473            ..test_adlsgen2_service_principal_common(None)
1474        };
1475
1476        let err = common
1477            .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1478            .unwrap_err();
1479        assert!(
1480            format!("{:#}", err).contains("exactly one auth mode"),
1481            "expected mutual-exclusion error, got: {err:#}"
1482        );
1483    }
1484
1485    #[test]
1486    fn test_adlsgen2_rejects_partial_service_principal_config() {
1487        let common = IcebergCommon {
1488            adlsgen2_client_secret: None,
1489            ..test_adlsgen2_service_principal_common(None)
1490        };
1491
1492        let err = common
1493            .build_jni_catalog_configs(JniCatalogImpl::Rest, &HashMap::new())
1494            .unwrap_err();
1495        assert!(
1496            format!("{:#}", err).contains("requires all three"),
1497            "expected partial-config error, got: {err:#}"
1498        );
1499    }
1500
1501    #[test]
1502    fn test_iceberg_table_identifier_validation() {
1503        let valid_identifier = IcebergTableIdentifier {
1504            database_name: Some("valid_db".to_owned()),
1505            table_name: "test_table".to_owned(),
1506        };
1507        assert!(valid_identifier.validate().is_ok());
1508
1509        let valid_underscore = IcebergTableIdentifier {
1510            database_name: Some("valid_db_name".to_owned()),
1511            table_name: "test_table".to_owned(),
1512        };
1513        assert!(valid_underscore.validate().is_ok());
1514
1515        let no_database = IcebergTableIdentifier {
1516            database_name: None,
1517            table_name: "test_table".to_owned(),
1518        };
1519        assert!(no_database.validate().is_ok());
1520
1521        let empty_part = IcebergTableIdentifier {
1522            database_name: Some("a..b".to_owned()),
1523            table_name: "test_table".to_owned(),
1524        };
1525        let result = empty_part.validate();
1526        assert!(result.is_err());
1527        assert!(
1528            result
1529                .unwrap_err()
1530                .to_string()
1531                .contains("identifier parts must not be empty")
1532        );
1533
1534        let leading_dot = IcebergTableIdentifier {
1535            database_name: None,
1536            table_name: ".test_table".to_owned(),
1537        };
1538        let result = leading_dot.validate();
1539        assert!(result.is_err());
1540        assert!(
1541            result
1542                .unwrap_err()
1543                .to_string()
1544                .contains("identifier parts must not be empty")
1545        );
1546    }
1547
1548    #[test]
1549    fn test_iceberg_table_identifier_dots_as_namespace_separators() {
1550        let table_ident = IcebergTableIdentifier {
1551            database_name: Some("general.zia.stats".to_owned()),
1552            table_name: "tagged_security_transactions".to_owned(),
1553        }
1554        .to_table_ident()
1555        .unwrap();
1556        let namespace: Vec<_> = table_ident
1557            .namespace()
1558            .as_ref()
1559            .iter()
1560            .map(String::as_str)
1561            .collect();
1562        assert_eq!(namespace, vec!["general", "zia", "stats"]);
1563        assert_eq!(table_ident.name(), "tagged_security_transactions");
1564
1565        let table_ident = IcebergTableIdentifier {
1566            database_name: Some("general".to_owned()),
1567            table_name: "zia.stats.tagged_security_transactions".to_owned(),
1568        }
1569        .to_table_ident()
1570        .unwrap();
1571        let namespace: Vec<_> = table_ident
1572            .namespace()
1573            .as_ref()
1574            .iter()
1575            .map(String::as_str)
1576            .collect();
1577        assert_eq!(namespace, vec!["general", "zia", "stats"]);
1578        assert_eq!(table_ident.name(), "tagged_security_transactions");
1579
1580        let table_ident = IcebergTableIdentifier {
1581            database_name: None,
1582            table_name: "general.zia.stats.tagged_security_transactions".to_owned(),
1583        }
1584        .to_table_ident()
1585        .unwrap();
1586        let namespace: Vec<_> = table_ident
1587            .namespace()
1588            .as_ref()
1589            .iter()
1590            .map(String::as_str)
1591            .collect();
1592        assert_eq!(namespace, vec!["general", "zia", "stats"]);
1593        assert_eq!(table_ident.name(), "tagged_security_transactions");
1594    }
1595}