Skip to main content

lance_io/
object_store.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Extend [object_store::ObjectStore] functionalities
5
6use std::borrow::Cow;
7use std::collections::{HashMap, HashSet};
8use std::ops::Range;
9#[cfg(unix)]
10use std::os::unix::fs::PermissionsExt;
11use std::pin::Pin;
12use std::str::FromStr;
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16use ::tracing::{Span, field::Empty, instrument};
17use async_trait::async_trait;
18use bytes::Bytes;
19use chrono::{DateTime, Utc};
20use futures::{FutureExt, Stream};
21use futures::{StreamExt, TryStreamExt, future, stream::BoxStream};
22use lance_core::deepsize::DeepSizeOf;
23use lance_core::error::LanceOptionExt;
24use lance_core::utils::parse::{parse_env_as_bool, str_is_truthy};
25use list_retry::ListRetryStream;
26use object_store::DynObjectStore;
27use object_store::ObjectStoreExt as OSObjectStoreExt;
28#[cfg(feature = "aws")]
29use object_store::aws::AwsCredentialProvider;
30use object_store::list::PaginatedListStore;
31#[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
32use object_store::{ClientOptions, HeaderMap, HeaderValue};
33use object_store::{
34    ListResult, ObjectMeta, ObjectStore as OSObjectStore, PutMode, PutOptions, PutPayload,
35    path::Path,
36};
37use providers::local::FileStoreProvider;
38use providers::memory::MemoryStoreProvider;
39use tokio::io::AsyncWriteExt;
40use url::Url;
41
42use super::local::LocalObjectReader;
43#[cfg(target_os = "linux")]
44use crate::uring::{UringCurrentThreadReader, UringReader};
45#[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
46pub(crate) mod dynamic_credentials;
47#[cfg(any(feature = "oss", feature = "huggingface", feature = "tos"))]
48pub(crate) mod dynamic_opendal;
49mod list_retry;
50#[cfg(feature = "metrics")]
51pub mod metrics;
52#[cfg(any(
53    feature = "aws",
54    feature = "gcp",
55    feature = "azure",
56    feature = "oss",
57    feature = "tencent",
58    feature = "huggingface",
59    feature = "tos",
60    feature = "goosefs",
61))]
62pub(crate) mod opendal_store;
63pub mod providers;
64pub(crate) mod read_dir;
65pub mod storage_options;
66#[cfg(test)]
67pub(crate) mod test_utils;
68pub mod throttle;
69mod tracing;
70use crate::object_reader::SmallReader;
71use crate::object_writer::{LocalWriter, WriteResult};
72use crate::traits::{WriteExt, Writer};
73use crate::utils::tracking_store::{IOTracker, IoStats};
74use crate::{object_reader::CloudObjectReader, object_writer::ObjectWriter, traits::Reader};
75use lance_core::{Error, Result};
76
77// Local disks tend to do fine with a few threads
78// Note: the number of threads here also impacts the number of files
79// we need to read in some situations.  So keeping this at 8 keeps the
80// RAM on our scanner down.
81pub const DEFAULT_LOCAL_IO_PARALLELISM: usize = 8;
82// Cloud disks often need many many threads to saturate the network
83pub const DEFAULT_CLOUD_IO_PARALLELISM: usize = 64;
84
85const SERVER_SIDE_COPY_ENABLED_ENV: &str = "LANCE_IO_SERVER_SIDE_COPY_ENABLED";
86
87const DEFAULT_LOCAL_BLOCK_SIZE: usize = 4 * 1024; // 4KB block size
88#[cfg(any(
89    feature = "aws",
90    feature = "gcp",
91    feature = "azure",
92    feature = "oss",
93    feature = "tencent",
94    feature = "huggingface",
95    feature = "tos",
96    feature = "goosefs",
97))]
98const DEFAULT_CLOUD_BLOCK_SIZE: usize = 64 * 1024; // 64KB block size
99
100pub static DEFAULT_MAX_IOP_SIZE: std::sync::LazyLock<u64> = std::sync::LazyLock::new(|| {
101    std::env::var("LANCE_MAX_IOP_SIZE")
102        .map(|val| val.parse().unwrap())
103        .unwrap_or(16 * 1024 * 1024)
104});
105
106pub const DEFAULT_DOWNLOAD_RETRY_COUNT: usize = 3;
107
108#[derive(Debug)]
109struct StreamCopyError {
110    stage: &'static str,
111    source_path: String,
112    destination_path: String,
113    source: Box<dyn std::error::Error + Send + Sync>,
114}
115
116impl std::fmt::Display for StreamCopyError {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        write!(
119            f,
120            "multipart_stream_copy failed during {} from {} to {}: {}",
121            self.stage, self.source_path, self.destination_path, self.source
122        )
123    }
124}
125
126impl std::error::Error for StreamCopyError {
127    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
128        Some(self.source.as_ref())
129    }
130}
131
132fn stream_copy_error(
133    stage: &'static str,
134    source_path: &Path,
135    destination_path: &Path,
136    source: impl std::error::Error + Send + Sync + 'static,
137) -> Error {
138    Error::io_source(Box::new(StreamCopyError {
139        stage,
140        source_path: source_path.to_string(),
141        destination_path: destination_path.to_string(),
142        source: Box::new(source),
143    }))
144}
145
146pub use providers::{ObjectStoreProvider, ObjectStoreRegistry};
147pub use read_dir::ReadDirOptions;
148pub use storage_options::{
149    BASE_SCOPED_OPTION_PREFIX, BaseScopedStorageOptionsProvider, EXPIRES_AT_MILLIS_KEY,
150    LanceNamespaceStorageOptionsProvider, REFRESH_OFFSET_MILLIS_KEY, StorageOptionsAccessor,
151    StorageOptionsProvider, has_base_scoped_options, parse_base_scoped_key,
152    resolve_base_scoped_options,
153};
154
155#[async_trait]
156pub trait ObjectStoreExt {
157    /// Returns true if the file exists.
158    async fn exists(&self, path: &Path) -> Result<bool>;
159
160    /// Read all files (start from base directory) recursively
161    ///
162    /// unmodified_since can be specified to only return files that have not been modified since the given time.
163    fn read_dir_all<'a, 'b>(
164        &'a self,
165        dir_path: impl Into<&'b Path> + Send,
166        unmodified_since: Option<DateTime<Utc>>,
167    ) -> BoxStream<'a, Result<ObjectMeta>>;
168}
169
170#[async_trait]
171pub(super) trait LocalDirOperations: std::fmt::Debug + Send + Sync {
172    async fn remove_dir_all(&self, path: &Path) -> Result<()>;
173}
174
175#[async_trait]
176impl<O: OSObjectStore + ?Sized> ObjectStoreExt for O {
177    fn read_dir_all<'a, 'b>(
178        &'a self,
179        dir_path: impl Into<&'b Path> + Send,
180        unmodified_since: Option<DateTime<Utc>>,
181    ) -> BoxStream<'a, Result<ObjectMeta>> {
182        let output = self.list(Some(dir_path.into())).map_err(|e| e.into());
183        if let Some(unmodified_since_val) = unmodified_since {
184            output
185                .try_filter(move |file| future::ready(file.last_modified <= unmodified_since_val))
186                .boxed()
187        } else {
188            output.boxed()
189        }
190    }
191
192    async fn exists(&self, path: &Path) -> Result<bool> {
193        match self.head(path).await {
194            Ok(_) => Ok(true),
195            Err(object_store::Error::NotFound { path: _, source: _ }) => Ok(false),
196            Err(e) => Err(e.into()),
197        }
198    }
199}
200
201/// Wraps [ObjectStore](object_store::ObjectStore)
202#[derive(Clone)]
203pub struct ObjectStore {
204    // Inner object store
205    pub inner: Arc<dyn OSObjectStore>,
206    // Provider-owned native directory operations for rooted local stores.
207    local_dir_operations: Option<Arc<dyn LocalDirOperations>>,
208    scheme: String,
209    block_size: usize,
210    max_iop_size: u64,
211    /// Whether to use constant size upload parts for multipart uploads. This
212    /// is only necessary for Cloudflare R2.
213    pub use_constant_size_upload_parts: bool,
214    /// Whether we can assume that the list of files is lexically ordered. This
215    /// is true for object stores, but not for local filesystems.
216    pub list_is_lexically_ordered: bool,
217    io_parallelism: usize,
218    /// Number of times to retry a failed download
219    download_retry_count: usize,
220    /// IO tracker for monitoring read/write operations
221    io_tracker: IOTracker,
222    /// The datastore prefix that uniquely identifies this object store. It encodes information
223    /// which usually cannot be found in the URL such as Azure account name. The prefix plus the
224    /// path uniquely identifies any object inside the store.
225    pub store_prefix: String,
226    /// The backend's paginated listing API, when it has one. `None` means
227    /// [`Self::read_dir_page`] has to list a directory in full to page through it.
228    pub(crate) paginated_lister: Option<Arc<dyn PaginatedListStore>>,
229}
230
231// Hand-written because `PaginatedListStore` is not `Debug`.
232impl std::fmt::Debug for ObjectStore {
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        f.debug_struct("ObjectStore")
235            .field("inner", &self.inner)
236            .field("scheme", &self.scheme)
237            .field("block_size", &self.block_size)
238            .field("max_iop_size", &self.max_iop_size)
239            .field(
240                "use_constant_size_upload_parts",
241                &self.use_constant_size_upload_parts,
242            )
243            .field("list_is_lexically_ordered", &self.list_is_lexically_ordered)
244            .field("io_parallelism", &self.io_parallelism)
245            .field("download_retry_count", &self.download_retry_count)
246            .field("io_tracker", &self.io_tracker)
247            .field("store_prefix", &self.store_prefix)
248            .field("paginated_lister", &self.paginated_lister.is_some())
249            .finish()
250    }
251}
252
253impl DeepSizeOf for ObjectStore {
254    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
255        // We aren't counting `inner` here which is problematic but an ObjectStore
256        // shouldn't be too big.  The only exception might be the write cache but, if
257        // the writer cache has data, it means we're using it somewhere else that isn't
258        // a cache and so that doesn't really count.
259        self.scheme.deep_size_of_children(context) + self.block_size.deep_size_of_children(context)
260    }
261}
262
263impl std::fmt::Display for ObjectStore {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        write!(f, "ObjectStore({})", self.scheme)
266    }
267}
268
269pub trait WrappingObjectStore: std::fmt::Debug + Send + Sync {
270    /// Wrap an object store with additional functionality
271    ///
272    /// The store_prefix is a string which uniquely identifies the object
273    /// store being wrapped.
274    fn wrap(&self, store_prefix: &str, original: Arc<dyn OSObjectStore>) -> Arc<dyn OSObjectStore>;
275
276    /// Wrap the paginated listing API that goes with the store, if it has one.
277    ///
278    /// [`ObjectStore::read_dir_page`] pushes the page size and the resume position into
279    /// [`PaginatedListStore`], which is a separate trait from [`OSObjectStore`] and so cannot
280    /// be reached through the store [`Self::wrap`] returns. A listing that is pushed down
281    /// therefore does not pass through [`Self::wrap`], and this is where a wrapper says what
282    /// should happen instead:
283    ///
284    /// - `Some(lister)` keeps the pushdown, wrapping the lister or handing back the one
285    ///   given. Right for a wrapper that observes rather than intercepts — metering, caching,
286    ///   mirroring writes.
287    /// - `None` gives up the pushdown, so listings go through [`Self::wrap`] as a full
288    ///   directory read. Right for a wrapper that hides, rewrites or fails paths, which a
289    ///   pushed-down listing would otherwise walk straight past.
290    ///
291    /// A wrapper that keeps the pushdown must leave the listing itself alone: setting
292    /// [`offset`](object_store::list::PaginatedListOptions::offset) or changing the delimiter
293    /// breaks paging, since `read_dir_page` reads one directory level and resumes by the token
294    /// it got back.
295    ///
296    /// There is deliberately no default: getting this wrong is either a silent loss of speed
297    /// or a silent loss of the wrapper, and neither announces itself.
298    fn wrap_paginated(
299        &self,
300        store_prefix: &str,
301        original: Arc<dyn PaginatedListStore>,
302    ) -> Option<Arc<dyn PaginatedListStore>>;
303}
304
305#[derive(Debug, Clone)]
306pub struct ChainedWrappingObjectStore {
307    wrappers: Vec<Arc<dyn WrappingObjectStore>>,
308}
309
310impl ChainedWrappingObjectStore {
311    pub fn new(wrappers: Vec<Arc<dyn WrappingObjectStore>>) -> Self {
312        Self { wrappers }
313    }
314
315    pub fn add_wrapper(&mut self, wrapper: Arc<dyn WrappingObjectStore>) {
316        self.wrappers.push(wrapper);
317    }
318}
319
320impl WrappingObjectStore for ChainedWrappingObjectStore {
321    fn wrap(&self, store_prefix: &str, original: Arc<dyn OSObjectStore>) -> Arc<dyn OSObjectStore> {
322        self.wrappers
323            .iter()
324            .fold(original, |acc, wrapper| wrapper.wrap(store_prefix, acc))
325    }
326
327    // One wrapper giving up the pushdown gives it up for the chain: the listing has to go
328    // through `wrap`, which is every wrapper in the chain at once.
329    fn wrap_paginated(
330        &self,
331        store_prefix: &str,
332        original: Arc<dyn PaginatedListStore>,
333    ) -> Option<Arc<dyn PaginatedListStore>> {
334        self.wrappers.iter().try_fold(original, |acc, wrapper| {
335            wrapper.wrap_paginated(store_prefix, acc)
336        })
337    }
338}
339
340/// Parameters to create an [ObjectStore]
341///
342#[derive(Debug, Clone)]
343pub struct ObjectStoreParams {
344    pub block_size: Option<usize>,
345    #[deprecated(note = "Implement an ObjectStoreProvider instead")]
346    pub object_store: Option<(Arc<DynObjectStore>, Url)>,
347    /// Refresh offset for AWS credentials when using the legacy AWS credentials path.
348    /// For StorageOptionsAccessor, use `refresh_offset_millis` storage option instead.
349    pub s3_credentials_refresh_offset: Duration,
350    #[cfg(feature = "aws")]
351    pub aws_credentials: Option<AwsCredentialProvider>,
352    pub object_store_wrapper: Option<Arc<dyn WrappingObjectStore>>,
353    /// Unified storage options accessor with caching and automatic refresh
354    ///
355    /// Provides storage options and optionally a dynamic provider for automatic
356    /// credential refresh. Use `StorageOptionsAccessor::with_static_options()` for static
357    /// options or `StorageOptionsAccessor::with_initial_and_provider()` for dynamic refresh.
358    pub storage_options_accessor: Option<Arc<StorageOptionsAccessor>>,
359    /// Use constant size upload parts for multipart uploads. Only necessary
360    /// for Cloudflare R2, which doesn't support variable size parts. When this
361    /// is false, max upload size is 2.5TB. When this is true, the max size is
362    /// 50GB.
363    pub use_constant_size_upload_parts: bool,
364    pub list_is_lexically_ordered: Option<bool>,
365}
366
367impl Default for ObjectStoreParams {
368    fn default() -> Self {
369        #[allow(deprecated)]
370        Self {
371            object_store: None,
372            block_size: None,
373            s3_credentials_refresh_offset: Duration::from_secs(60),
374            #[cfg(feature = "aws")]
375            aws_credentials: None,
376            object_store_wrapper: None,
377            storage_options_accessor: None,
378            use_constant_size_upload_parts: false,
379            list_is_lexically_ordered: None,
380        }
381    }
382}
383
384impl ObjectStoreParams {
385    /// Get the StorageOptionsAccessor from the params
386    pub fn get_accessor(&self) -> Option<Arc<StorageOptionsAccessor>> {
387        self.storage_options_accessor.clone()
388    }
389
390    /// Get storage options from the accessor, if any
391    ///
392    /// Returns the initial storage options from the accessor without triggering refresh.
393    pub fn storage_options(&self) -> Option<&HashMap<String, String>> {
394        self.storage_options_accessor
395            .as_ref()
396            .and_then(|a| a.initial_storage_options())
397    }
398
399    /// Resolve these params for a single base path scope.
400    ///
401    /// Storage options may carry base-scoped entries (`base_<id>.<key>`) that
402    /// apply only to one registered base path; see
403    /// [`StorageOptionsAccessor::scoped_to_base`]. Returns the params unchanged
404    /// when the storage options contain no base-scoped entries.
405    pub fn scoped_to_base(&self, base_id: Option<u32>) -> Cow<'_, Self> {
406        let Some(accessor) = &self.storage_options_accessor else {
407            return Cow::Borrowed(self);
408        };
409        let scoped = accessor.scoped_to_base(base_id);
410        if Arc::ptr_eq(&scoped, accessor) {
411            Cow::Borrowed(self)
412        } else {
413            Cow::Owned(Self {
414                storage_options_accessor: Some(scoped),
415                ..self.clone()
416            })
417        }
418    }
419}
420
421fn wrapper_allocation_ptr(wrapper: &Arc<dyn WrappingObjectStore>) -> *const () {
422    // Trait object pointers include vtable metadata, which is not stable across codegen units.
423    // Cache identity must follow the Arc allocation instead.
424    Arc::as_ptr(wrapper) as *const ()
425}
426
427// We implement hash for caching
428impl std::hash::Hash for ObjectStoreParams {
429    #[allow(deprecated)]
430    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
431        // For hashing, we use pointer values for ObjectStore, S3 credentials, wrapper
432        self.block_size.hash(state);
433        if let Some((store, url)) = &self.object_store {
434            Arc::as_ptr(store).hash(state);
435            url.hash(state);
436        }
437        self.s3_credentials_refresh_offset.hash(state);
438        #[cfg(feature = "aws")]
439        if let Some(aws_credentials) = &self.aws_credentials {
440            Arc::as_ptr(aws_credentials).hash(state);
441        }
442        if let Some(wrapper) = &self.object_store_wrapper {
443            wrapper_allocation_ptr(wrapper).hash(state);
444        }
445        if let Some(accessor) = &self.storage_options_accessor {
446            accessor.accessor_id().hash(state);
447        }
448        self.use_constant_size_upload_parts.hash(state);
449        self.list_is_lexically_ordered.hash(state);
450    }
451}
452
453// We implement eq for caching
454impl Eq for ObjectStoreParams {}
455impl PartialEq for ObjectStoreParams {
456    #[allow(deprecated)]
457    fn eq(&self, other: &Self) -> bool {
458        #[cfg(feature = "aws")]
459        if self.aws_credentials.is_some() != other.aws_credentials.is_some() {
460            return false;
461        }
462
463        // For equality, we use pointer comparison for ObjectStore, S3 credentials, wrapper
464        // For accessor, we use accessor_id() for semantic equality
465        self.block_size == other.block_size
466            && self
467                .object_store
468                .as_ref()
469                .map(|(store, url)| (Arc::as_ptr(store), url))
470                == other
471                    .object_store
472                    .as_ref()
473                    .map(|(store, url)| (Arc::as_ptr(store), url))
474            && self.s3_credentials_refresh_offset == other.s3_credentials_refresh_offset
475            && self
476                .object_store_wrapper
477                .as_ref()
478                .map(wrapper_allocation_ptr)
479                == other
480                    .object_store_wrapper
481                    .as_ref()
482                    .map(wrapper_allocation_ptr)
483            && self
484                .storage_options_accessor
485                .as_ref()
486                .map(|a| a.accessor_id())
487                == other
488                    .storage_options_accessor
489                    .as_ref()
490                    .map(|a| a.accessor_id())
491            && self.use_constant_size_upload_parts == other.use_constant_size_upload_parts
492            && self.list_is_lexically_ordered == other.list_is_lexically_ordered
493    }
494}
495
496/// Convert a URI string or local path to a URL
497///
498/// This function handles both proper URIs (with schemes like `file://`, `s3://`, etc.)
499/// and plain local filesystem paths. On Windows, it correctly handles drive letters
500/// that might be parsed as URL schemes.
501///
502/// # Examples
503///
504/// ```
505/// # use lance_io::object_store::uri_to_url;
506/// // URIs are preserved
507/// let url = uri_to_url("s3://bucket/path").unwrap();
508/// assert_eq!(url.scheme(), "s3");
509///
510/// // Local paths are converted to file:// URIs
511/// # #[cfg(unix)]
512/// let url = uri_to_url("/tmp/data").unwrap();
513/// # #[cfg(unix)]
514/// assert_eq!(url.scheme(), "file");
515/// ```
516pub fn uri_to_url(uri: &str) -> Result<Url> {
517    match Url::parse(uri) {
518        Ok(url) if url.scheme().len() == 1 && cfg!(windows) => {
519            // On Windows, the drive is parsed as a scheme
520            local_path_to_url(uri)
521        }
522        Ok(url) => Ok(url),
523        Err(_) => local_path_to_url(uri),
524    }
525}
526
527fn expand_path(str_path: impl AsRef<str>) -> Result<std::path::PathBuf> {
528    let str_path = str_path.as_ref();
529    let expanded = expand_tilde_path(str_path).unwrap_or_else(|| str_path.into());
530
531    let mut expanded_path = path_abs::PathAbs::new(expanded)
532        .unwrap()
533        .as_path()
534        .to_path_buf();
535    // path_abs::PathAbs::new(".") returns an empty string.
536    if let Some(s) = expanded_path.as_path().to_str()
537        && s.is_empty()
538    {
539        expanded_path = std::env::current_dir()?;
540    }
541
542    Ok(expanded_path)
543}
544
545fn expand_tilde_path(path: &str) -> Option<std::path::PathBuf> {
546    let home_dir = std::env::home_dir()?;
547    if path == "~" {
548        return Some(home_dir);
549    }
550    if let Some(stripped) = path.strip_prefix("~/") {
551        return Some(home_dir.join(stripped));
552    }
553    #[cfg(windows)]
554    if let Some(stripped) = path.strip_prefix("~\\") {
555        return Some(home_dir.join(stripped));
556    }
557
558    None
559}
560
561fn local_path_to_url(str_path: &str) -> Result<Url> {
562    let expanded_path = expand_path(str_path)?;
563
564    Url::from_directory_path(expanded_path).map_err(|_| {
565        Error::invalid_input_source(format!("Invalid table location: '{}'", str_path).into())
566    })
567}
568
569#[cfg(feature = "huggingface")]
570fn parse_hf_repo_id(url: &Url) -> Result<String> {
571    // Accept forms with repo type prefix (models/datasets/spaces) or legacy without.
572    let mut segments: Vec<String> = Vec::new();
573    if let Some(host) = url.host_str() {
574        segments.push(host.to_string());
575    }
576    segments.extend(
577        url.path()
578            .trim_start_matches('/')
579            .split('/')
580            .map(|s| s.to_string()),
581    );
582
583    if segments.len() < 2 {
584        return Err(Error::invalid_input(
585            "Huggingface URL must contain at least owner and repo",
586        ));
587    }
588
589    let repo_type_candidates = ["models", "datasets", "spaces"];
590    let (owner, repo_with_rev) = if repo_type_candidates.contains(&segments[0].as_str()) {
591        if segments.len() < 3 {
592            return Err(Error::invalid_input(
593                "Huggingface URL missing owner/repo after repo type",
594            ));
595        }
596        (segments[1].as_str(), segments[2].as_str())
597    } else {
598        (segments[0].as_str(), segments[1].as_str())
599    };
600
601    let repo = repo_with_rev
602        .split_once('@')
603        .map(|(r, _)| r)
604        .unwrap_or(repo_with_rev);
605    Ok(format!("{owner}/{repo}"))
606}
607
608impl ObjectStore {
609    /// Parse from a string URI.
610    ///
611    /// Returns the ObjectStore instance and the absolute path to the object.
612    ///
613    /// This uses the default [ObjectStoreRegistry] to find the object store. To
614    /// allow for potential re-use of object store instances, it's recommended to
615    /// create a shared [ObjectStoreRegistry] and pass that to [Self::from_uri_and_params].
616    pub async fn from_uri(uri: &str) -> Result<(Arc<Self>, Path)> {
617        let registry = Arc::new(ObjectStoreRegistry::default());
618
619        Self::from_uri_and_params(registry, uri, &ObjectStoreParams::default()).await
620    }
621
622    /// Parse from a string URI.
623    ///
624    /// Returns the ObjectStore instance and the absolute path to the object.
625    pub async fn from_uri_and_params(
626        registry: Arc<ObjectStoreRegistry>,
627        uri: &str,
628        params: &ObjectStoreParams,
629    ) -> Result<(Arc<Self>, Path)> {
630        Self::from_uri_and_params_impl(registry, uri, params, true).await
631    }
632
633    /// Parse a URI and build a fresh object store outside the registry cache.
634    ///
635    /// The caller must retain the returned store for as long as its
636    /// provider-local state should be reused.
637    #[doc(hidden)]
638    pub async fn from_uri_and_params_uncached(
639        registry: Arc<ObjectStoreRegistry>,
640        uri: &str,
641        params: &ObjectStoreParams,
642    ) -> Result<(Arc<Self>, Path)> {
643        Self::from_uri_and_params_impl(registry, uri, params, false).await
644    }
645
646    async fn from_uri_and_params_impl(
647        registry: Arc<ObjectStoreRegistry>,
648        uri: &str,
649        params: &ObjectStoreParams,
650        use_registry_cache: bool,
651    ) -> Result<(Arc<Self>, Path)> {
652        #[allow(deprecated)]
653        if let Some((store, path)) = params.object_store.as_ref() {
654            let mut inner = store.clone();
655            let store_prefix =
656                registry.calculate_object_store_prefix(uri, params.storage_options())?;
657
658            let mut io_tracker = IOTracker::default();
659            meter_store(&mut inner, &mut io_tracker, &store_prefix);
660
661            if let Some(wrapper) = params.object_store_wrapper.as_ref() {
662                inner = wrapper.wrap(&store_prefix, inner);
663            }
664
665            // Always wrap with IO tracking
666            let tracked_store = io_tracker.wrap("", inner);
667
668            let store = Self {
669                inner: tracked_store,
670                local_dir_operations: None,
671                scheme: path.scheme().to_string(),
672                block_size: params.block_size.unwrap_or(64 * 1024),
673                max_iop_size: *DEFAULT_MAX_IOP_SIZE,
674                use_constant_size_upload_parts: params.use_constant_size_upload_parts,
675                list_is_lexically_ordered: params.list_is_lexically_ordered.unwrap_or_default(),
676                io_parallelism: DEFAULT_CLOUD_IO_PARALLELISM,
677                download_retry_count: DEFAULT_DOWNLOAD_RETRY_COUNT,
678                io_tracker,
679                store_prefix,
680                // Type-erased on the way in, so there is no telling if it can paginate.
681                paginated_lister: None,
682            };
683            let path = Path::parse(path.path())?;
684            return Ok((Arc::new(store), path));
685        }
686        let url = uri_to_url(uri)?;
687
688        let store = if use_registry_cache {
689            registry.get_store(url.clone(), params).await?
690        } else {
691            registry.new_store(url.clone(), params).await?
692        };
693        // We know the scheme is valid if we got a store back.
694        let provider = registry.get_provider(url.scheme()).expect_ok()?;
695        let path = provider.extract_path(&url)?;
696
697        Ok((store, path))
698    }
699
700    /// Extract the path component from a URI without initializing the object store.
701    ///
702    /// This is a synchronous operation that only parses the URI and extracts the path,
703    /// without creating or initializing any object store instance.
704    ///
705    /// # Arguments
706    ///
707    /// * `registry` - The object store registry to get the provider
708    /// * `uri` - The URI to extract the path from
709    ///
710    /// # Returns
711    ///
712    /// The extracted path component
713    pub fn extract_path_from_uri(registry: Arc<ObjectStoreRegistry>, uri: &str) -> Result<Path> {
714        let url = uri_to_url(uri)?;
715        let provider = registry
716            .get_provider(url.scheme())
717            .ok_or_else(|| Error::invalid_input(format!("Unknown scheme: {}", url.scheme())))?;
718        provider.extract_path(&url)
719    }
720
721    #[deprecated(note = "Use `from_uri` instead")]
722    pub fn from_path(str_path: &str) -> Result<(Arc<Self>, Path)> {
723        Self::from_uri_and_params(
724            Arc::new(ObjectStoreRegistry::default()),
725            str_path,
726            &Default::default(),
727        )
728        .now_or_never()
729        .unwrap()
730    }
731
732    /// Local object store.
733    pub fn local() -> Self {
734        let provider = FileStoreProvider;
735        provider
736            .new_store(Url::parse("file:///").unwrap(), &Default::default())
737            .now_or_never()
738            .unwrap()
739            .unwrap()
740    }
741
742    /// Create a in-memory object store directly for testing.
743    pub fn memory() -> Self {
744        let provider = MemoryStoreProvider;
745        provider
746            .new_store(Url::parse("memory:///").unwrap(), &Default::default())
747            .now_or_never()
748            .unwrap()
749            .unwrap()
750    }
751
752    /// Returns true if the object store pointed to a local file system.
753    pub fn is_local(&self) -> bool {
754        self.scheme == "file" || self.scheme == "file+uring"
755    }
756
757    /// Returns true when object paths directly encode absolute local filesystem paths.
758    ///
759    /// Local stores rooted below the filesystem root, such as UNC-backed stores, use
760    /// their inner object-store implementation instead of direct filesystem access.
761    pub fn has_direct_local_paths(&self) -> bool {
762        self.is_local() && self.store_prefix == self.scheme
763    }
764
765    pub fn is_cloud(&self) -> bool {
766        if self.is_local() || self.scheme == "memory" || self.scheme == "shared-memory" {
767            return false;
768        }
769        true
770    }
771
772    /// Whether this object store prefers the lite scheduler.
773    ///
774    /// The lite scheduler is designed for backends like io_uring where
775    /// tasks should only be polled when the consumer polls them.
776    pub fn prefers_lite_scheduler(&self) -> bool {
777        self.scheme == "file+uring"
778    }
779
780    pub fn scheme(&self) -> &str {
781        &self.scheme
782    }
783
784    pub fn block_size(&self) -> usize {
785        self.block_size
786    }
787
788    pub fn max_iop_size(&self) -> u64 {
789        self.max_iop_size
790    }
791
792    /// The amount of parallelism to use for I/O operations.
793    ///
794    /// Honors the `LANCE_IO_THREADS` override when set, otherwise the store's configured value.
795    /// Always at least 1: callers feed this straight into `buffered` / `buffer_unordered`, and a
796    /// window of 0 makes those streams never poll their input — e.g. a metadata-only `count_rows`
797    /// would hang rather than return.
798    pub fn io_parallelism(&self) -> usize {
799        std::env::var("LANCE_IO_THREADS")
800            .map(|val| val.parse::<usize>().unwrap())
801            .unwrap_or(self.io_parallelism)
802            .max(1)
803    }
804
805    /// Get the IO tracker for this object store
806    ///
807    /// The IO tracker can be used to get statistics about read/write operations
808    /// performed on this object store.
809    pub fn io_tracker(&self) -> &IOTracker {
810        &self.io_tracker
811    }
812
813    /// Get a snapshot of current IO statistics without resetting counters
814    ///
815    /// Returns the current IO statistics without modifying the internal state.
816    /// Use this when you need to check stats without resetting them.
817    pub fn io_stats_snapshot(&self) -> IoStats {
818        self.io_tracker.stats()
819    }
820
821    /// Get incremental IO statistics since the last call to this method
822    ///
823    /// Returns the accumulated statistics since the last call and resets the
824    /// counters to zero. This is useful for tracking IO operations between
825    /// different stages of processing.
826    pub fn io_stats_incremental(&self) -> IoStats {
827        self.io_tracker.incremental_stats()
828    }
829
830    /// Apply a [`WrappingObjectStore`] to both `inner` and `paginated_lister` together.
831    ///
832    /// Keeps both halves in sync: a wrapper returning `None` from
833    /// [`WrappingObjectStore::wrap_paginated`] clears the lister so that
834    /// [`Self::read_dir_page`] falls back through the (already-wrapped) `inner`.
835    pub fn apply_wrapper(&mut self, wrapper: &dyn WrappingObjectStore) {
836        self.inner = wrapper.wrap(&self.store_prefix, self.inner.clone());
837        self.paginated_lister = self
838            .paginated_lister
839            .take()
840            .and_then(|lister| wrapper.wrap_paginated(&self.store_prefix, lister));
841    }
842
843    /// Open a file for path.
844    ///
845    /// Parameters
846    /// - ``path``: Absolute path to the file.
847    pub async fn open(&self, path: &Path) -> Result<Box<dyn Reader>> {
848        match self.scheme.as_str() {
849            "file" if self.has_direct_local_paths() => {
850                LocalObjectReader::open_with_tracker(
851                    path,
852                    self.block_size,
853                    None,
854                    Arc::new(self.io_tracker.clone()),
855                )
856                .await
857            }
858            #[cfg(target_os = "linux")]
859            "file+uring" => {
860                // Check if current-thread mode enabled
861                let use_current_thread = std::env::var("LANCE_URING_CURRENT_THREAD")
862                    .map(|v| str_is_truthy(&v))
863                    .unwrap_or(false);
864
865                if use_current_thread {
866                    UringCurrentThreadReader::open(
867                        path,
868                        self.block_size,
869                        None,
870                        Arc::new(self.io_tracker.clone()),
871                    )
872                    .await
873                } else {
874                    UringReader::open(
875                        path,
876                        self.block_size,
877                        None,
878                        Arc::new(self.io_tracker.clone()),
879                    )
880                    .await
881                }
882            }
883            _ => Ok(Box::new(
884                CloudObjectReader::new(
885                    self.inner.clone(),
886                    path.clone(),
887                    self.block_size,
888                    None,
889                    self.download_retry_count,
890                )?
891                .with_io_parallelism(self.io_parallelism()),
892            )),
893        }
894    }
895
896    /// Open a reader for a file with known size.
897    ///
898    /// This size may either have been retrieved from a list operation or
899    /// cached metadata. By passing in the known size, we can skip a HEAD / metadata
900    /// call.
901    pub async fn open_with_size(&self, path: &Path, known_size: usize) -> Result<Box<dyn Reader>> {
902        // If we know the file is really small, we can read the whole thing
903        // as a single request.
904        if known_size <= self.block_size {
905            return Ok(Box::new(SmallReader::new(
906                self.inner.clone(),
907                path.clone(),
908                self.download_retry_count,
909                known_size,
910            )));
911        }
912
913        match self.scheme.as_str() {
914            "file" if self.has_direct_local_paths() => {
915                LocalObjectReader::open_with_tracker(
916                    path,
917                    self.block_size,
918                    Some(known_size),
919                    Arc::new(self.io_tracker.clone()),
920                )
921                .await
922            }
923            #[cfg(target_os = "linux")]
924            "file+uring" => {
925                // Check if current-thread mode enabled
926                let use_current_thread = std::env::var("LANCE_URING_CURRENT_THREAD")
927                    .map(|v| str_is_truthy(&v))
928                    .unwrap_or(false);
929
930                if use_current_thread {
931                    UringCurrentThreadReader::open(
932                        path,
933                        self.block_size,
934                        Some(known_size),
935                        Arc::new(self.io_tracker.clone()),
936                    )
937                    .await
938                } else {
939                    UringReader::open(
940                        path,
941                        self.block_size,
942                        Some(known_size),
943                        Arc::new(self.io_tracker.clone()),
944                    )
945                    .await
946                }
947            }
948            _ => Ok(Box::new(
949                CloudObjectReader::new(
950                    self.inner.clone(),
951                    path.clone(),
952                    self.block_size,
953                    Some(known_size),
954                    self.download_retry_count,
955                )?
956                .with_io_parallelism(self.io_parallelism()),
957            )),
958        }
959    }
960
961    /// Create an [ObjectWriter] from local [std::path::Path]
962    pub async fn create_local_writer(path: &std::path::Path) -> Result<ObjectWriter> {
963        let object_store = Self::local();
964        let absolute_path = expand_path(path.to_string_lossy())?;
965        let os_path = Path::from_absolute_path(absolute_path)?;
966        ObjectWriter::new(&object_store, &os_path).await
967    }
968
969    /// Open an [Reader] from local [std::path::Path]
970    pub async fn open_local(path: &std::path::Path) -> Result<Box<dyn Reader>> {
971        let object_store = Self::local();
972        let absolute_path = expand_path(path.to_string_lossy())?;
973        let os_path = Path::from_absolute_path(absolute_path)?;
974        object_store.open(&os_path).await
975    }
976
977    /// Create a new file.
978    pub async fn create(&self, path: &Path) -> Result<Box<dyn Writer>> {
979        match self.scheme.as_str() {
980            "file" if self.has_direct_local_paths() => {
981                let local_path = super::local::to_local_path(path);
982                let local_path = std::path::PathBuf::from(&local_path);
983                if let Some(parent) = local_path.parent() {
984                    tokio::fs::create_dir_all(parent).await?;
985                }
986                let parent = local_path
987                    .parent()
988                    .expect("file path must have parent")
989                    .to_owned();
990                let named_temp = tokio::task::spawn_blocking(move || {
991                    #[cfg(unix)]
992                    {
993                        // NamedTempFile defaults to 0o600. Use ordinary file creation permissions so the published file honors the caller's umask.
994                        tempfile::Builder::new()
995                            .permissions(std::fs::Permissions::from_mode(0o666))
996                            .tempfile_in(parent)
997                    }
998                    #[cfg(not(unix))]
999                    tempfile::NamedTempFile::new_in(parent)
1000                })
1001                .await
1002                .map_err(|e| Error::io(format!("spawn_blocking failed: {}", e)))??;
1003                let (std_file, temp_path) = named_temp.into_parts();
1004                let file = tokio::fs::File::from_std(std_file);
1005                Ok(Box::new(LocalWriter::new(
1006                    file,
1007                    path.clone(),
1008                    temp_path,
1009                    Arc::new(self.io_tracker.clone()),
1010                )))
1011            }
1012            _ => Ok(Box::new(ObjectWriter::new(self, path).await?)),
1013        }
1014    }
1015
1016    /// A helper function to create a file and write content to it.
1017    pub async fn put(&self, path: &Path, content: &[u8]) -> Result<WriteResult> {
1018        let mut writer = self.create(path).await?;
1019        writer.write_all(content).await?;
1020        Writer::shutdown(writer.as_mut()).await
1021    }
1022
1023    /// Atomically creates an object without replacing an existing object.
1024    ///
1025    /// Local stores publish a uniquely named staging object with a conditional
1026    /// rename. Other stores use their conditional create operation. Tencent COS
1027    /// is rejected because it can silently ignore conditional create requests.
1028    ///
1029    /// Returns [`object_store::Error::NotSupported`] without writing when the
1030    /// backend cannot reliably provide put-if-absent semantics.
1031    pub async fn put_if_absent(
1032        &self,
1033        path: &Path,
1034        content: PutPayload,
1035    ) -> object_store::Result<()> {
1036        if self.scheme == "cos" {
1037            return Err(object_store::Error::NotSupported {
1038                source: "Tencent COS does not reliably enforce put-if-absent after bucket \
1039                         versioning has ever been enabled"
1040                    .into(),
1041            });
1042        }
1043
1044        if self.is_local() {
1045            let staging_path =
1046                Path::from(format!("{}.tmp.{}", path, uuid::Uuid::new_v4().simple()));
1047            self.inner.put(&staging_path, content).await?;
1048            let result = self.inner.rename_if_not_exists(&staging_path, path).await;
1049            if result.is_err()
1050                && let Err(error) = self.inner.delete(&staging_path).await
1051            {
1052                log::warn!(
1053                    "Failed to remove staging object {} after atomic create failed: {}",
1054                    staging_path,
1055                    error
1056                );
1057            }
1058            result
1059        } else {
1060            self.inner
1061                .put_opts(
1062                    path,
1063                    content,
1064                    PutOptions {
1065                        mode: PutMode::Create,
1066                        ..Default::default()
1067                    },
1068                )
1069                .await
1070                .map(|_| ())
1071        }
1072    }
1073
1074    pub async fn delete(&self, path: &Path) -> Result<()> {
1075        self.inner.delete(path).await?;
1076        Ok(())
1077    }
1078
1079    /// AWS S3 and GCS reject a single-shot server-side copy whose source is
1080    /// larger than this; such sources are streamed through a multipart write.
1081    const MAX_SINGLE_COPY_BYTES: u64 = 5 * 1024 * 1024 * 1024; // 5 GiB
1082
1083    pub async fn copy(&self, from: &Path, to: &Path) -> Result<()> {
1084        // S3 and GCS cap single-shot server-side copies at 5 GiB and object_store
1085        // does not fall back to a multipart copy for larger sources
1086        // (https://github.com/apache/arrow-rs-object-store/issues/563). Azure and
1087        // other blob stores don't have this limit, so we only pay for the fallback
1088        // (an extra size lookup) on S3 and GCS.
1089        let multipart_copy_fallback = matches!(self.scheme.as_str(), "s3" | "s3+ddb" | "gs");
1090        self.copy_impl(
1091            from,
1092            to,
1093            multipart_copy_fallback,
1094            Self::MAX_SINGLE_COPY_BYTES,
1095        )
1096        .await
1097    }
1098
1099    /// Copy an object using the policy for bulk file movement.
1100    ///
1101    /// Streaming is the default because it works across object stores and does
1102    /// not require provider-native copy support. Setting
1103    /// `LANCE_IO_SERVER_SIDE_COPY_ENABLED` to a truthy value opts same-store
1104    /// copies into [`Self::copy`]. Cross-store and local copies continue to use
1105    /// [`Self::copy_via_stream`].
1106    ///
1107    /// ```no_run
1108    /// # use lance_core::Result;
1109    /// # use lance_io::object_store::ObjectStore;
1110    /// # use object_store::path::Path;
1111    /// # async fn copy(source: &ObjectStore, destination: &ObjectStore) -> Result<()> {
1112    /// source
1113    ///     .copy_bulk(
1114    ///         &Path::from("staging/index.lance"),
1115    ///         destination,
1116    ///         &Path::from("index.lance"),
1117    ///     )
1118    ///     .await?;
1119    /// # Ok(())
1120    /// # }
1121    /// ```
1122    pub async fn copy_bulk(
1123        &self,
1124        source_path: &Path,
1125        destination_store: &Self,
1126        destination_path: &Path,
1127    ) -> Result<WriteResult> {
1128        self.copy_bulk_with_server_side_copy(
1129            source_path,
1130            destination_store,
1131            destination_path,
1132            self.uses_server_side_copy(destination_store),
1133        )
1134        .await
1135    }
1136
1137    fn uses_server_side_copy(&self, destination_store: &Self) -> bool {
1138        parse_env_as_bool(SERVER_SIDE_COPY_ENABLED_ENV, false)
1139            && self.can_server_side_copy_to(destination_store)
1140    }
1141
1142    async fn copy_bulk_with_server_side_copy(
1143        &self,
1144        source_path: &Path,
1145        destination_store: &Self,
1146        destination_path: &Path,
1147        server_side_copy_enabled: bool,
1148    ) -> Result<WriteResult> {
1149        if !server_side_copy_enabled || !self.can_server_side_copy_to(destination_store) {
1150            return self
1151                .copy_via_stream(source_path, destination_store, destination_path)
1152                .await;
1153        }
1154
1155        let source_size = self.size(source_path).await?;
1156        let result_size = usize::try_from(source_size).map_err(|source| {
1157            Error::io(format!(
1158                "server-side copy source size conversion failed from {source_path} to \
1159                 {destination_path}: source_size={source_size}, error={source}"
1160            ))
1161        })?;
1162        destination_store
1163            .copy(source_path, destination_path)
1164            .await?;
1165        let destination_size = destination_store.size(destination_path).await?;
1166        if destination_size != source_size {
1167            return Err(Error::io(format!(
1168                "server-side copy destination size mismatch from {source_path} to \
1169                 {destination_path}: source_size={source_size}, \
1170                 destination_size={destination_size}"
1171            )));
1172        }
1173
1174        Ok(WriteResult {
1175            size: result_size,
1176            e_tag: None,
1177        })
1178    }
1179
1180    fn can_server_side_copy_to(&self, destination_store: &Self) -> bool {
1181        // Prefixes can collide across endpoints or wrappers, where native copy could
1182        // read or write the wrong backend. Exact client identity is required.
1183        self.is_cloud()
1184            && destination_store.is_cloud()
1185            && Arc::ptr_eq(&self.inner, &destination_store.inner)
1186    }
1187
1188    /// Copy an object by streaming its bytes through Lance's multipart-aware writer.
1189    ///
1190    /// Unlike [`Self::copy`], this never delegates to a provider-native server-side
1191    /// copy. The source and destination may use different object stores. The copy
1192    /// succeeds only after the byte count reported by the writer and a destination
1193    /// metadata lookup both match the source size.
1194    ///
1195    /// ```no_run
1196    /// # use lance_core::Result;
1197    /// # use lance_io::object_store::ObjectStore;
1198    /// # use object_store::path::Path;
1199    /// # async fn copy(source: &ObjectStore, destination: &ObjectStore) -> Result<()> {
1200    /// source
1201    ///     .copy_via_stream(
1202    ///         &Path::from("staging/index.lance"),
1203    ///         destination,
1204    ///         &Path::from("index.lance"),
1205    ///     )
1206    ///     .await?;
1207    /// # Ok(())
1208    /// # }
1209    /// ```
1210    #[instrument(
1211        name = "multipart_stream_copy",
1212        level = "info",
1213        skip(self, source_path, destination_store, destination_path),
1214        fields(
1215            source = %source_path,
1216            destination = %destination_path,
1217            source_size = Empty,
1218            read_chunk_size = Empty,
1219            multipart_part_size = crate::object_writer::initial_upload_size(),
1220            multipart_concurrency = crate::object_writer::max_upload_parallelism(),
1221            part_count = Empty,
1222            bytes_transferred = Empty,
1223            destination_size = Empty,
1224            validation = Empty,
1225            elapsed_ms = Empty,
1226        ),
1227        err
1228    )]
1229    pub async fn copy_via_stream(
1230        &self,
1231        source_path: &Path,
1232        destination_store: &Self,
1233        destination_path: &Path,
1234    ) -> Result<WriteResult> {
1235        let started_at = Instant::now();
1236        if self.has_direct_local_paths() && destination_store.has_direct_local_paths() {
1237            let source_size = std::fs::metadata(super::local::to_local_path(source_path))
1238                .map_err(|source| {
1239                    let source = if source.kind() == std::io::ErrorKind::NotFound {
1240                        Error::not_found(source_path.to_string())
1241                    } else {
1242                        Error::from(source)
1243                    };
1244                    stream_copy_error("source metadata", source_path, destination_path, source)
1245                })?
1246                .len();
1247            let source_size = usize::try_from(source_size).map_err(|source| {
1248                stream_copy_error(
1249                    "source size conversion",
1250                    source_path,
1251                    destination_path,
1252                    source,
1253                )
1254            })?;
1255            Span::current().record("source_size", source_size as u64);
1256
1257            let metrics = destination_store.io_tracker.begin_io("copy");
1258            let result = super::local::copy_file(source_path, destination_path);
1259            metrics.record(&result, source_size as u64);
1260            result.map_err(|source| {
1261                stream_copy_error(
1262                    "local filesystem copy",
1263                    source_path,
1264                    destination_path,
1265                    source,
1266                )
1267            })?;
1268
1269            let destination_size =
1270                destination_store
1271                    .size(destination_path)
1272                    .await
1273                    .map_err(|source| {
1274                        stream_copy_error(
1275                            "destination validation",
1276                            source_path,
1277                            destination_path,
1278                            source,
1279                        )
1280                    })?;
1281            Span::current().record("bytes_transferred", source_size as u64);
1282            Span::current().record("destination_size", destination_size);
1283            if destination_size != source_size as u64 {
1284                Span::current().record("validation", "failed");
1285                return Err(Error::io(format!(
1286                    "multipart_stream_copy destination size mismatch from {source_path} to \
1287                     {destination_path}: source_size={source_size}, \
1288                     destination_size={destination_size}"
1289                )));
1290            }
1291
1292            Span::current().record("validation", "passed");
1293            Span::current().record("elapsed_ms", started_at.elapsed().as_millis() as u64);
1294            return Ok(WriteResult {
1295                size: source_size,
1296                e_tag: None,
1297            });
1298        }
1299
1300        let reader = self.open(source_path).await.map_err(|source| {
1301            stream_copy_error("source open", source_path, destination_path, source)
1302        })?;
1303        let source_size = reader.size().await.map_err(|source| {
1304            stream_copy_error("source metadata", source_path, destination_path, source)
1305        })?;
1306        Span::current().record("source_size", source_size as u64);
1307
1308        let mut writer = destination_store
1309            .create(destination_path)
1310            .await
1311            .map_err(|source| {
1312                stream_copy_error(
1313                    "destination writer creation",
1314                    source_path,
1315                    destination_path,
1316                    source,
1317                )
1318            })?;
1319        let read_chunk_size = usize::try_from(self.max_iop_size())
1320            .unwrap_or(usize::MAX)
1321            .max(1);
1322        Span::current().record("read_chunk_size", read_chunk_size as u64);
1323        let mut bytes_transferred = 0usize;
1324        if source_size > 0 {
1325            let first_range = 0..read_chunk_size.min(source_size);
1326            let mut current_range = first_range.clone();
1327            let mut current_bytes = reader.get_range(first_range).await.map_err(|source| {
1328                stream_copy_error("source read", source_path, destination_path, source)
1329            })?;
1330
1331            loop {
1332                let expected_bytes = current_range.len();
1333                if current_bytes.len() != expected_bytes {
1334                    Span::current().record("validation", "failed");
1335                    return Err(Error::io(format!(
1336                        "multipart_stream_copy source range size mismatch from {source_path} to \
1337                         {destination_path}: range={current_range:?}, \
1338                         expected_bytes={expected_bytes}, actual_bytes={}",
1339                        current_bytes.len()
1340                    )));
1341                }
1342                bytes_transferred = bytes_transferred
1343                    .checked_add(current_bytes.len())
1344                    .ok_or_else(|| {
1345                        Error::io(format!(
1346                            "multipart_stream_copy byte count overflow from {source_path} to \
1347                             {destination_path}"
1348                        ))
1349                    })?;
1350
1351                if bytes_transferred == source_size {
1352                    writer.write_all(&current_bytes).await.map_err(|source| {
1353                        stream_copy_error(
1354                            "destination write",
1355                            source_path,
1356                            destination_path,
1357                            source,
1358                        )
1359                    })?;
1360                    break;
1361                }
1362
1363                let range_end = bytes_transferred
1364                    .checked_add(read_chunk_size)
1365                    .unwrap_or(source_size)
1366                    .min(source_size);
1367                let next_range = bytes_transferred..range_end;
1368                let next_read = reader.get_range(next_range.clone());
1369                let (write_result, next_bytes) =
1370                    tokio::join!(writer.write_all(&current_bytes), next_read);
1371                write_result.map_err(|source| {
1372                    stream_copy_error("destination write", source_path, destination_path, source)
1373                })?;
1374                current_bytes = next_bytes.map_err(|source| {
1375                    stream_copy_error("source read", source_path, destination_path, source)
1376                })?;
1377                current_range = next_range;
1378            }
1379        }
1380        Span::current().record("bytes_transferred", bytes_transferred as u64);
1381
1382        let write_result = Writer::shutdown(writer.as_mut()).await.map_err(|source| {
1383            stream_copy_error(
1384                "destination completion",
1385                source_path,
1386                destination_path,
1387                source,
1388            )
1389        })?;
1390        if write_result.size != source_size {
1391            Span::current().record("validation", "failed");
1392            return Err(Error::io(format!(
1393                "multipart_stream_copy writer size mismatch from {source_path} to \
1394                 {destination_path}: source_size={source_size}, \
1395                 writer_size={}",
1396                write_result.size
1397            )));
1398        }
1399
1400        let destination_size =
1401            destination_store
1402                .size(destination_path)
1403                .await
1404                .map_err(|source| {
1405                    stream_copy_error(
1406                        "destination validation",
1407                        source_path,
1408                        destination_path,
1409                        source,
1410                    )
1411                })?;
1412        Span::current().record("destination_size", destination_size);
1413        if destination_size != source_size as u64 {
1414            Span::current().record("validation", "failed");
1415            return Err(Error::io(format!(
1416                "multipart_stream_copy destination size mismatch from {source_path} to \
1417                 {destination_path}: source_size={source_size}, \
1418                 destination_size={destination_size}"
1419            )));
1420        }
1421
1422        Span::current().record("validation", "passed");
1423        Span::current().record("elapsed_ms", started_at.elapsed().as_millis() as u64);
1424        Ok(write_result)
1425    }
1426
1427    /// Copy `from` to `to`. When `multipart_copy_fallback` is set, a source
1428    /// larger than `max_single_copy` is streamed through a multipart write
1429    /// instead of a single-shot server-side copy. Both are parameters so tests
1430    /// can drive the streaming path without a multi-gigabyte fixture or an S3
1431    /// endpoint.
1432    async fn copy_impl(
1433        &self,
1434        from: &Path,
1435        to: &Path,
1436        multipart_copy_fallback: bool,
1437        max_single_copy: u64,
1438    ) -> Result<()> {
1439        if self.has_direct_local_paths() {
1440            // Use std::fs::copy for local filesystem to support cross-filesystem copies
1441            let metrics = self.io_tracker.begin_io("copy");
1442            let result = super::local::copy_file(from, to);
1443            metrics.record(&result, 0);
1444            return result;
1445        }
1446        if multipart_copy_fallback {
1447            // Reuse the reader for both the size lookup (a single cached HEAD)
1448            // and the streamed copy, avoiding a separate HEAD request.
1449            let reader = self.open(from).await?;
1450            if reader.size().await? as u64 > max_single_copy {
1451                let mut writer = self.create(to).await?;
1452                writer.copy_from_reader(reader.as_ref()).await?;
1453                Writer::shutdown(writer.as_mut()).await?;
1454                return Ok(());
1455            }
1456        }
1457        Ok(self.inner.copy(from, to).await?)
1458    }
1459
1460    /// Read a directory (start from base directory) and returns all sub-paths in the directory.
1461    ///
1462    /// This enumerates the whole prefix before it returns, however many children it holds.
1463    /// Use [`Self::read_dir_page`] to page through a directory instead.
1464    pub async fn read_dir(&self, dir_path: impl Into<Path>) -> Result<Vec<String>> {
1465        let path = dir_path.into();
1466        let path = Path::parse(&path)?;
1467        let output = self.inner.list_with_delimiter(Some(&path)).await?;
1468        Ok(output
1469            .common_prefixes
1470            .iter()
1471            .chain(output.objects.iter().map(|o| &o.location))
1472            .filter_map(|s| s.filename().map(|f| f.to_string()))
1473            .collect())
1474    }
1475
1476    /// Non-recursive, path-segment delimited list of a single directory level.
1477    ///
1478    /// Unlike [`Self::list`], which recurses into the entire subtree, this returns
1479    /// only the immediate children of `prefix`: the child "directories" as
1480    /// [`ListResult::common_prefixes`] and the direct child files as
1481    /// [`ListResult::objects`].
1482    pub async fn list_with_delimiter(&self, prefix: Option<&Path>) -> Result<ListResult> {
1483        Ok(self.inner.list_with_delimiter(prefix).await?)
1484    }
1485
1486    pub fn list(
1487        &self,
1488        path: Option<Path>,
1489    ) -> Pin<Box<dyn Stream<Item = Result<ObjectMeta>> + Send>> {
1490        Box::pin(ListRetryStream::new(self.inner.clone(), path, 5).map(|m| m.map_err(|e| e.into())))
1491    }
1492
1493    /// Read all files (start from base directory) recursively
1494    ///
1495    /// unmodified_since can be specified to only return files that have not been modified since the given time.
1496    pub fn read_dir_all<'a, 'b>(
1497        &'a self,
1498        dir_path: impl Into<&'b Path> + Send,
1499        unmodified_since: Option<DateTime<Utc>>,
1500    ) -> BoxStream<'a, Result<ObjectMeta>> {
1501        self.inner.read_dir_all(dir_path, unmodified_since)
1502    }
1503
1504    /// Remove a directory recursively.
1505    pub async fn remove_dir_all(&self, dir_path: impl Into<Path>) -> Result<()> {
1506        let path = dir_path.into();
1507        let path = Path::parse(&path)?;
1508
1509        if let Some(local_dir_operations) = &self.local_dir_operations {
1510            let metrics = self.io_tracker.begin_io("delete");
1511            let result = local_dir_operations.remove_dir_all(&path).await;
1512            metrics.record(&result, 0);
1513            return result;
1514        }
1515        if self.has_direct_local_paths() {
1516            // The local file system provider needs to delete both files and directories.
1517            // Counted as a single delete request, matching how `delete_stream`
1518            // counts one batched request regardless of how many paths it removes.
1519            let metrics = self.io_tracker.begin_io("delete");
1520            let result = super::local::remove_dir_all(&path);
1521            metrics.record(&result, 0);
1522            return result;
1523        }
1524        let sub_entries = self
1525            .inner
1526            .list(Some(&path))
1527            .map(|m| m.map(|meta| meta.location))
1528            .boxed();
1529        self.inner
1530            .delete_stream(sub_entries)
1531            .try_collect::<Vec<_>>()
1532            .await?;
1533        if self.scheme == "file-object-store" {
1534            // file-object-store tries to do everything as similarly as possible to the remote
1535            // object stores. But we still have to delete the directory entries afterwards.
1536            return super::local::remove_dir_all(&path);
1537        }
1538        Ok(())
1539    }
1540
1541    /// Remove eligible materialized empty directories below a local root.
1542    ///
1543    /// This is a no-op for object stores, which do not materialize directories.
1544    /// Traversal does not follow symbolic links. Directories in `retained_dirs` and their
1545    /// descendants are preserved. Other directories are removed only if they are empty and
1546    /// either appear in `verified_dirs` or predate `unmodified_since`. Passing `None` for
1547    /// `unmodified_since` disables the age check.
1548    ///
1549    /// ```
1550    /// # use std::collections::HashSet;
1551    /// # use chrono::Utc;
1552    /// # use lance_core::Result;
1553    /// # use lance_io::object_store::ObjectStore;
1554    /// # async fn remove_stale_index_dirs(store: &ObjectStore) -> Result<()> {
1555    /// store
1556    ///     .remove_empty_dirs(
1557    ///         "dataset/_indices",
1558    ///         HashSet::new(),
1559    ///         HashSet::new(),
1560    ///         Some(Utc::now()),
1561    ///     )
1562    ///     .await?;
1563    /// # Ok(())
1564    /// # }
1565    /// ```
1566    pub async fn remove_empty_dirs(
1567        &self,
1568        root_path: impl Into<Path>,
1569        retained_dirs: HashSet<Path>,
1570        verified_dirs: HashSet<Path>,
1571        unmodified_since: Option<DateTime<Utc>>,
1572    ) -> Result<()> {
1573        if !self.has_direct_local_paths() && self.scheme != "file-object-store" {
1574            return Ok(());
1575        }
1576
1577        let path = Path::parse(root_path.into())?;
1578        let metrics = self.io_tracker.begin_io("delete");
1579        let result = tokio::task::spawn_blocking(move || {
1580            super::local::remove_empty_dirs(&path, &retained_dirs, &verified_dirs, unmodified_since)
1581        })
1582        .await
1583        .map_err(|error| Error::io(format!("empty-directory cleanup task failed: {error}")))?;
1584        metrics.record(&result, 0);
1585        result
1586    }
1587
1588    pub fn remove_stream<'a>(
1589        &'a self,
1590        locations: BoxStream<'a, Result<Path>>,
1591    ) -> BoxStream<'a, Result<Path>> {
1592        let store = Arc::clone(&self.inner);
1593        locations
1594            .and_then(move |location| {
1595                let store = Arc::clone(&store);
1596                async move {
1597                    store.delete(&location).await?;
1598                    Ok(location)
1599                }
1600            })
1601            .boxed()
1602    }
1603
1604    /// Check a file exists.
1605    pub async fn exists(&self, path: &Path) -> Result<bool> {
1606        match self.inner.head(path).await {
1607            Ok(_) => Ok(true),
1608            Err(object_store::Error::NotFound { path: _, source: _ }) => Ok(false),
1609            Err(e) => Err(e.into()),
1610        }
1611    }
1612
1613    /// Get file size.
1614    pub async fn size(&self, path: &Path) -> Result<u64> {
1615        Ok(self.inner.head(path).await?.size)
1616    }
1617
1618    /// Convenience function to open a reader and read all the bytes
1619    pub async fn read_one_all(&self, path: &Path) -> Result<Bytes> {
1620        let reader = self.open(path).await?;
1621        Ok(reader.get_all().await?)
1622    }
1623
1624    /// Convenience function open a reader and make a single request
1625    ///
1626    /// If you will be making multiple requests to the path it is more efficient to call [`Self::open`]
1627    /// and then call [`Reader::get_range`] multiple times.
1628    pub async fn read_one_range(&self, path: &Path, range: Range<usize>) -> Result<Bytes> {
1629        let reader = self.open(path).await?;
1630        Ok(reader.get_range(range).await?)
1631    }
1632}
1633
1634/// Options that can be set for multiple object stores
1635#[derive(PartialEq, Eq, Hash, Clone, Debug, Copy)]
1636pub enum LanceConfigKey {
1637    /// Number of times to retry a download that fails
1638    DownloadRetryCount,
1639}
1640
1641impl FromStr for LanceConfigKey {
1642    type Err = Error;
1643
1644    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
1645        match s.to_ascii_lowercase().as_str() {
1646            "download_retry_count" => Ok(Self::DownloadRetryCount),
1647            _ => Err(Error::invalid_input_source(
1648                format!("Invalid LanceConfigKey: {}", s).into(),
1649            )),
1650        }
1651    }
1652}
1653
1654#[derive(Clone, Debug, Default)]
1655pub struct StorageOptions(pub HashMap<String, String>);
1656
1657impl StorageOptions {
1658    /// Create a new instance of [`StorageOptions`]
1659    pub fn new(options: HashMap<String, String>) -> Self {
1660        let mut options = options;
1661        if let Ok(value) = std::env::var("AZURE_STORAGE_ALLOW_HTTP") {
1662            options.insert("allow_http".into(), value);
1663        }
1664        if let Ok(value) = std::env::var("AZURE_STORAGE_USE_HTTP") {
1665            options.insert("allow_http".into(), value);
1666        }
1667        if let Ok(value) = std::env::var("AWS_ALLOW_HTTP") {
1668            options.insert("allow_http".into(), value);
1669        }
1670        if let Ok(value) = std::env::var("OBJECT_STORE_CLIENT_MAX_RETRIES") {
1671            options.insert("client_max_retries".into(), value);
1672        }
1673        if let Ok(value) = std::env::var("OBJECT_STORE_CLIENT_RETRY_TIMEOUT") {
1674            options.insert("client_retry_timeout".into(), value);
1675        }
1676        Self(options)
1677    }
1678
1679    /// Denotes if unsecure connections via http are allowed
1680    pub fn allow_http(&self) -> bool {
1681        self.0.iter().any(|(key, value)| {
1682            key.to_ascii_lowercase().contains("allow_http") & str_is_truthy(value)
1683        })
1684    }
1685
1686    /// Number of times to retry a download that fails
1687    pub fn download_retry_count(&self) -> usize {
1688        self.0
1689            .iter()
1690            .find(|(key, _)| key.eq_ignore_ascii_case("download_retry_count"))
1691            .map(|(_, value)| value.parse::<usize>().unwrap_or(3))
1692            .unwrap_or(3)
1693    }
1694
1695    /// Max retry times to set in RetryConfig for object store client
1696    pub fn client_max_retries(&self) -> usize {
1697        self.0
1698            .iter()
1699            .find(|(key, _)| key.eq_ignore_ascii_case("client_max_retries"))
1700            .and_then(|(_, value)| value.parse::<usize>().ok())
1701            .unwrap_or(3)
1702    }
1703
1704    /// Seconds of timeout to set in RetryConfig for object store client
1705    pub fn client_retry_timeout(&self) -> u64 {
1706        self.0
1707            .iter()
1708            .find(|(key, _)| key.eq_ignore_ascii_case("client_retry_timeout"))
1709            .and_then(|(_, value)| value.parse::<u64>().ok())
1710            .unwrap_or(180)
1711    }
1712
1713    pub fn get(&self, key: &str) -> Option<&String> {
1714        self.0.get(key)
1715    }
1716
1717    /// Build [`ClientOptions`] with default headers extracted from `headers.*` keys.
1718    ///
1719    /// Keys prefixed with `headers.` are parsed into HTTP headers. For example,
1720    /// `headers.x-ms-version = 2023-11-03` results in a default header
1721    /// `x-ms-version: 2023-11-03`.
1722    ///
1723    /// Returns an error if any `headers.*` key has an invalid header name or value.
1724    #[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
1725    pub fn client_options(&self) -> Result<ClientOptions> {
1726        let mut headers = HeaderMap::new();
1727        for (key, value) in &self.0 {
1728            if let Some(header_name) = key.strip_prefix("headers.") {
1729                let name = header_name
1730                    .parse::<http::header::HeaderName>()
1731                    .map_err(|e| {
1732                        Error::invalid_input(format!("invalid header name '{header_name}': {e}"))
1733                    })?;
1734                let val = HeaderValue::from_str(value).map_err(|e| {
1735                    Error::invalid_input(format!("invalid header value for '{header_name}': {e}"))
1736                })?;
1737                headers.insert(name, val);
1738            }
1739        }
1740        let mut client_options = ClientOptions::default();
1741        if !headers.is_empty() {
1742            client_options = client_options.with_default_headers(headers);
1743        }
1744        Ok(client_options)
1745    }
1746
1747    /// Get the expiration time in milliseconds since epoch, if present
1748    pub fn expires_at_millis(&self) -> Option<u64> {
1749        self.0
1750            .get(EXPIRES_AT_MILLIS_KEY)
1751            .and_then(|s| s.parse::<u64>().ok())
1752    }
1753}
1754
1755impl From<HashMap<String, String>> for StorageOptions {
1756    fn from(value: HashMap<String, String>) -> Self {
1757        Self::new(value)
1758    }
1759}
1760
1761static DEFAULT_OBJECT_STORE_REGISTRY: std::sync::LazyLock<ObjectStoreRegistry> =
1762    std::sync::LazyLock::new(ObjectStoreRegistry::default);
1763
1764impl ObjectStore {
1765    #[allow(clippy::too_many_arguments)]
1766    pub fn new(
1767        mut store: Arc<DynObjectStore>,
1768        location: Url,
1769        block_size: Option<usize>,
1770        wrapper: Option<Arc<dyn WrappingObjectStore>>,
1771        use_constant_size_upload_parts: bool,
1772        list_is_lexically_ordered: bool,
1773        io_parallelism: usize,
1774        download_retry_count: usize,
1775        storage_options: Option<&HashMap<String, String>>,
1776    ) -> Self {
1777        let scheme = location.scheme();
1778        let block_size = block_size.unwrap_or_else(|| infer_block_size(scheme));
1779        let store_prefix = match DEFAULT_OBJECT_STORE_REGISTRY.get_provider(scheme) {
1780            Some(provider) => provider
1781                .calculate_object_store_prefix(&location, storage_options)
1782                .unwrap(),
1783            None => {
1784                let store_prefix = format!("{}${}", location.scheme(), location.authority());
1785                log::warn!(
1786                    "Guessing that object store prefix is {}, since object store scheme is not found in registry.",
1787                    store_prefix
1788                );
1789                store_prefix
1790            }
1791        };
1792        let mut io_tracker = IOTracker::default();
1793        meter_store(&mut store, &mut io_tracker, &store_prefix);
1794
1795        let store = match wrapper {
1796            Some(wrapper) => wrapper.wrap(&store_prefix, store),
1797            None => store,
1798        };
1799
1800        // Always wrap with IO tracking
1801        let tracked_store = io_tracker.wrap("", store);
1802
1803        Self {
1804            inner: tracked_store,
1805            local_dir_operations: None,
1806            scheme: scheme.into(),
1807            block_size,
1808            max_iop_size: *DEFAULT_MAX_IOP_SIZE,
1809            use_constant_size_upload_parts,
1810            list_is_lexically_ordered,
1811            io_parallelism,
1812            download_retry_count,
1813            io_tracker,
1814            store_prefix,
1815            // Type-erased on the way in, so there is no telling if it can paginate.
1816            paginated_lister: None,
1817        }
1818    }
1819}
1820
1821/// Wrap `inner` so its operations publish metrics labelled by `store_prefix`,
1822/// and label `io_tracker` with the same prefix so the local reads and writes
1823/// that bypass `inner` publish under it too.
1824///
1825/// The two go together on purpose: a store metered on one path but not the other
1826/// would report a partial picture that reads like a complete one. Every
1827/// constructor that hands an [`ObjectStore`] to a caller must route its `inner`
1828/// through here, or through nothing at all.
1829#[cfg(feature = "metrics")]
1830fn meter_store(inner: &mut Arc<dyn OSObjectStore>, io_tracker: &mut IOTracker, store_prefix: &str) {
1831    use crate::object_store::metrics::ObjectStoreMetricsExt;
1832    io_tracker.set_metrics_base(store_prefix);
1833    *inner = inner.clone().metered(store_prefix.to_owned());
1834}
1835
1836#[cfg(not(feature = "metrics"))]
1837fn meter_store(
1838    _inner: &mut Arc<dyn OSObjectStore>,
1839    _io_tracker: &mut IOTracker,
1840    _store_prefix: &str,
1841) {
1842}
1843
1844fn infer_block_size(scheme: &str) -> usize {
1845    // Block size: On local file systems, we use 4KB block size. On cloud
1846    // object stores, we use 64KB block size. This is generally the largest
1847    // block size where we don't see a latency penalty.
1848    match scheme {
1849        "file" => 4 * 1024,
1850        _ => 64 * 1024,
1851    }
1852}
1853
1854#[cfg(test)]
1855mod tests {
1856    use super::*;
1857    use async_trait::async_trait;
1858    use bytes::Bytes;
1859    use lance_core::utils::tempfile::{TempStdDir, TempStdFile, TempStrDir};
1860    use object_store::memory::InMemory;
1861    use object_store::{
1862        CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload, PutMultipartOptions,
1863        PutOptions, PutPayload, PutResult, Result as OSResult, UploadPart,
1864    };
1865    use rstest::rstest;
1866    use serial_test::serial;
1867    use std::env::set_current_dir;
1868    use std::fmt::{Display, Formatter};
1869    use std::fs::{create_dir_all, write};
1870    use std::ops::Range;
1871    use std::path::Path as StdPath;
1872    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1873
1874    /// Write test content to file.
1875    fn write_to_file(path_str: &str, contents: &str) -> std::io::Result<()> {
1876        let path = expand_path(path_str).map_err(std::io::Error::other)?;
1877        std::fs::create_dir_all(path.parent().unwrap())?;
1878        write(path, contents)
1879    }
1880
1881    async fn read_from_store(store: &ObjectStore, path: &Path) -> Result<String> {
1882        let test_file_store = store.open(path).await.unwrap();
1883        let size = test_file_store.size().await.unwrap();
1884        let bytes = test_file_store.get_range(0..size).await.unwrap();
1885        let contents = String::from_utf8(bytes.to_vec()).unwrap();
1886        Ok(contents)
1887    }
1888
1889    #[tokio::test]
1890    async fn test_put_if_absent() {
1891        let temp_dir = TempStrDir::default();
1892        let path = Path::from(format!("{}/atomic-create", temp_dir.as_str()));
1893        let store = ObjectStore::local();
1894        store
1895            .put_if_absent(&path, Bytes::from_static(b"first").into())
1896            .await
1897            .unwrap();
1898        let error = store
1899            .put_if_absent(&path, Bytes::from_static(b"second").into())
1900            .await
1901            .unwrap_err();
1902        assert!(matches!(
1903            error,
1904            object_store::Error::AlreadyExists { .. } | object_store::Error::Precondition { .. }
1905        ));
1906        assert_eq!(
1907            store.read_one_all(&path).await.unwrap(),
1908            b"first".as_slice()
1909        );
1910    }
1911
1912    #[tokio::test]
1913    async fn test_put_if_absent_rejects_cos() {
1914        let mut store = ObjectStore::memory();
1915        store.scheme = "cos".to_string();
1916        let path = Path::from("atomic-create");
1917
1918        let error = store
1919            .put_if_absent(&path, Bytes::from_static(b"value").into())
1920            .await
1921            .unwrap_err();
1922
1923        assert!(matches!(error, object_store::Error::NotSupported { .. }));
1924        assert!(!store.exists(&path).await.unwrap());
1925    }
1926
1927    #[tokio::test]
1928    async fn test_io_parallelism_clamped_to_nonzero() {
1929        // `io_parallelism()` feeds `buffered`/`buffer_unordered` windows; a value of 0 makes those
1930        // streams never poll, hanging callers (e.g. a metadata-only `count_rows`). It must clamp.
1931        let store = ObjectStore::local();
1932        // Readers opened by the store must advertise the store's normalized
1933        // effective parallelism, not the hardcoded cloud default.
1934        let mem_store = ObjectStore::memory();
1935        let path = Path::from("/io_parallelism_probe");
1936        mem_store.put(&path, b"x").await.unwrap();
1937
1938        // SAFETY: process-global env var, set and restored within this test. `io_parallelism()`
1939        // only reads it, and a concurrent reader observes a valid clamped value, never 0.
1940        unsafe { std::env::set_var("LANCE_IO_THREADS", "0") };
1941        assert_eq!(
1942            store.io_parallelism(),
1943            1,
1944            "LANCE_IO_THREADS=0 must clamp to 1"
1945        );
1946        assert_eq!(
1947            mem_store.open(&path).await.unwrap().io_parallelism(),
1948            1,
1949            "an opened reader must report the store's clamped parallelism"
1950        );
1951
1952        unsafe { std::env::set_var("LANCE_IO_THREADS", "8") };
1953        assert_eq!(
1954            store.io_parallelism(),
1955            8,
1956            "a positive override must pass through unchanged"
1957        );
1958        assert_eq!(
1959            mem_store.open(&path).await.unwrap().io_parallelism(),
1960            8,
1961            "an opened reader must honor the configured request limit"
1962        );
1963        assert_eq!(
1964            mem_store
1965                .open_with_size(&path, 1024 * 1024)
1966                .await
1967                .unwrap()
1968                .io_parallelism(),
1969            8,
1970            "a sized reader must honor the configured request limit"
1971        );
1972
1973        unsafe { std::env::remove_var("LANCE_IO_THREADS") };
1974        assert!(
1975            store.io_parallelism() >= 1,
1976            "the configured default parallelism must be at least 1"
1977        );
1978    }
1979
1980    #[tokio::test]
1981    async fn test_absolute_paths() {
1982        let tmp_path = TempStrDir::default();
1983        write_to_file(
1984            &format!("{tmp_path}/bar/foo.lance/test_file"),
1985            "TEST_CONTENT",
1986        )
1987        .unwrap();
1988
1989        // test a few variations of the same path
1990        for uri in &[
1991            format!("{tmp_path}/bar/foo.lance"),
1992            format!("{tmp_path}/./bar/foo.lance"),
1993            format!("{tmp_path}/bar/foo.lance/../foo.lance"),
1994        ] {
1995            let (store, path) = ObjectStore::from_uri(uri).await.unwrap();
1996            let contents = read_from_store(store.as_ref(), &path.clone().join("test_file"))
1997                .await
1998                .unwrap();
1999            assert_eq!(contents, "TEST_CONTENT");
2000        }
2001    }
2002
2003    #[tokio::test]
2004    async fn test_cloud_paths() {
2005        let uri = "s3://bucket/foo.lance";
2006        let (store, path) = ObjectStore::from_uri(uri).await.unwrap();
2007        assert_eq!(store.scheme, "s3");
2008        assert_eq!(path.to_string(), "foo.lance");
2009
2010        let (store, path) = ObjectStore::from_uri("s3+ddb://bucket/foo.lance")
2011            .await
2012            .unwrap();
2013        assert_eq!(store.scheme, "s3");
2014        assert_eq!(path.to_string(), "foo.lance");
2015
2016        let (store, path) = ObjectStore::from_uri("gs://bucket/foo.lance")
2017            .await
2018            .unwrap();
2019        assert_eq!(store.scheme, "gs");
2020        assert_eq!(path.to_string(), "foo.lance");
2021
2022        let (store, path) =
2023            ObjectStore::from_uri("abfss://filesystem@account.dfs.core.windows.net/foo.lance")
2024                .await
2025                .unwrap();
2026        assert_eq!(store.scheme, "abfss");
2027        assert_eq!(path.to_string(), "foo.lance");
2028    }
2029
2030    async fn test_block_size_used_test_helper(
2031        uri: &str,
2032        storage_options: Option<HashMap<String, String>>,
2033        default_expected_block_size: usize,
2034    ) {
2035        // Test the default
2036        let registry = Arc::new(ObjectStoreRegistry::default());
2037        let accessor = storage_options
2038            .clone()
2039            .map(|opts| Arc::new(StorageOptionsAccessor::with_static_options(opts)));
2040        let params = ObjectStoreParams {
2041            storage_options_accessor: accessor.clone(),
2042            ..ObjectStoreParams::default()
2043        };
2044        let (store, _) = ObjectStore::from_uri_and_params(registry, uri, &params)
2045            .await
2046            .unwrap();
2047        assert_eq!(store.block_size, default_expected_block_size);
2048
2049        // Ensure param is used
2050        let registry = Arc::new(ObjectStoreRegistry::default());
2051        let params = ObjectStoreParams {
2052            block_size: Some(1024),
2053            storage_options_accessor: accessor,
2054            ..ObjectStoreParams::default()
2055        };
2056        let (store, _) = ObjectStore::from_uri_and_params(registry, uri, &params)
2057            .await
2058            .unwrap();
2059        assert_eq!(store.block_size, 1024);
2060    }
2061
2062    #[rstest]
2063    #[case("s3://bucket/foo.lance", None)]
2064    #[case("gs://bucket/foo.lance", None)]
2065    #[case("az://account/bucket/foo.lance",
2066      Some(HashMap::from([
2067            (String::from("account_name"), String::from("account")),
2068            (String::from("container_name"), String::from("container"))
2069           ])))]
2070    #[case("abfss://filesystem@account.dfs.core.windows.net/foo.lance",
2071      Some(HashMap::from([
2072            (String::from("account_name"), String::from("account")),
2073            (String::from("container_name"), String::from("filesystem"))
2074           ])))]
2075    #[tokio::test]
2076    async fn test_block_size_used_cloud(
2077        #[case] uri: &str,
2078        #[case] storage_options: Option<HashMap<String, String>>,
2079    ) {
2080        test_block_size_used_test_helper(uri, storage_options, 64 * 1024).await;
2081    }
2082
2083    #[rstest]
2084    #[case("file")]
2085    #[case("file-object-store")]
2086    #[case("memory:///bucket/foo.lance")]
2087    #[tokio::test]
2088    async fn test_block_size_used_file(#[case] prefix: &str) {
2089        let tmp_path = TempStrDir::default();
2090        let path = format!("{tmp_path}/bar/foo.lance/test_file");
2091        write_to_file(&path, "URL").unwrap();
2092        let uri = format!("{prefix}:///{path}");
2093        test_block_size_used_test_helper(&uri, None, 4 * 1024).await;
2094    }
2095
2096    #[tokio::test]
2097    async fn test_relative_paths() {
2098        let tmp_path = TempStrDir::default();
2099        write_to_file(
2100            &format!("{tmp_path}/bar/foo.lance/test_file"),
2101            "RELATIVE_URL",
2102        )
2103        .unwrap();
2104
2105        set_current_dir(StdPath::new(tmp_path.as_ref())).expect("Error changing current dir");
2106        let (store, path) = ObjectStore::from_uri("./bar/foo.lance").await.unwrap();
2107
2108        let contents = read_from_store(store.as_ref(), &path.clone().join("test_file"))
2109            .await
2110            .unwrap();
2111        assert_eq!(contents, "RELATIVE_URL");
2112    }
2113
2114    #[tokio::test]
2115    async fn test_tilde_expansion() {
2116        let uri = "~/foo.lance";
2117        write_to_file(&format!("{uri}/test_file"), "TILDE").unwrap();
2118        let (store, path) = ObjectStore::from_uri(uri).await.unwrap();
2119        let contents = read_from_store(store.as_ref(), &path.clone().join("test_file"))
2120            .await
2121            .unwrap();
2122        assert_eq!(contents, "TILDE");
2123    }
2124
2125    #[tokio::test]
2126    async fn test_read_directory() {
2127        let path = TempStdDir::default();
2128        create_dir_all(path.join("foo").join("bar")).unwrap();
2129        create_dir_all(path.join("foo").join("zoo")).unwrap();
2130        create_dir_all(path.join("foo").join("zoo").join("abc")).unwrap();
2131        write_to_file(
2132            path.join("foo").join("test_file").to_str().unwrap(),
2133            "read_dir",
2134        )
2135        .unwrap();
2136        let (store, base) = ObjectStore::from_uri(path.to_str().unwrap()).await.unwrap();
2137
2138        let sub_dirs = store.read_dir(base.clone().join("foo")).await.unwrap();
2139        assert_eq!(sub_dirs, vec!["bar", "zoo", "test_file"]);
2140    }
2141
2142    #[tokio::test]
2143    async fn test_delete_directory_local_store() {
2144        test_delete_directory("").await;
2145    }
2146
2147    #[tokio::test]
2148    async fn test_delete_directory_file_object_store() {
2149        test_delete_directory("file-object-store").await;
2150    }
2151
2152    async fn test_delete_directory(scheme: &str) {
2153        let path = TempStdDir::default();
2154        create_dir_all(path.join("foo").join("bar")).unwrap();
2155        create_dir_all(path.join("foo").join("zoo")).unwrap();
2156        create_dir_all(path.join("foo").join("zoo").join("abc")).unwrap();
2157        write_to_file(
2158            path.join("foo")
2159                .join("bar")
2160                .join("test_file")
2161                .to_str()
2162                .unwrap(),
2163            "delete",
2164        )
2165        .unwrap();
2166        let file_url = Url::from_directory_path(&path).unwrap();
2167        let url = if scheme.is_empty() {
2168            file_url
2169        } else {
2170            let mut url = Url::parse(&format!("{scheme}:///")).unwrap();
2171            // Use the file:// URL's normalized path so this works on Windows too.
2172            url.set_path(file_url.path());
2173            url
2174        };
2175        let (store, base) = ObjectStore::from_uri(url.as_ref()).await.unwrap();
2176        store
2177            .remove_dir_all(base.clone().join("foo"))
2178            .await
2179            .unwrap();
2180
2181        assert!(!path.join("foo").exists());
2182    }
2183
2184    #[rstest]
2185    #[case("file")]
2186    #[case("file-object-store")]
2187    #[tokio::test]
2188    async fn test_remove_empty_directories(#[case] scheme: &str) {
2189        let path = TempStdDir::default();
2190        let stale_dir = path.join("stale");
2191        let nested_stale_dir = path.join("nested_stale");
2192        let nested_stale_child = nested_stale_dir.join("child");
2193        create_dir_all(&stale_dir).unwrap();
2194        create_dir_all(&nested_stale_child).unwrap();
2195        create_dir_all(path.join("retained").join("child")).unwrap();
2196        write_to_file(
2197            path.join("file_bearing")
2198                .join("test_file")
2199                .to_str()
2200                .unwrap(),
2201            "keep",
2202        )
2203        .unwrap();
2204        create_dir_all(path.join("file_bearing").join("empty_child")).unwrap();
2205
2206        let file_url = Url::from_directory_path(&path).unwrap();
2207        let mut url = Url::parse(&format!("{scheme}:///")).unwrap();
2208        url.set_path(file_url.path());
2209        let (store, base) = ObjectStore::from_uri(url.as_ref()).await.unwrap();
2210
2211        #[cfg(unix)]
2212        let unmodified_since = {
2213            let old_modified_time =
2214                std::time::SystemTime::now() - std::time::Duration::from_secs(10 * 24 * 60 * 60);
2215            for directory in [&stale_dir, &nested_stale_dir, &nested_stale_child] {
2216                std::fs::File::open(directory)
2217                    .unwrap()
2218                    .set_times(std::fs::FileTimes::new().set_modified(old_modified_time))
2219                    .unwrap();
2220            }
2221            DateTime::<Utc>::from(std::time::SystemTime::now())
2222                - chrono::TimeDelta::try_days(7).unwrap()
2223        };
2224        #[cfg(not(unix))]
2225        let unmodified_since = DateTime::<Utc>::from(std::time::SystemTime::now())
2226            + chrono::TimeDelta::try_days(1).unwrap();
2227
2228        store
2229            .remove_empty_dirs(
2230                base.clone(),
2231                HashSet::from([base.clone().join("retained")]),
2232                HashSet::new(),
2233                Some(unmodified_since),
2234            )
2235            .await
2236            .unwrap();
2237
2238        assert!(!path.join("stale").exists());
2239        assert!(!path.join("nested_stale").exists());
2240        assert!(path.join("retained").join("child").exists());
2241        assert!(path.join("file_bearing").join("empty_child").exists());
2242
2243        create_dir_all(path.join("fresh")).unwrap();
2244        create_dir_all(path.join("verified")).unwrap();
2245        store
2246            .remove_empty_dirs(
2247                base.clone(),
2248                HashSet::from([base.clone().join("retained")]),
2249                HashSet::from([base.clone().join("verified")]),
2250                Some(
2251                    DateTime::<Utc>::from(std::time::SystemTime::now())
2252                        - chrono::TimeDelta::try_days(7).unwrap(),
2253                ),
2254            )
2255            .await
2256            .unwrap();
2257
2258        assert!(path.join("fresh").exists());
2259        assert!(!path.join("verified").exists());
2260    }
2261
2262    #[derive(Debug)]
2263    struct TestWrapper {
2264        called: AtomicBool,
2265
2266        return_value: Arc<dyn OSObjectStore>,
2267    }
2268
2269    impl WrappingObjectStore for TestWrapper {
2270        fn wrap(
2271            &self,
2272            _store_prefix: &str,
2273            _original: Arc<dyn OSObjectStore>,
2274        ) -> Arc<dyn OSObjectStore> {
2275            self.called.store(true, Ordering::Relaxed);
2276
2277            // return a mocked value so we can check if the final store is the one we expect
2278            self.return_value.clone()
2279        }
2280
2281        // This one swaps the store out entirely, so a listing that went around it would be
2282        // listing something else.
2283        fn wrap_paginated(
2284            &self,
2285            _store_prefix: &str,
2286            _original: Arc<dyn PaginatedListStore>,
2287        ) -> Option<Arc<dyn PaginatedListStore>> {
2288            None
2289        }
2290    }
2291
2292    impl TestWrapper {
2293        fn called(&self) -> bool {
2294            self.called.load(Ordering::Relaxed)
2295        }
2296    }
2297
2298    /// A lister that exists only to be wrapped.
2299    #[derive(Debug)]
2300    struct StubLister;
2301
2302    #[async_trait]
2303    impl PaginatedListStore for StubLister {
2304        async fn list_paginated(
2305            &self,
2306            _prefix: Option<&str>,
2307            _opts: object_store::list::PaginatedListOptions,
2308        ) -> object_store::Result<object_store::list::PaginatedListResult> {
2309            unimplemented!("this lister exists to be wrapped, not to list")
2310        }
2311    }
2312
2313    /// Records the listers it was handed, and leaves the store alone.
2314    #[derive(Debug)]
2315    struct PaginatedTestWrapper {
2316        name: &'static str,
2317        log: Arc<std::sync::Mutex<Vec<String>>>,
2318    }
2319
2320    impl WrappingObjectStore for PaginatedTestWrapper {
2321        fn wrap(
2322            &self,
2323            _store_prefix: &str,
2324            original: Arc<dyn OSObjectStore>,
2325        ) -> Arc<dyn OSObjectStore> {
2326            original
2327        }
2328
2329        fn wrap_paginated(
2330            &self,
2331            store_prefix: &str,
2332            original: Arc<dyn PaginatedListStore>,
2333        ) -> Option<Arc<dyn PaginatedListStore>> {
2334            self.log
2335                .lock()
2336                .unwrap()
2337                .push(format!("{}@{store_prefix}", self.name));
2338            Some(original)
2339        }
2340    }
2341
2342    /// A chain hands the lister to each of its wrappers in turn. One wrapper giving up the
2343    /// pushdown gives it up for the chain, and the wrappers after it are never asked: the
2344    /// listing is going through `wrap` either way, which is every wrapper at once.
2345    #[rstest]
2346    #[case::every_wrapper_keeps_it(false, vec!["first@memory", "second@memory"])]
2347    #[case::one_wrapper_gives_it_up(true, vec!["first@memory"])]
2348    fn test_a_chain_wraps_the_lister_until_one_gives_it_up(
2349        #[case] gives_up: bool,
2350        #[case] expected_log: Vec<&str>,
2351    ) {
2352        let log = Arc::new(std::sync::Mutex::new(Vec::new()));
2353        let mut wrappers: Vec<Arc<dyn WrappingObjectStore>> =
2354            vec![Arc::new(PaginatedTestWrapper {
2355                name: "first",
2356                log: log.clone(),
2357            })];
2358        if gives_up {
2359            wrappers.push(Arc::new(TestWrapper {
2360                called: AtomicBool::new(false),
2361                return_value: Arc::new(InMemory::new()),
2362            }));
2363        }
2364        wrappers.push(Arc::new(PaginatedTestWrapper {
2365            name: "second",
2366            log: log.clone(),
2367        }));
2368
2369        let wrapped = ChainedWrappingObjectStore::new(wrappers)
2370            .wrap_paginated("memory", Arc::new(StubLister));
2371
2372        assert_eq!(wrapped.is_none(), gives_up);
2373        assert_eq!(*log.lock().unwrap(), expected_log);
2374    }
2375
2376    /// `apply_wrapper` keeps both halves of the store in sync. A wrapper that gives up the
2377    /// pushdown has to clear the lister too, or `read_dir_page` would keep talking to the
2378    /// backend behind the wrapper's back.
2379    #[rstest]
2380    #[case::gives_up_the_pushdown(true)]
2381    #[case::keeps_the_pushdown(false)]
2382    fn test_apply_wrapper_keeps_inner_and_the_lister_in_sync(#[case] gives_up: bool) {
2383        let replacement = Arc::new(InMemory::new());
2384        let giving_up = TestWrapper {
2385            called: AtomicBool::new(false),
2386            return_value: replacement.clone(),
2387        };
2388        let keeping = PaginatedTestWrapper {
2389            name: "passthrough",
2390            log: Arc::new(std::sync::Mutex::new(Vec::new())),
2391        };
2392        let wrapper: &dyn WrappingObjectStore = match gives_up {
2393            true => &giving_up,
2394            false => &keeping,
2395        };
2396
2397        let mut store = ObjectStore::memory();
2398        store.paginated_lister = Some(Arc::new(StubLister) as Arc<dyn PaginatedListStore>);
2399        store.apply_wrapper(wrapper);
2400
2401        assert_eq!(
2402            store.paginated_lister.is_some(),
2403            !gives_up,
2404            "the lister has to follow what the wrapper said"
2405        );
2406        // The wrapper that gives up the pushdown is also the one that swaps the store out, so
2407        // whether `inner` was replaced says that `wrap` ran on the same wrapper.
2408        assert_eq!(
2409            Arc::ptr_eq(&store.inner, &(replacement as Arc<dyn OSObjectStore>)),
2410            gives_up
2411        );
2412    }
2413
2414    #[tokio::test]
2415    async fn test_wrapper_identity_is_stable_across_tasks() {
2416        let wrapper = Arc::new(TestWrapper {
2417            called: AtomicBool::new(false),
2418            return_value: Arc::new(InMemory::new()),
2419        });
2420        let initial_params = ObjectStoreParams {
2421            object_store_wrapper: Some(wrapper.clone()),
2422            ..ObjectStoreParams::default()
2423        };
2424        let task_params = tokio::spawn(async move {
2425            ObjectStoreParams {
2426                object_store_wrapper: Some(wrapper),
2427                ..ObjectStoreParams::default()
2428            }
2429        })
2430        .await
2431        .unwrap();
2432
2433        assert_eq!(initial_params, task_params);
2434
2435        let mut initial_hasher = std::hash::DefaultHasher::new();
2436        std::hash::Hash::hash(&initial_params, &mut initial_hasher);
2437        let mut task_hasher = std::hash::DefaultHasher::new();
2438        std::hash::Hash::hash(&task_params, &mut task_hasher);
2439        assert_eq!(
2440            std::hash::Hasher::finish(&initial_hasher),
2441            std::hash::Hasher::finish(&task_hasher)
2442        );
2443    }
2444
2445    #[tokio::test]
2446    async fn test_wrapping_object_store_option_is_used() {
2447        // Make a store for the inner store first
2448        let mock_inner_store: Arc<dyn OSObjectStore> = Arc::new(InMemory::new());
2449        let registry = Arc::new(ObjectStoreRegistry::default());
2450
2451        assert_eq!(Arc::strong_count(&mock_inner_store), 1);
2452
2453        let wrapper = Arc::new(TestWrapper {
2454            called: AtomicBool::new(false),
2455            return_value: mock_inner_store.clone(),
2456        });
2457
2458        let params = ObjectStoreParams {
2459            object_store_wrapper: Some(wrapper.clone()),
2460            ..ObjectStoreParams::default()
2461        };
2462
2463        // not called yet
2464        assert!(!wrapper.called());
2465
2466        let _ = ObjectStore::from_uri_and_params(registry, "memory:///", &params)
2467            .await
2468            .unwrap();
2469
2470        // called after construction
2471        assert!(wrapper.called());
2472
2473        // hard to compare two trait pointers as the point to vtables
2474        // using the ref count as a proxy to make sure that the store is correctly kept
2475        assert_eq!(Arc::strong_count(&mock_inner_store), 2);
2476    }
2477
2478    #[tokio::test]
2479    async fn test_local_paths() {
2480        let file_path = TempStdFile::default();
2481        let mut writer = ObjectStore::create_local_writer(&file_path).await.unwrap();
2482        writer.write_all(b"LOCAL").await.unwrap();
2483        Writer::shutdown(&mut writer).await.unwrap();
2484
2485        let reader = ObjectStore::open_local(&file_path).await.unwrap();
2486        let buf = reader.get_range(0..5).await.unwrap();
2487        assert_eq!(buf.as_ref(), b"LOCAL");
2488    }
2489
2490    #[cfg(unix)]
2491    #[tokio::test]
2492    async fn test_direct_local_writer_uses_standard_file_permissions() {
2493        let directory = TempStdDir::default();
2494        let reference_path = directory.join("reference");
2495        std::fs::File::create(&reference_path).unwrap();
2496        let expected_mode = std::fs::metadata(reference_path)
2497            .unwrap()
2498            .permissions()
2499            .mode()
2500            & 0o777;
2501
2502        let output_path = directory.join("output");
2503        let object_path = Path::from_absolute_path(&output_path).unwrap();
2504        let store = ObjectStore::local();
2505        let mut writer = store.create(&object_path).await.unwrap();
2506        writer.write_all(b"LOCAL").await.unwrap();
2507        Writer::shutdown(writer.as_mut()).await.unwrap();
2508
2509        let actual_mode = std::fs::metadata(output_path).unwrap().permissions().mode() & 0o777;
2510        assert_eq!(actual_mode, expected_mode);
2511    }
2512
2513    #[tokio::test]
2514    async fn test_read_one() {
2515        let file_path = TempStdFile::default();
2516        let mut writer = ObjectStore::create_local_writer(&file_path).await.unwrap();
2517        writer.write_all(b"LOCAL").await.unwrap();
2518        Writer::shutdown(&mut writer).await.unwrap();
2519
2520        let file_path_os = object_store::path::Path::parse(file_path.to_str().unwrap()).unwrap();
2521        let obj_store = ObjectStore::local();
2522        let buf = obj_store.read_one_all(&file_path_os).await.unwrap();
2523        assert_eq!(buf.as_ref(), b"LOCAL");
2524
2525        let buf = obj_store.read_one_range(&file_path_os, 0..5).await.unwrap();
2526        assert_eq!(buf.as_ref(), b"LOCAL");
2527    }
2528
2529    #[tokio::test]
2530    #[cfg(windows)]
2531    async fn test_windows_paths() {
2532        use std::path::Component;
2533        use std::path::Prefix;
2534        use std::path::Prefix::*;
2535
2536        fn get_path_prefix(path: &StdPath) -> Prefix<'_> {
2537            match path.components().next().unwrap() {
2538                Component::Prefix(prefix_component) => prefix_component.kind(),
2539                _ => panic!(),
2540            }
2541        }
2542
2543        fn get_drive_letter(prefix: Prefix) -> String {
2544            match prefix {
2545                Disk(bytes) => String::from_utf8(vec![bytes]).unwrap(),
2546                _ => panic!(),
2547            }
2548        }
2549
2550        let tmp_path = TempStdFile::default();
2551        let prefix = get_path_prefix(&tmp_path);
2552        let drive_letter = get_drive_letter(prefix);
2553
2554        write_to_file(
2555            &(format!("{drive_letter}:/test_folder/test.lance") + "/test_file"),
2556            "WINDOWS",
2557        )
2558        .unwrap();
2559
2560        for uri in &[
2561            format!("{drive_letter}:/test_folder/test.lance"),
2562            format!("{drive_letter}:\\test_folder\\test.lance"),
2563        ] {
2564            let (store, base) = ObjectStore::from_uri(uri).await.unwrap();
2565            let contents = read_from_store(store.as_ref(), &base.clone().join("test_file"))
2566                .await
2567                .unwrap();
2568            assert_eq!(contents, "WINDOWS");
2569        }
2570    }
2571
2572    #[tokio::test]
2573    async fn test_cross_filesystem_copy() {
2574        // Create two temporary directories that simulate different filesystems
2575        let source_dir = TempStdDir::default();
2576        let dest_dir = TempStdDir::default();
2577
2578        // Create a test file in the source directory
2579        let source_file_name = "test_file.txt";
2580        let source_file = source_dir.join(source_file_name);
2581        std::fs::write(&source_file, b"test content").unwrap();
2582
2583        // Create ObjectStore for local filesystem
2584        let (store, base_path) = ObjectStore::from_uri(source_dir.to_str().unwrap())
2585            .await
2586            .unwrap();
2587
2588        // Create paths relative to the ObjectStore base
2589        let from_path = base_path.clone().join(source_file_name);
2590
2591        // Use object_store::Path::parse for the destination
2592        let dest_file = dest_dir.join("copied_file.txt");
2593        let dest_str = dest_file.to_str().unwrap();
2594        let to_path = object_store::path::Path::parse(dest_str).unwrap();
2595
2596        // Perform the copy operation
2597        store.copy(&from_path, &to_path).await.unwrap();
2598
2599        // Verify the file was copied correctly
2600        assert!(dest_file.exists());
2601        let copied_content = std::fs::read(&dest_file).unwrap();
2602        assert_eq!(copied_content, b"test content");
2603    }
2604
2605    #[tokio::test]
2606    async fn test_copy_creates_parent_directories() {
2607        let source_dir = TempStdDir::default();
2608        let dest_dir = TempStdDir::default();
2609
2610        // Create a test file in the source directory
2611        let source_file_name = "test_file.txt";
2612        let source_file = source_dir.join(source_file_name);
2613        std::fs::write(&source_file, b"test content").unwrap();
2614
2615        // Create ObjectStore for local filesystem
2616        let (store, base_path) = ObjectStore::from_uri(source_dir.to_str().unwrap())
2617            .await
2618            .unwrap();
2619
2620        // Create paths
2621        let from_path = base_path.clone().join(source_file_name);
2622
2623        // Create destination with nested directories that don't exist yet
2624        let dest_file = dest_dir.join("nested").join("dirs").join("copied_file.txt");
2625        let dest_str = dest_file.to_str().unwrap();
2626        let to_path = object_store::path::Path::parse(dest_str).unwrap();
2627
2628        // Perform the copy operation - should create parent directories
2629        store.copy(&from_path, &to_path).await.unwrap();
2630
2631        // Verify the file was copied correctly and directories were created
2632        assert!(dest_file.exists());
2633        assert!(dest_file.parent().unwrap().exists());
2634        let copied_content = std::fs::read(&dest_file).unwrap();
2635        assert_eq!(copied_content, b"test content");
2636    }
2637
2638    /// Inner store that forwards everything to `InMemory` except single-shot
2639    /// server-side copy (`copy_opts`), which always fails. This lets a test
2640    /// prove that `ObjectStore::copy` fell back to a streaming multipart copy
2641    /// for an oversized source rather than issuing a single `CopyObject`.
2642    #[derive(Debug)]
2643    struct CopyFailingStore {
2644        inner: InMemory,
2645    }
2646
2647    impl Display for CopyFailingStore {
2648        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2649            write!(f, "CopyFailingStore")
2650        }
2651    }
2652
2653    #[derive(Debug, Default)]
2654    struct MultipartObservations {
2655        part_count: AtomicUsize,
2656        abort_count: AtomicUsize,
2657        native_copy_count: AtomicUsize,
2658    }
2659
2660    #[derive(Debug)]
2661    struct ObservedMultipartUpload {
2662        inner: Box<dyn MultipartUpload>,
2663        observations: Arc<MultipartObservations>,
2664        fail_parts: bool,
2665    }
2666
2667    #[async_trait]
2668    impl MultipartUpload for ObservedMultipartUpload {
2669        fn put_part(&mut self, data: PutPayload) -> UploadPart {
2670            self.observations.part_count.fetch_add(1, Ordering::SeqCst);
2671            if self.fail_parts {
2672                return Box::pin(async {
2673                    Err(object_store::Error::Generic {
2674                        store: "ObservedMultipartStore",
2675                        source: "injected multipart part failure".into(),
2676                    })
2677                });
2678            }
2679            self.inner.put_part(data)
2680        }
2681
2682        async fn complete(&mut self) -> OSResult<PutResult> {
2683            self.inner.complete().await
2684        }
2685
2686        async fn abort(&mut self) -> OSResult<()> {
2687            self.observations.abort_count.fetch_add(1, Ordering::SeqCst);
2688            self.inner.abort().await
2689        }
2690    }
2691
2692    #[derive(Debug)]
2693    struct ObservedMultipartStore {
2694        inner: InMemory,
2695        observations: Arc<MultipartObservations>,
2696        fail_parts: bool,
2697        destination_size_adjustment: u64,
2698    }
2699
2700    impl Display for ObservedMultipartStore {
2701        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2702            write!(f, "ObservedMultipartStore")
2703        }
2704    }
2705
2706    #[async_trait]
2707    impl OSObjectStore for ObservedMultipartStore {
2708        async fn put_opts(
2709            &self,
2710            location: &Path,
2711            bytes: PutPayload,
2712            opts: PutOptions,
2713        ) -> OSResult<PutResult> {
2714            self.inner.put_opts(location, bytes, opts).await
2715        }
2716
2717        async fn put_multipart_opts(
2718            &self,
2719            location: &Path,
2720            opts: PutMultipartOptions,
2721        ) -> OSResult<Box<dyn MultipartUpload>> {
2722            let inner = self.inner.put_multipart_opts(location, opts).await?;
2723            Ok(Box::new(ObservedMultipartUpload {
2724                inner,
2725                observations: self.observations.clone(),
2726                fail_parts: self.fail_parts,
2727            }))
2728        }
2729
2730        async fn get_opts(&self, location: &Path, options: GetOptions) -> OSResult<GetResult> {
2731            let is_head = options.head;
2732            let mut result = self.inner.get_opts(location, options).await?;
2733            if is_head && location.filename() == Some("destination.bin") {
2734                result.meta.size = result
2735                    .meta
2736                    .size
2737                    .checked_add(self.destination_size_adjustment)
2738                    .expect("test destination size should not overflow");
2739            }
2740            Ok(result)
2741        }
2742
2743        async fn get_ranges(&self, location: &Path, ranges: &[Range<u64>]) -> OSResult<Vec<Bytes>> {
2744            self.inner.get_ranges(location, ranges).await
2745        }
2746
2747        fn delete_stream(
2748            &self,
2749            locations: BoxStream<'static, OSResult<Path>>,
2750        ) -> BoxStream<'static, OSResult<Path>> {
2751            self.inner.delete_stream(locations)
2752        }
2753
2754        fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, OSResult<ObjectMeta>> {
2755            self.inner.list(prefix)
2756        }
2757
2758        fn list_with_offset(
2759            &self,
2760            prefix: Option<&Path>,
2761            offset: &Path,
2762        ) -> BoxStream<'static, OSResult<ObjectMeta>> {
2763            self.inner.list_with_offset(prefix, offset)
2764        }
2765
2766        async fn list_with_delimiter(&self, prefix: Option<&Path>) -> OSResult<ListResult> {
2767            self.inner.list_with_delimiter(prefix).await
2768        }
2769
2770        async fn copy_opts(&self, from: &Path, to: &Path, opts: CopyOptions) -> OSResult<()> {
2771            self.observations
2772                .native_copy_count
2773                .fetch_add(1, Ordering::SeqCst);
2774            self.inner.copy_opts(from, to, opts).await
2775        }
2776    }
2777
2778    #[async_trait]
2779    impl OSObjectStore for CopyFailingStore {
2780        async fn put_opts(
2781            &self,
2782            location: &Path,
2783            bytes: PutPayload,
2784            opts: PutOptions,
2785        ) -> OSResult<PutResult> {
2786            self.inner.put_opts(location, bytes, opts).await
2787        }
2788        async fn put_multipart_opts(
2789            &self,
2790            location: &Path,
2791            opts: PutMultipartOptions,
2792        ) -> OSResult<Box<dyn MultipartUpload>> {
2793            self.inner.put_multipart_opts(location, opts).await
2794        }
2795        async fn get_opts(&self, location: &Path, options: GetOptions) -> OSResult<GetResult> {
2796            self.inner.get_opts(location, options).await
2797        }
2798        async fn get_ranges(&self, location: &Path, ranges: &[Range<u64>]) -> OSResult<Vec<Bytes>> {
2799            self.inner.get_ranges(location, ranges).await
2800        }
2801        fn delete_stream(
2802            &self,
2803            locations: BoxStream<'static, OSResult<Path>>,
2804        ) -> BoxStream<'static, OSResult<Path>> {
2805            self.inner.delete_stream(locations)
2806        }
2807        fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, OSResult<ObjectMeta>> {
2808            self.inner.list(prefix)
2809        }
2810        fn list_with_offset(
2811            &self,
2812            prefix: Option<&Path>,
2813            offset: &Path,
2814        ) -> BoxStream<'static, OSResult<ObjectMeta>> {
2815            self.inner.list_with_offset(prefix, offset)
2816        }
2817        async fn list_with_delimiter(&self, prefix: Option<&Path>) -> OSResult<ListResult> {
2818            self.inner.list_with_delimiter(prefix).await
2819        }
2820        async fn copy_opts(&self, _from: &Path, _to: &Path, _opts: CopyOptions) -> OSResult<()> {
2821            Err(object_store::Error::Generic {
2822                store: "CopyFailingStore",
2823                source: "single-shot copy disabled in test".into(),
2824            })
2825        }
2826    }
2827
2828    #[tokio::test]
2829    async fn test_copy_streams_objects_larger_than_threshold() {
2830        // memory:// is non-local but isn't an S3/GCS scheme, so copy() wouldn't
2831        // enable the fallback on its own. Drive copy_impl directly with
2832        // multipart_copy_fallback = true to exercise the streaming path. The
2833        // inner store rejects any single-shot copy, so a successful copy can only
2834        // have gone through the streaming branch.
2835        let mut store = ObjectStore::memory();
2836        store.inner = Arc::new(CopyFailingStore {
2837            inner: InMemory::new(),
2838        });
2839
2840        let from = Path::from("source.bin");
2841        let contents = b"streaming multipart copy payload well past the tiny threshold";
2842        store.put(&from, contents).await.unwrap();
2843
2844        // Source size (61 bytes) exceeds the threshold -> must stream via a
2845        // multipart write rather than a single-shot server-side copy.
2846        let streamed = Path::from("streamed.bin");
2847        store.copy_impl(&from, &streamed, true, 8).await.unwrap();
2848        let copied = store.read_one_all(&streamed).await.unwrap();
2849        assert_eq!(copied.as_ref(), contents.as_slice());
2850
2851        // Source size below the threshold -> single-shot copy, which the inner
2852        // store rejects, confirming that the streaming branch (not native copy)
2853        // is what made the first copy succeed.
2854        let native = Path::from("native.bin");
2855        assert!(
2856            store
2857                .copy_impl(&from, &native, true, u64::MAX)
2858                .await
2859                .is_err()
2860        );
2861    }
2862
2863    #[tokio::test]
2864    async fn test_copy_via_stream_never_uses_native_copy() {
2865        let mut store = ObjectStore::memory();
2866        store.inner = Arc::new(CopyFailingStore {
2867            inner: InMemory::new(),
2868        });
2869
2870        let source = Path::from("source.bin");
2871        let destination = Path::from("destination.bin");
2872        let contents = b"stream raw bytes instead of issuing native copy";
2873        store.put(&source, contents).await.unwrap();
2874
2875        let result = store
2876            .copy_via_stream(&source, &store, &destination)
2877            .await
2878            .unwrap();
2879
2880        assert_eq!(result.size, contents.len());
2881        assert_eq!(
2882            store.read_one_all(&destination).await.unwrap().as_ref(),
2883            contents
2884        );
2885    }
2886
2887    #[tokio::test]
2888    async fn test_bulk_copy_streams_when_server_side_copy_is_disabled() {
2889        let observations = Arc::new(MultipartObservations::default());
2890        let mut store = ObjectStore::memory();
2891        store.inner = Arc::new(ObservedMultipartStore {
2892            inner: InMemory::new(),
2893            observations: observations.clone(),
2894            fail_parts: false,
2895            destination_size_adjustment: 0,
2896        });
2897
2898        let source = Path::from("source.bin");
2899        let destination = Path::from("destination.bin");
2900        let contents = b"stream by default";
2901        store.put(&source, contents).await.unwrap();
2902
2903        let result = store
2904            .copy_bulk_with_server_side_copy(&source, &store, &destination, false)
2905            .await
2906            .unwrap();
2907
2908        assert_eq!(result.size, contents.len());
2909        assert_eq!(observations.native_copy_count.load(Ordering::SeqCst), 0);
2910        assert_eq!(
2911            store.read_one_all(&destination).await.unwrap().as_ref(),
2912            contents
2913        );
2914    }
2915
2916    #[test]
2917    #[serial(server_side_copy_env)]
2918    fn test_server_side_copy_environment_policy() {
2919        let previous_value = std::env::var_os(SERVER_SIDE_COPY_ENABLED_ENV);
2920        let mut store = ObjectStore::memory();
2921        store.scheme = "test-cloud".to_string();
2922        let destination_store = store.clone();
2923
2924        // SAFETY: this serialized test is the only test that mutates this task-specific
2925        // environment variable, and it restores the original value before returning.
2926        unsafe { std::env::remove_var(SERVER_SIDE_COPY_ENABLED_ENV) };
2927        assert!(!store.uses_server_side_copy(&destination_store));
2928
2929        // SAFETY: see the serialized-test guarantee above.
2930        unsafe { std::env::set_var(SERVER_SIDE_COPY_ENABLED_ENV, "true") };
2931        assert!(store.uses_server_side_copy(&destination_store));
2932
2933        // SAFETY: restore the process environment before the test returns.
2934        unsafe {
2935            match previous_value {
2936                Some(value) => std::env::set_var(SERVER_SIDE_COPY_ENABLED_ENV, value),
2937                None => std::env::remove_var(SERVER_SIDE_COPY_ENABLED_ENV),
2938            }
2939        }
2940    }
2941
2942    #[tokio::test]
2943    async fn test_bulk_copy_uses_server_side_copy_when_enabled_for_same_store() {
2944        let observations = Arc::new(MultipartObservations::default());
2945        let mut source_store = ObjectStore::memory();
2946        source_store.scheme = "test-cloud".to_string();
2947        source_store.inner = Arc::new(ObservedMultipartStore {
2948            inner: InMemory::new(),
2949            observations: observations.clone(),
2950            fail_parts: false,
2951            destination_size_adjustment: 0,
2952        });
2953        let destination_store = source_store.clone();
2954
2955        let source = Path::from("source.bin");
2956        let destination = Path::from("destination.bin");
2957        let contents = b"use native copy when explicitly enabled";
2958        source_store.put(&source, contents).await.unwrap();
2959
2960        let result = source_store
2961            .copy_bulk_with_server_side_copy(&source, &destination_store, &destination, true)
2962            .await
2963            .unwrap();
2964
2965        assert_eq!(result.size, contents.len());
2966        assert_eq!(observations.native_copy_count.load(Ordering::SeqCst), 1);
2967        assert_eq!(
2968            destination_store
2969                .read_one_all(&destination)
2970                .await
2971                .unwrap()
2972                .as_ref(),
2973            contents
2974        );
2975    }
2976
2977    #[tokio::test]
2978    async fn test_bulk_copy_streams_for_distinct_clients_with_same_prefix() {
2979        let shared_inner = InMemory::new();
2980        let source_observations = Arc::new(MultipartObservations::default());
2981        let mut source_store = ObjectStore::memory();
2982        source_store.scheme = "test-cloud".to_string();
2983        source_store.store_prefix = "test-cloud$bucket".to_string();
2984        source_store.inner = Arc::new(ObservedMultipartStore {
2985            inner: shared_inner.clone(),
2986            observations: source_observations.clone(),
2987            fail_parts: false,
2988            destination_size_adjustment: 0,
2989        });
2990        let destination_observations = Arc::new(MultipartObservations::default());
2991        let mut destination_store = ObjectStore::memory();
2992        destination_store.scheme = "test-cloud".to_string();
2993        destination_store.store_prefix = "test-cloud$bucket".to_string();
2994        destination_store.inner = Arc::new(ObservedMultipartStore {
2995            inner: shared_inner,
2996            observations: destination_observations.clone(),
2997            fail_parts: false,
2998            destination_size_adjustment: 0,
2999        });
3000
3001        let source = Path::from("source.bin");
3002        let destination = Path::from("destination.bin");
3003        let contents = b"use native copy when explicitly enabled";
3004        source_store.put(&source, contents).await.unwrap();
3005
3006        let result = source_store
3007            .copy_bulk_with_server_side_copy(&source, &destination_store, &destination, true)
3008            .await
3009            .unwrap();
3010
3011        assert_eq!(result.size, contents.len());
3012        assert_eq!(
3013            source_observations.native_copy_count.load(Ordering::SeqCst),
3014            0
3015        );
3016        assert_eq!(
3017            destination_observations
3018                .native_copy_count
3019                .load(Ordering::SeqCst),
3020            0
3021        );
3022        assert_eq!(
3023            destination_store
3024                .read_one_all(&destination)
3025                .await
3026                .unwrap()
3027                .as_ref(),
3028            contents
3029        );
3030    }
3031
3032    #[tokio::test]
3033    async fn test_bulk_copy_rejects_server_side_destination_size_mismatch() {
3034        let observations = Arc::new(MultipartObservations::default());
3035        let mut source_store = ObjectStore::memory();
3036        source_store.scheme = "test-cloud".to_string();
3037        source_store.inner = Arc::new(ObservedMultipartStore {
3038            inner: InMemory::new(),
3039            observations: observations.clone(),
3040            fail_parts: false,
3041            destination_size_adjustment: 1,
3042        });
3043        let destination_store = source_store.clone();
3044
3045        let source = Path::from("source.bin");
3046        let destination = Path::from("destination.bin");
3047        source_store
3048            .put(&source, b"validate native copy")
3049            .await
3050            .unwrap();
3051
3052        let error = source_store
3053            .copy_bulk_with_server_side_copy(&source, &destination_store, &destination, true)
3054            .await
3055            .unwrap_err();
3056
3057        assert!(
3058            error.to_string().contains("destination size mismatch"),
3059            "expected validation failure, got: {error}"
3060        );
3061        assert_eq!(observations.native_copy_count.load(Ordering::SeqCst), 1);
3062    }
3063
3064    #[tokio::test]
3065    async fn test_bulk_copy_streams_across_stores_when_server_side_copy_is_enabled() {
3066        let source_store = ObjectStore::memory();
3067        let observations = Arc::new(MultipartObservations::default());
3068        let mut destination_store = ObjectStore::memory();
3069        destination_store.inner = Arc::new(ObservedMultipartStore {
3070            inner: InMemory::new(),
3071            observations: observations.clone(),
3072            fail_parts: false,
3073            destination_size_adjustment: 0,
3074        });
3075
3076        let source = Path::from("source.bin");
3077        let destination = Path::from("destination.bin");
3078        let contents = b"cross-store copies must stream";
3079        source_store.put(&source, contents).await.unwrap();
3080
3081        let result = source_store
3082            .copy_bulk_with_server_side_copy(&source, &destination_store, &destination, true)
3083            .await
3084            .unwrap();
3085
3086        assert_eq!(result.size, contents.len());
3087        assert_eq!(observations.native_copy_count.load(Ordering::SeqCst), 0);
3088        assert_eq!(
3089            destination_store
3090                .read_one_all(&destination)
3091                .await
3092                .unwrap()
3093                .as_ref(),
3094            contents
3095        );
3096    }
3097
3098    #[tokio::test]
3099    async fn test_copy_via_stream_preserves_local_not_found() {
3100        let directory = TempStdDir::default();
3101        let (store, base_path) = ObjectStore::from_uri(directory.to_str().unwrap())
3102            .await
3103            .unwrap();
3104        let source = base_path.clone().join("missing.bin");
3105        let destination = base_path.join("destination.bin");
3106
3107        let error = store
3108            .copy_via_stream(&source, &store, &destination)
3109            .await
3110            .unwrap_err();
3111
3112        assert!(
3113            error.is_not_found(),
3114            "expected not-found error, got: {error}"
3115        );
3116    }
3117
3118    #[tokio::test]
3119    async fn test_copy_via_stream_uses_multiple_parts() {
3120        let mut source_store = ObjectStore::memory();
3121        source_store.max_iop_size = 1024 * 1024;
3122        let observations = Arc::new(MultipartObservations::default());
3123        let mut destination_store = ObjectStore::memory();
3124        destination_store.inner = Arc::new(ObservedMultipartStore {
3125            inner: InMemory::new(),
3126            observations: observations.clone(),
3127            fail_parts: false,
3128            destination_size_adjustment: 0,
3129        });
3130
3131        let source = Path::from("source.bin");
3132        let destination = Path::from("destination.bin");
3133        let contents = vec![42; crate::object_writer::initial_upload_size() * 2 + 1];
3134        source_store.put(&source, &contents).await.unwrap();
3135
3136        let result = source_store
3137            .copy_via_stream(&source, &destination_store, &destination)
3138            .await
3139            .unwrap();
3140
3141        assert_eq!(result.size, contents.len());
3142        assert!(
3143            observations.part_count.load(Ordering::SeqCst) >= 2,
3144            "stream copy should split a large destination into multiple upload parts"
3145        );
3146        assert_eq!(
3147            destination_store
3148                .read_one_all(&destination)
3149                .await
3150                .unwrap()
3151                .as_ref(),
3152            contents.as_slice()
3153        );
3154    }
3155
3156    #[tokio::test]
3157    async fn test_copy_via_stream_aborts_failed_upload_and_retains_source() {
3158        let source_store = ObjectStore::memory();
3159        let observations = Arc::new(MultipartObservations::default());
3160        let mut destination_store = ObjectStore::memory();
3161        destination_store.inner = Arc::new(ObservedMultipartStore {
3162            inner: InMemory::new(),
3163            observations: observations.clone(),
3164            fail_parts: true,
3165            destination_size_adjustment: 0,
3166        });
3167
3168        let source = Path::from("source.bin");
3169        let destination = Path::from("destination.bin");
3170        let contents = vec![7; crate::object_writer::initial_upload_size() * 2];
3171        source_store.put(&source, &contents).await.unwrap();
3172
3173        let error = source_store
3174            .copy_via_stream(&source, &destination_store, &destination)
3175            .await
3176            .unwrap_err();
3177        let error_message = error.to_string();
3178        assert!(
3179            (error_message.contains("destination write")
3180                || error_message.contains("destination completion"))
3181                && error_message.contains("injected multipart part failure"),
3182            "expected upload-stage context and the underlying error, got: {error}"
3183        );
3184
3185        tokio::time::timeout(Duration::from_secs(1), async {
3186            loop {
3187                if observations.abort_count.load(Ordering::SeqCst) > 0 {
3188                    break;
3189                }
3190                tokio::task::yield_now().await;
3191            }
3192        })
3193        .await
3194        .expect("multipart abort should complete");
3195        assert_eq!(observations.abort_count.load(Ordering::SeqCst), 1);
3196        assert_eq!(
3197            source_store.read_one_all(&source).await.unwrap().as_ref(),
3198            contents.as_slice()
3199        );
3200        assert!(!destination_store.exists(&destination).await.unwrap());
3201    }
3202
3203    #[tokio::test]
3204    async fn test_copy_via_stream_rejects_destination_size_mismatch() {
3205        let source_store = ObjectStore::memory();
3206        let mut destination_store = ObjectStore::memory();
3207        destination_store.inner = Arc::new(ObservedMultipartStore {
3208            inner: InMemory::new(),
3209            observations: Arc::new(MultipartObservations::default()),
3210            fail_parts: false,
3211            destination_size_adjustment: 1,
3212        });
3213
3214        let source = Path::from("source.bin");
3215        let destination = Path::from("destination.bin");
3216        let contents = b"validate the destination after completion";
3217        source_store.put(&source, contents).await.unwrap();
3218
3219        let error = source_store
3220            .copy_via_stream(&source, &destination_store, &destination)
3221            .await
3222            .unwrap_err();
3223
3224        assert!(
3225            error.to_string().contains("destination size mismatch"),
3226            "expected validation failure, got: {error}"
3227        );
3228    }
3229
3230    #[test]
3231    #[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
3232    fn test_client_options_extracts_headers() {
3233        let opts = StorageOptions(HashMap::from([
3234            ("headers.x-custom-foo".to_string(), "bar".to_string()),
3235            ("headers.x-ms-version".to_string(), "2023-11-03".to_string()),
3236            ("region".to_string(), "us-west-2".to_string()),
3237        ]));
3238        let client_options = opts.client_options().unwrap();
3239
3240        // Verify non-header keys are not consumed as headers by creating
3241        // another StorageOptions with no headers.* keys.
3242        let opts_no_headers = StorageOptions(HashMap::from([(
3243            "region".to_string(),
3244            "us-west-2".to_string(),
3245        )]));
3246        opts_no_headers.client_options().unwrap();
3247
3248        // Smoke test: the client_options with headers should be usable
3249        // in a builder (we can't inspect the headers directly, but building
3250        // should not fail).
3251        #[cfg(feature = "gcp")]
3252        {
3253            use object_store::gcp::GoogleCloudStorageBuilder;
3254            let _builder = GoogleCloudStorageBuilder::new()
3255                .with_client_options(client_options)
3256                .with_url("gs://test-bucket");
3257        }
3258    }
3259
3260    #[test]
3261    #[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
3262    fn test_client_options_rejects_invalid_header_name() {
3263        let opts = StorageOptions(HashMap::from([(
3264            "headers.bad header".to_string(),
3265            "value".to_string(),
3266        )]));
3267        let err = opts.client_options().unwrap_err();
3268        assert!(err.to_string().contains("invalid header name"));
3269    }
3270
3271    #[test]
3272    #[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
3273    fn test_client_options_rejects_invalid_header_value() {
3274        let opts = StorageOptions(HashMap::from([(
3275            "headers.x-good-name".to_string(),
3276            "bad\x01value".to_string(),
3277        )]));
3278        let err = opts.client_options().unwrap_err();
3279        assert!(err.to_string().contains("invalid header value"));
3280    }
3281
3282    #[test]
3283    #[cfg(any(feature = "aws", feature = "azure", feature = "gcp"))]
3284    fn test_client_options_empty_when_no_header_keys() {
3285        let opts = StorageOptions(HashMap::from([
3286            ("region".to_string(), "us-east-1".to_string()),
3287            ("access_key_id".to_string(), "AKID".to_string()),
3288        ]));
3289        opts.client_options().unwrap();
3290    }
3291}