Skip to main content

iceberg_storage_opendal/
lib.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
18//! OpenDAL-based storage implementation for Apache Iceberg.
19//!
20//! This crate provides [`OpenDalStorage`] and [`OpenDalStorageFactory`],
21//! which implement the [`Storage`] and
22//! [`StorageFactory`] traits from the `iceberg` crate
23//! using [OpenDAL](https://opendal.apache.org/) as the backend.
24
25mod utils;
26
27use std::collections::HashMap;
28use std::collections::hash_map::Entry;
29use std::sync::Arc;
30
31use async_trait::async_trait;
32use bytes::Bytes;
33use cfg_if::cfg_if;
34use futures::StreamExt;
35use futures::stream::BoxStream;
36use iceberg::io::{
37    FileMetadata, FileRead, FileWrite, InputFile, OutputFile, Storage, StorageConfig,
38    StorageFactory,
39};
40use iceberg::{Error, ErrorKind, Result};
41use opendal::Operator;
42use opendal::layers::{RetryLayer, TimeoutLayer};
43use serde::{Deserialize, Serialize};
44use utils::from_opendal_error;
45
46cfg_if! {
47    if #[cfg(feature = "opendal-azdls")] {
48        mod azdls;
49        use azdls::*;
50        use opendal::services::AzdlsConfig;
51    }
52}
53
54cfg_if! {
55    if #[cfg(feature = "opendal-hf")] {
56        mod hf;
57        use hf::*;
58        use opendal::services::HfConfig;
59    }
60}
61
62cfg_if! {
63    if #[cfg(feature = "opendal-fs")] {
64        mod fs;
65        use fs::*;
66    }
67}
68
69cfg_if! {
70    if #[cfg(feature = "opendal-gcs")] {
71        mod gcs;
72        use gcs::*;
73        use opendal::services::GcsConfig;
74    }
75}
76
77cfg_if! {
78    if #[cfg(feature = "opendal-memory")] {
79        mod memory;
80        use memory::*;
81    }
82}
83
84cfg_if! {
85    if #[cfg(feature = "opendal-oss")] {
86        mod oss;
87        use opendal::services::OssConfig;
88        use oss::*;
89    }
90}
91
92cfg_if! {
93    if #[cfg(feature = "opendal-s3")] {
94        mod s3;
95        use opendal::services::S3Config;
96        pub use s3::*;
97    }
98}
99
100mod resolving;
101pub use resolving::{OpenDalResolvingStorage, OpenDalResolvingStorageFactory};
102
103/// OpenDAL-based storage factory.
104///
105/// Maps scheme to the corresponding OpenDalStorage storage variant.
106/// Use this factory with `FileIOBuilder::new(factory)` to create FileIO instances.
107///
108/// # Serialization
109///
110/// The receiving binary must enable the feature corresponding to the serialized backend variant.
111/// For example, deserializing `OpenDalStorageFactory::S3` requires the `opendal-s3` feature.
112///
113/// Serialization fails when the `OpenDalStorageFactory::S3` variant contains a custom AWS
114/// credential loader because the loader holds process-local state that cannot be reconstructed in
115/// another process. Construct the factory without a custom loader before serializing it.
116#[derive(Clone, Debug, Serialize, Deserialize)]
117pub enum OpenDalStorageFactory {
118    /// Memory storage factory.
119    #[cfg(feature = "opendal-memory")]
120    Memory,
121    /// Local filesystem storage factory.
122    #[cfg(feature = "opendal-fs")]
123    Fs,
124    /// S3 storage factory.
125    #[cfg(feature = "opendal-s3")]
126    S3 {
127        /// Custom AWS credential loader.
128        #[serde(
129            skip_deserializing,
130            skip_serializing_if = "Option::is_none",
131            serialize_with = "serialize_custom_credential_loader"
132        )]
133        customized_credential_load: Option<CustomAwsCredentialLoader>,
134    },
135    /// GCS storage factory.
136    #[cfg(feature = "opendal-gcs")]
137    Gcs,
138    /// OSS storage factory.
139    #[cfg(feature = "opendal-oss")]
140    Oss,
141    /// Azure Data Lake Storage factory.
142    #[cfg(feature = "opendal-azdls")]
143    Azdls,
144    /// HuggingFace Hub storage factory.
145    #[cfg(feature = "opendal-hf")]
146    Hf,
147}
148
149#[cfg(feature = "opendal-s3")]
150pub(crate) fn serialize_custom_credential_loader<S>(
151    _loader: &Option<CustomAwsCredentialLoader>,
152    _serializer: S,
153) -> std::result::Result<S::Ok, S::Error>
154where
155    S: serde::Serializer,
156{
157    Err(serde::ser::Error::custom(
158        "custom AWS credential loaders cannot be serialized",
159    ))
160}
161
162#[typetag::serde(name = "OpenDalStorageFactory")]
163impl StorageFactory for OpenDalStorageFactory {
164    #[allow(unused_variables)]
165    fn build(&self, config: &StorageConfig) -> Result<Arc<dyn Storage>> {
166        match self {
167            #[cfg(feature = "opendal-memory")]
168            OpenDalStorageFactory::Memory => {
169                Ok(Arc::new(OpenDalStorage::Memory(memory_config_build()?)))
170            }
171            #[cfg(feature = "opendal-fs")]
172            OpenDalStorageFactory::Fs => Ok(Arc::new(OpenDalStorage::LocalFs)),
173            #[cfg(feature = "opendal-s3")]
174            OpenDalStorageFactory::S3 {
175                customized_credential_load,
176            } => Ok(Arc::new(OpenDalStorage::S3 {
177                config: s3_config_parse(config.props().clone())?.into(),
178                customized_credential_load: customized_credential_load.clone(),
179            })),
180            #[cfg(feature = "opendal-gcs")]
181            OpenDalStorageFactory::Gcs => Ok(Arc::new(OpenDalStorage::Gcs {
182                config: gcs_config_parse(config.props().clone())?.into(),
183            })),
184            #[cfg(feature = "opendal-oss")]
185            OpenDalStorageFactory::Oss => Ok(Arc::new(OpenDalStorage::Oss {
186                config: oss_config_parse(config.props().clone())?.into(),
187            })),
188            #[cfg(feature = "opendal-azdls")]
189            OpenDalStorageFactory::Azdls => Ok(Arc::new(OpenDalStorage::Azdls {
190                config: azdls_config_parse(config.props().clone())?.into(),
191            })),
192            #[cfg(feature = "opendal-hf")]
193            OpenDalStorageFactory::Hf => Ok(Arc::new(OpenDalStorage::Hf {
194                config: hf_config_parse(config.props().clone())?.into(),
195            })),
196            #[cfg(all(
197                not(feature = "opendal-memory"),
198                not(feature = "opendal-fs"),
199                not(feature = "opendal-s3"),
200                not(feature = "opendal-gcs"),
201                not(feature = "opendal-oss"),
202                not(feature = "opendal-azdls"),
203                not(feature = "opendal-hf"),
204            ))]
205            _ => Err(Error::new(
206                ErrorKind::FeatureUnsupported,
207                "No storage service has been enabled",
208            )),
209        }
210    }
211}
212
213/// Default memory operator for serde deserialization.
214#[cfg(feature = "opendal-memory")]
215fn default_memory_operator() -> Operator {
216    memory_config_build().expect("Failed to create default memory operator")
217}
218
219/// OpenDAL-based storage implementation.
220#[derive(Clone, Debug, Serialize, Deserialize)]
221pub enum OpenDalStorage {
222    /// Memory storage variant.
223    #[cfg(feature = "opendal-memory")]
224    Memory(#[serde(skip, default = "self::default_memory_operator")] Operator),
225    /// Local filesystem storage variant.
226    #[cfg(feature = "opendal-fs")]
227    LocalFs,
228    /// S3 storage variant.
229    ///
230    /// Accepts any S3-family URL (`s3://`, `s3a://`, `s3n://`); the scheme is
231    /// derived from the path at call time.
232    #[cfg(feature = "opendal-s3")]
233    S3 {
234        /// S3 configuration.
235        config: Arc<S3Config>,
236        /// Custom AWS credential loader.
237        #[serde(skip)]
238        customized_credential_load: Option<CustomAwsCredentialLoader>,
239    },
240    /// GCS storage variant.
241    #[cfg(feature = "opendal-gcs")]
242    Gcs {
243        /// GCS configuration.
244        config: Arc<GcsConfig>,
245    },
246    /// OSS storage variant.
247    #[cfg(feature = "opendal-oss")]
248    Oss {
249        /// OSS configuration.
250        config: Arc<OssConfig>,
251    },
252    /// Azure Data Lake Storage variant.
253    ///
254    /// Accepts paths of the form
255    /// `abfs[s]://<filesystem>@<account>.dfs.<endpoint-suffix>/<path>` or
256    /// `wasb[s]://<container>@<account>.blob.<endpoint-suffix>/<path>`.
257    /// The scheme is derived from the path at call time.
258    #[cfg(feature = "opendal-azdls")]
259    Azdls {
260        /// Azure DLS configuration.
261        config: Arc<AzdlsConfig>,
262    },
263    /// HuggingFace Hub storage variant.
264    ///
265    /// Accepts paths of the form
266    /// `hf://<repo_type>/<owner>/<repo>[@<revision>]/<path_in_repo>`,
267    /// where `<repo_type>` must be one of `models`, `datasets`, `spaces`, or `buckets`.
268    #[cfg(feature = "opendal-hf")]
269    Hf {
270        /// HuggingFace Hub configuration (token + endpoint).
271        config: Arc<HfConfig>,
272    },
273}
274
275impl OpenDalStorage {
276    /// Creates operator from path.
277    ///
278    /// # Arguments
279    ///
280    /// * path: It should be *absolute* path starting with scheme string used to construct [`FileIO`](iceberg::io::FileIO).
281    ///
282    /// # Returns
283    ///
284    /// The return value consists of two parts:
285    ///
286    /// * An [`opendal::Operator`] instance used to operate on file.
287    /// * Relative path to the root uri of [`opendal::Operator`].
288    #[allow(unreachable_code, unused_variables)]
289    pub(crate) fn create_operator<'a>(
290        &self,
291        path: &'a impl AsRef<str>,
292    ) -> Result<(Operator, &'a str)> {
293        let path = path.as_ref();
294        let (operator, relative_path): (Operator, &str) = match self {
295            #[cfg(feature = "opendal-memory")]
296            OpenDalStorage::Memory(op) => {
297                if let Some(stripped) = path.strip_prefix("memory:/") {
298                    (op.clone(), stripped)
299                } else {
300                    (op.clone(), &path[1..])
301                }
302            }
303            #[cfg(feature = "opendal-fs")]
304            OpenDalStorage::LocalFs => {
305                let op = fs_config_build()?;
306                if let Some(stripped) = path.strip_prefix("file:/") {
307                    (op, stripped)
308                } else {
309                    (op, &path[1..])
310                }
311            }
312            #[cfg(feature = "opendal-s3")]
313            OpenDalStorage::S3 {
314                config,
315                customized_credential_load,
316            } => {
317                let op = s3_config_build(config, customized_credential_load, path)?;
318                let op_info = op.info();
319
320                // Use the URL scheme in the path for prefix matching. This enables
321                // use of S3-compatible storage backends using custom schemes (e.g., `minio://`, `r2://`).
322                let url = url::Url::parse(path).map_err(|e| {
323                    Error::new(
324                        ErrorKind::DataInvalid,
325                        format!("Invalid s3 url: {path}: {e}"),
326                    )
327                })?;
328                let prefix = format!("{}://{}/", url.scheme(), op_info.name());
329                if path.starts_with(&prefix) {
330                    (op, &path[prefix.len()..])
331                } else {
332                    return Err(Error::new(
333                        ErrorKind::DataInvalid,
334                        format!("Invalid s3 url: {path}, should start with {prefix}"),
335                    ));
336                }
337            }
338            #[cfg(feature = "opendal-gcs")]
339            OpenDalStorage::Gcs { config } => {
340                let operator = gcs_config_build(config, path)?;
341                let prefix = format!("gs://{}/", operator.info().name());
342                if path.starts_with(&prefix) {
343                    (operator, &path[prefix.len()..])
344                } else {
345                    return Err(Error::new(
346                        ErrorKind::DataInvalid,
347                        format!("Invalid gcs url: {path}, should start with {prefix}"),
348                    ));
349                }
350            }
351            #[cfg(feature = "opendal-oss")]
352            OpenDalStorage::Oss { config } => {
353                let op = oss_config_build(config, path)?;
354                let prefix = format!("oss://{}/", op.info().name());
355                if path.starts_with(&prefix) {
356                    (op, &path[prefix.len()..])
357                } else {
358                    return Err(Error::new(
359                        ErrorKind::DataInvalid,
360                        format!("Invalid oss url: {path}, should start with {prefix}"),
361                    ));
362                }
363            }
364            #[cfg(feature = "opendal-azdls")]
365            OpenDalStorage::Azdls { config } => azdls_create_operator(path, config)?,
366            #[cfg(feature = "opendal-hf")]
367            OpenDalStorage::Hf { config } => hf_config_build(config, path)?,
368            #[cfg(all(
369                not(feature = "opendal-s3"),
370                not(feature = "opendal-fs"),
371                not(feature = "opendal-gcs"),
372                not(feature = "opendal-oss"),
373                not(feature = "opendal-azdls"),
374                not(feature = "opendal-hf"),
375            ))]
376            _ => {
377                return Err(Error::new(
378                    ErrorKind::FeatureUnsupported,
379                    "No storage service has been enabled",
380                ));
381            }
382        };
383
384        // Apply observability/resilience layers. TimeoutLayer must be
385        // inside RetryLayer so each retry attempt is independently
386        // bounded — without a per-attempt timeout, a future parked on a
387        // silently dropped TCP connection never produces an `Err` and
388        // RetryLayer cannot retry, leaving the caller hung indefinitely.
389        // See: https://opendal.apache.org/docs/rust/opendal/layers/struct.TimeoutLayer.html
390        //
391        // Transient errors are common for object stores; we retry temporary
392        // failures with exponential backoff. The retry behavior also
393        // benefits non-object-store backends.
394        let operator = operator.layer(TimeoutLayer::new()).layer(RetryLayer::new());
395        Ok((operator, relative_path))
396    }
397
398    /// Returns a cache key used by `delete_stream` to group paths by storage operator.
399    ///
400    /// For most backends the URL host (bucket name) is sufficient. For HF the host
401    /// encodes the repo type, not the repo identity, so a more specific key is used.
402    fn batch_key_for_path(&self, path: &str) -> String {
403        match self {
404            #[cfg(feature = "opendal-hf")]
405            OpenDalStorage::Hf { .. } => hf_batch_key(path),
406            _ => url::Url::parse(path)
407                .ok()
408                .and_then(|u| u.host_str().map(|s| s.to_string()))
409                .unwrap_or_default(),
410        }
411    }
412
413    /// Extracts the relative path from an absolute path without building an operator.
414    ///
415    /// This is a lightweight alternative to [`create_operator`](Self::create_operator) for cases
416    /// where only the relative path is needed (e.g. bulk deletes where the operator is already
417    /// available).
418    #[allow(unreachable_code, unused_variables)]
419    pub(crate) fn relativize_path<'a>(&self, path: &'a str) -> Result<&'a str> {
420        match self {
421            #[cfg(feature = "opendal-memory")]
422            OpenDalStorage::Memory(_) => Ok(path.strip_prefix("memory:/").unwrap_or(&path[1..])),
423            #[cfg(feature = "opendal-fs")]
424            OpenDalStorage::LocalFs => Ok(path.strip_prefix("file:/").unwrap_or(&path[1..])),
425            #[cfg(feature = "opendal-s3")]
426            OpenDalStorage::S3 { .. } => {
427                let url = url::Url::parse(path)?;
428                let bucket = url.host_str().ok_or_else(|| {
429                    Error::new(
430                        ErrorKind::DataInvalid,
431                        format!("Invalid s3 url: {path}, missing bucket"),
432                    )
433                })?;
434                let prefix = format!("{}://{}/", url.scheme(), bucket);
435                if path.starts_with(&prefix) {
436                    Ok(&path[prefix.len()..])
437                } else {
438                    Err(Error::new(
439                        ErrorKind::DataInvalid,
440                        format!("Invalid s3 url: {path}, should start with {prefix}"),
441                    ))
442                }
443            }
444            #[cfg(feature = "opendal-gcs")]
445            OpenDalStorage::Gcs { .. } => {
446                let url = url::Url::parse(path)?;
447                let bucket = url.host_str().ok_or_else(|| {
448                    Error::new(
449                        ErrorKind::DataInvalid,
450                        format!("Invalid gcs url: {path}, missing bucket"),
451                    )
452                })?;
453                let prefix = format!("gs://{}/", bucket);
454                if path.starts_with(&prefix) {
455                    Ok(&path[prefix.len()..])
456                } else {
457                    Err(Error::new(
458                        ErrorKind::DataInvalid,
459                        format!("Invalid gcs url: {path}, should start with {prefix}"),
460                    ))
461                }
462            }
463            #[cfg(feature = "opendal-oss")]
464            OpenDalStorage::Oss { .. } => {
465                let url = url::Url::parse(path)?;
466                let bucket = url.host_str().ok_or_else(|| {
467                    Error::new(
468                        ErrorKind::DataInvalid,
469                        format!("Invalid oss url: {path}, missing bucket"),
470                    )
471                })?;
472                let prefix = format!("oss://{}/", bucket);
473                if path.starts_with(&prefix) {
474                    Ok(&path[prefix.len()..])
475                } else {
476                    Err(Error::new(
477                        ErrorKind::DataInvalid,
478                        format!("Invalid oss url: {path}, should start with {prefix}"),
479                    ))
480                }
481            }
482            #[cfg(feature = "opendal-azdls")]
483            OpenDalStorage::Azdls { config } => {
484                let azure_path = path.parse::<AzureStoragePath>()?;
485                match_path_with_config(&azure_path, config)?;
486                let relative_path_len = azure_path.path.len();
487                Ok(&path[path.len() - relative_path_len..])
488            }
489            #[cfg(feature = "opendal-hf")]
490            OpenDalStorage::Hf { .. } => {
491                let parsed = HfUri::parse(path).ok_or_else(|| {
492                    Error::new(ErrorKind::DataInvalid, format!("Invalid hf url: {path}"))
493                })?;
494                Ok(&path[path.len() - parsed.path.len()..])
495            }
496            #[cfg(all(
497                not(feature = "opendal-s3"),
498                not(feature = "opendal-fs"),
499                not(feature = "opendal-gcs"),
500                not(feature = "opendal-oss"),
501                not(feature = "opendal-azdls"),
502                not(feature = "opendal-hf"),
503            ))]
504            _ => Err(Error::new(
505                ErrorKind::FeatureUnsupported,
506                "No storage service has been enabled",
507            )),
508        }
509    }
510}
511
512#[typetag::serde(name = "OpenDalStorage")]
513#[async_trait]
514impl Storage for OpenDalStorage {
515    async fn exists(&self, path: &str) -> Result<bool> {
516        let (op, relative_path) = self.create_operator(&path)?;
517        Ok(op.exists(relative_path).await.map_err(from_opendal_error)?)
518    }
519
520    async fn metadata(&self, path: &str) -> Result<FileMetadata> {
521        let (op, relative_path) = self.create_operator(&path)?;
522        let meta = op.stat(relative_path).await.map_err(from_opendal_error)?;
523        Ok(FileMetadata {
524            size: meta.content_length(),
525        })
526    }
527
528    async fn read(&self, path: &str) -> Result<Bytes> {
529        let (op, relative_path) = self.create_operator(&path)?;
530        Ok(op
531            .read(relative_path)
532            .await
533            .map_err(from_opendal_error)?
534            .to_bytes())
535    }
536
537    async fn reader(&self, path: &str) -> Result<Box<dyn FileRead>> {
538        let (op, relative_path) = self.create_operator(&path)?;
539        Ok(Box::new(OpenDalReader(
540            op.reader(relative_path).await.map_err(from_opendal_error)?,
541        )))
542    }
543
544    async fn write(&self, path: &str, bs: Bytes) -> Result<()> {
545        let (op, relative_path) = self.create_operator(&path)?;
546        op.write(relative_path, bs)
547            .await
548            .map_err(from_opendal_error)?;
549        Ok(())
550    }
551
552    async fn writer(&self, path: &str) -> Result<Box<dyn FileWrite>> {
553        let (op, relative_path) = self.create_operator(&path)?;
554        Ok(Box::new(OpenDalWriter(
555            op.writer(relative_path).await.map_err(from_opendal_error)?,
556        )))
557    }
558
559    async fn delete(&self, path: &str) -> Result<()> {
560        let (op, relative_path) = self.create_operator(&path)?;
561        Ok(op.delete(relative_path).await.map_err(from_opendal_error)?)
562    }
563
564    async fn delete_prefix(&self, path: &str) -> Result<()> {
565        let (op, relative_path) = self.create_operator(&path)?;
566        let path = if relative_path.ends_with('/') {
567            relative_path.to_string()
568        } else {
569            format!("{relative_path}/")
570        };
571        Ok(op
572            .delete_with(&path)
573            .recursive(true)
574            .await
575            .map_err(from_opendal_error)?)
576    }
577
578    async fn delete_stream(&self, mut paths: BoxStream<'static, String>) -> Result<()> {
579        let mut deleters: HashMap<String, opendal::Deleter> = HashMap::new();
580
581        while let Some(path) = paths.next().await {
582            let bucket = self.batch_key_for_path(&path);
583
584            let (relative_path, deleter) = match deleters.entry(bucket) {
585                Entry::Occupied(entry) => {
586                    (self.relativize_path(&path)?.to_string(), entry.into_mut())
587                }
588                Entry::Vacant(entry) => {
589                    let (op, rel) = self.create_operator(&path)?;
590                    let rel = rel.to_string();
591                    let deleter = op.deleter().await.map_err(from_opendal_error)?;
592                    (rel, entry.insert(deleter))
593                }
594            };
595
596            deleter
597                .delete(relative_path)
598                .await
599                .map_err(from_opendal_error)?;
600        }
601
602        for (_, mut deleter) in deleters {
603            deleter.close().await.map_err(from_opendal_error)?;
604        }
605
606        Ok(())
607    }
608
609    #[allow(unreachable_code, unused_variables)]
610    fn new_input(&self, path: &str) -> Result<InputFile> {
611        Ok(InputFile::new(Arc::new(self.clone()), path.to_string()))
612    }
613
614    #[allow(unreachable_code, unused_variables)]
615    fn new_output(&self, path: &str) -> Result<OutputFile> {
616        Ok(OutputFile::new(Arc::new(self.clone()), path.to_string()))
617    }
618}
619
620// Newtype wrappers for opendal types to satisfy orphan rules.
621// We can't implement iceberg's FileRead/FileWrite traits directly on opendal's
622// Reader/Writer since neither trait nor type is defined in this crate.
623
624/// Wrapper around `opendal::Reader` that implements `FileRead`.
625pub(crate) struct OpenDalReader(pub(crate) opendal::Reader);
626
627#[async_trait]
628impl FileRead for OpenDalReader {
629    async fn read(&self, range: std::ops::Range<u64>) -> Result<Bytes> {
630        Ok(opendal::Reader::read(&self.0, range)
631            .await
632            .map_err(from_opendal_error)?
633            .to_bytes())
634    }
635}
636
637/// Wrapper around `opendal::Writer` that implements `FileWrite`.
638pub(crate) struct OpenDalWriter(pub(crate) opendal::Writer);
639
640#[async_trait]
641impl FileWrite for OpenDalWriter {
642    async fn write(&mut self, bs: Bytes) -> Result<()> {
643        Ok(opendal::Writer::write(&mut self.0, bs)
644            .await
645            .map_err(from_opendal_error)?)
646    }
647
648    async fn close(&mut self) -> Result<()> {
649        let _ = opendal::Writer::close(&mut self.0)
650            .await
651            .map_err(from_opendal_error)?;
652        Ok(())
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659
660    #[cfg(feature = "opendal-s3")]
661    #[derive(Debug)]
662    struct EmptyCredentialLoader;
663
664    #[cfg(feature = "opendal-s3")]
665    impl ProvideCredential for EmptyCredentialLoader {
666        type Credential = AwsCredential;
667
668        async fn provide_credential(
669            &self,
670            _ctx: &reqsign_core::Context,
671        ) -> reqsign_core::Result<Option<AwsCredential>> {
672            Ok(None)
673        }
674    }
675
676    #[cfg(feature = "opendal-s3")]
677    #[test]
678    fn test_s3_factory_custom_credential_loader_serialization_fails() {
679        let file_io = iceberg::io::FileIOBuilder::new(Arc::new(OpenDalStorageFactory::S3 {
680            customized_credential_load: Some(CustomAwsCredentialLoader::new(EmptyCredentialLoader)),
681        }))
682        .build();
683
684        let err = file_io.serialize_all().unwrap_err();
685        assert!(
686            err.to_string()
687                .contains("custom AWS credential loaders cannot be serialized")
688        );
689    }
690
691    #[cfg(feature = "opendal-memory")]
692    #[test]
693    fn test_default_memory_operator() {
694        let op = default_memory_operator();
695        assert_eq!(op.info().scheme().to_string(), "memory");
696    }
697
698    #[cfg(feature = "opendal-memory")]
699    #[test]
700    fn test_relativize_path_memory() {
701        let storage = OpenDalStorage::Memory(default_memory_operator());
702
703        assert_eq!(
704            storage.relativize_path("memory:/path/to/file").unwrap(),
705            "path/to/file"
706        );
707        // Without the scheme prefix, falls back to stripping the leading slash
708        assert_eq!(
709            storage.relativize_path("/path/to/file").unwrap(),
710            "path/to/file"
711        );
712    }
713
714    #[cfg(feature = "opendal-fs")]
715    #[test]
716    fn test_relativize_path_fs() {
717        let storage = OpenDalStorage::LocalFs;
718
719        assert_eq!(
720            storage
721                .relativize_path("file:/tmp/data/file.parquet")
722                .unwrap(),
723            "tmp/data/file.parquet"
724        );
725        assert_eq!(
726            storage.relativize_path("/tmp/data/file.parquet").unwrap(),
727            "tmp/data/file.parquet"
728        );
729    }
730
731    #[cfg(feature = "opendal-s3")]
732    #[test]
733    fn test_relativize_path_s3() {
734        let storage = OpenDalStorage::S3 {
735            config: Arc::new(S3Config::default()),
736            customized_credential_load: None,
737        };
738
739        // All S3-family schemes are accepted by the same storage instance.
740        // Custom schemes for S3-compatible stores (e.g., `minio://`) are also
741        // accepted because the path's scheme is used as-is for prefix matching.
742        for scheme in ["s3", "s3a", "s3n", "minio"] {
743            assert_eq!(
744                storage
745                    .relativize_path(&format!("{scheme}://my-bucket/path/to/file.parquet"))
746                    .unwrap(),
747                "path/to/file.parquet"
748            );
749        }
750    }
751
752    #[cfg(feature = "opendal-gcs")]
753    #[test]
754    fn test_relativize_path_gcs() {
755        let storage = OpenDalStorage::Gcs {
756            config: Arc::new(GcsConfig::default()),
757        };
758
759        assert_eq!(
760            storage
761                .relativize_path("gs://my-bucket/path/to/file.parquet")
762                .unwrap(),
763            "path/to/file.parquet"
764        );
765    }
766
767    #[cfg(feature = "opendal-gcs")]
768    #[test]
769    fn test_relativize_path_gcs_invalid_scheme() {
770        let storage = OpenDalStorage::Gcs {
771            config: Arc::new(GcsConfig::default()),
772        };
773
774        assert!(
775            storage
776                .relativize_path("s3://my-bucket/path/to/file.parquet")
777                .is_err()
778        );
779    }
780
781    #[cfg(feature = "opendal-oss")]
782    #[test]
783    fn test_relativize_path_oss() {
784        let storage = OpenDalStorage::Oss {
785            config: Arc::new(OssConfig::default()),
786        };
787
788        assert_eq!(
789            storage
790                .relativize_path("oss://my-bucket/path/to/file.parquet")
791                .unwrap(),
792            "path/to/file.parquet"
793        );
794    }
795
796    #[cfg(feature = "opendal-oss")]
797    #[test]
798    fn test_relativize_path_oss_invalid_scheme() {
799        let storage = OpenDalStorage::Oss {
800            config: Arc::new(OssConfig::default()),
801        };
802
803        assert!(
804            storage
805                .relativize_path("s3://my-bucket/path/to/file.parquet")
806                .is_err()
807        );
808    }
809
810    #[cfg(feature = "opendal-azdls")]
811    #[test]
812    fn test_relativize_path_azdls() {
813        let storage = OpenDalStorage::Azdls {
814            config: Arc::new(AzdlsConfig {
815                account_name: Some("myaccount".to_string()),
816                endpoint: Some("https://myaccount.dfs.core.windows.net".to_string()),
817                ..Default::default()
818            }),
819        };
820
821        assert_eq!(
822            storage
823                .relativize_path("abfss://myfs@myaccount.dfs.core.windows.net/path/to/file.parquet")
824                .unwrap(),
825            "/path/to/file.parquet"
826        );
827    }
828}