Skip to main content

unitycatalog_object_store/
lib.rs

1//! `object_store` integration for Unity Catalog.
2//!
3//! This crate adapts Unity Catalog's credential-vending APIs to the
4//! [`object_store`](https://docs.rs/object_store) trait, so any framework
5//! that accepts an `Arc<dyn ObjectStore>` (DataFusion, `delta_kernel`,
6//! `parquet`, …) can read and write data governed by Unity Catalog
7//! volumes, tables, or external locations with no extra glue.
8//!
9//! # Quickstart
10//!
11//! ```no_run
12//! use unitycatalog_object_store::{Operation, UnityObjectStoreFactory};
13//!
14//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
15//! let factory = UnityObjectStoreFactory::builder()
16//!     .with_uri("https://my-workspace.cloud.databricks.com/api/2.1/unity-catalog/")
17//!     .with_token(std::env::var("DATABRICKS_TOKEN").unwrap())
18//!     .build()
19//!     .await?;
20//!
21//! // Address a UC securable directly with a `uc://` URL …
22//! let store = factory
23//!     .for_url("uc:///Volumes/main/default/landing/raw/", Operation::Read)
24//!     .await?;
25//!
26//! // … and use it like any other `object_store`.
27//! let listing = futures::StreamExt::collect::<Vec<_>>(store.as_dyn().list(None)).await;
28//! # Ok(()) }
29//! ```
30//!
31//! # URL scheme
32//!
33//! See [`UCReference`] for the full grammar. In short:
34//!
35//! - `uc:///Volumes/<catalog>/<schema>/<volume>[/<path>]`
36//! - `uc:///Tables/<catalog>/<schema>/<table>`
37//! - `s3://`, `gs://`, `abfss://`, `r2://`, … — raw cloud URLs, vended via
38//!   `temporary-path-credentials`.
39//!
40//! The kind segment is **case-insensitive** (so `/Volumes/`, `/volumes/`,
41//! and `/VOLUMES/` all work); the capitalised form is canonical because it
42//! mirrors the Databricks workspace POSIX path convention. The Databricks
43//! `vol+dbfs:/Volumes/...` alias is also accepted.
44
45use std::sync::Arc;
46
47use object_store::aws::AmazonS3Builder;
48use object_store::azure::MicrosoftAzureBuilder;
49use object_store::client::SpawnedReqwestConnector;
50use object_store::gcp::GoogleCloudStorageBuilder;
51use object_store::local::LocalFileSystem;
52use object_store::path::Path;
53use object_store::prefix::PrefixStore;
54use object_store::{ObjectStore, Result};
55use olai_http::CloudClient;
56use tokio::runtime::Handle;
57use unitycatalog_client::{TemporaryCredentialClient, UnityCatalogClient};
58use unitycatalog_common::tables::v1::GetTableRequest;
59use unitycatalog_common::temporary_credentials::v1::TemporaryCredential;
60use unitycatalog_common::volumes::v1::GetVolumeRequest;
61use url::Url;
62
63use crate::credential::{
64    SecurableRef, as_aws, as_azure, as_gcp, aws_access_point, new_aws, new_azure, new_gcp,
65};
66pub use crate::error::Error;
67pub use unitycatalog_common::UCReference;
68// Re-export the reference / operation enums so consumers do not need a direct
69// dependency on `unitycatalog-client` for the common case.
70pub use unitycatalog_client::{
71    PathOperation, TableOperation, TableReference, VolumeOperation, VolumeReference,
72};
73
74mod credential;
75mod error;
76/// Builder for [`UnityObjectStoreFactory`].
77#[derive(Debug, Clone, Default)]
78pub struct UnityObjectStoreFactoryBuilder {
79    /// Base URL of the Unity Catalog REST API
80    /// (e.g. `https://<workspace>.cloud.databricks.com/api/2.1/unity-catalog/`).
81    uri: Option<String>,
82    /// Bearer token used for authentication.
83    token: Option<String>,
84    /// Permit construction without a token. Useful for local development
85    /// against an unauthenticated OSS server; do not use in production.
86    allow_unauthenticated: bool,
87    /// Optional AWS region hint. Required when the data lives in a region
88    /// other than `us-east-1` and the server does not return region info
89    /// alongside the vended credential.
90    aws_region: Option<String>,
91    /// Optional dedicated tokio runtime for HTTP I/O. When set, all
92    /// object-store and credential-vending requests are spawned on this
93    /// runtime instead of the ambient one. See [`with_io_runtime`].
94    ///
95    /// [`with_io_runtime`]: UnityObjectStoreFactoryBuilder::with_io_runtime
96    io_handle: Option<Handle>,
97}
98
99impl UnityObjectStoreFactoryBuilder {
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    /// Set the URI of the Unity Catalog API
105    /// (e.g. `https://<workspace>/api/2.1/unity-catalog/`).
106    pub fn with_uri(mut self, uri: impl Into<String>) -> Self {
107        self.uri = Some(uri.into());
108        self
109    }
110
111    /// Set the [access token] used for bearer authentication.
112    ///
113    /// Accepts both `String` and `Option<String>` — pass `None` to clear
114    /// a previously-set token (e.g. when reusing the builder).
115    ///
116    /// [access token]: https://docs.databricks.com/aws/en/dev-tools/auth/pat
117    pub fn with_token(mut self, token: impl Into<Option<String>>) -> Self {
118        self.token = token.into();
119        self
120    }
121
122    /// Allow construction without any authentication credentials.
123    ///
124    /// Only intended for local development against an unauthenticated OSS
125    /// Unity Catalog server — there should not be any unauthenticated UC
126    /// servers in production deployments.
127    pub fn with_allow_unauthenticated(mut self, allow_unauthenticated: bool) -> Self {
128        self.allow_unauthenticated = allow_unauthenticated;
129        self
130    }
131
132    /// Override the AWS region used for vended AWS credentials.
133    ///
134    /// When unset the factory falls back to (in order):
135    /// 1. The `AWS_REGION` environment variable.
136    /// 2. The `object_store` default region (`us-east-1`).
137    ///
138    /// This is a stop-gap until the server reliably returns region info
139    /// alongside the credential.
140    pub fn with_aws_region(mut self, aws_region: impl Into<Option<String>>) -> Self {
141        self.aws_region = aws_region.into();
142        self
143    }
144
145    /// Route all HTTP I/O onto a dedicated tokio runtime.
146    ///
147    /// In production DataFusion deployments it is common to segregate network
148    /// I/O onto a separate runtime so that CPU-bound query work on the main
149    /// runtime cannot starve object-store requests (and vice versa). When a
150    /// handle is supplied here:
151    ///
152    /// - every cloud object store ([`AmazonS3Builder`], [`MicrosoftAzureBuilder`],
153    ///   [`GoogleCloudStorageBuilder`]) is built with a
154    ///   [`SpawnedReqwestConnector`] that spawns its requests on this runtime; and
155    /// - the credential-vending [`CloudClient`] is configured with the same
156    ///   runtime via [`CloudClient::with_runtime`].
157    ///
158    /// When unset (the default), I/O runs on the ambient runtime — current
159    /// behaviour, fully backwards compatible.
160    ///
161    /// Pass `None` to clear a previously set handle (e.g. when reusing the
162    /// builder).
163    pub fn with_io_runtime(mut self, handle: impl Into<Option<Handle>>) -> Self {
164        self.io_handle = handle.into();
165        self
166    }
167
168    pub async fn build(self) -> Result<UnityObjectStoreFactory> {
169        let url = if let Some(uri) = self.uri {
170            url::Url::parse(&uri).map_err(Error::from)?
171        } else {
172            return Err(Error::invalid_config("missing `uri` for Unity Catalog endpoint").into());
173        };
174
175        let cloud_client = if let Some(token) = self.token {
176            CloudClient::new_with_token(token)
177        } else if self.allow_unauthenticated {
178            CloudClient::new_unauthenticated()
179        } else {
180            return Err(Error::invalid_config(
181                "no token and `allow_unauthenticated` not set: cannot build credential client",
182            )
183            .into());
184        };
185
186        // Route credential-vending HTTP onto the dedicated I/O runtime when one
187        // was supplied; otherwise leave it on the ambient runtime.
188        let cloud_client = match &self.io_handle {
189            Some(handle) => cloud_client.with_runtime(handle.clone()),
190            None => cloud_client,
191        };
192
193        let creds = TemporaryCredentialClient::new_with_url(cloud_client.clone(), url.clone());
194        let uc = UnityCatalogClient::new(cloud_client, url);
195        Ok(UnityObjectStoreFactory {
196            creds,
197            uc,
198            aws_region: self.aws_region,
199            io_handle: self.io_handle,
200        })
201    }
202}
203
204/// A configured Unity Catalog `ObjectStore` ready for use.
205///
206/// The default [`Self::as_dyn`] returns a store that is automatically
207/// prefixed to the credential-scoped sub-path (e.g. just the volume's
208/// storage root); paths passed to `list`/`get`/`put` are interpreted
209/// relative to that prefix. The unprefixed [`Self::root`] is an escape
210/// hatch for callers that need to work at the bucket level.
211#[derive(Clone)]
212pub struct UCStore {
213    /// Bucket-rooted store (credentials may be scoped to a sub-path).
214    root: Arc<dyn ObjectStore>,
215    /// The full cloud URL of the credential-scoped root.
216    url: Url,
217    /// Path within `root` the credential is scoped to.
218    path: Path,
219}
220
221impl UCStore {
222    /// Returns the credential-scoped store (prefixed at [`Self::prefix`]).
223    ///
224    /// This is the common case: callers list / read / write paths inside
225    /// the volume or table the credential was vended for.
226    pub fn as_dyn(&self) -> Arc<dyn ObjectStore> {
227        if self.path.as_ref().is_empty() {
228            self.root.clone()
229        } else {
230            Arc::new(PrefixStore::new(self.root.clone(), self.path.clone()))
231        }
232    }
233
234    /// Returns the bucket-rooted store.
235    ///
236    /// The vended credential may not authorise access to siblings of
237    /// [`Self::prefix`]; callers using `root()` are responsible for not
238    /// accessing paths outside the scoped region.
239    pub fn root(&self) -> Arc<dyn ObjectStore> {
240        self.root.clone()
241    }
242
243    /// The full cloud URL of the credential-scoped root
244    /// (e.g. `s3://bucket/prefix/inside/volume/`).
245    pub fn url(&self) -> &Url {
246        &self.url
247    }
248
249    /// The prefix inside [`Self::root`] the credential is scoped to.
250    pub fn prefix(&self) -> &Path {
251        &self.path
252    }
253}
254
255/// Factory that mints `object_store` instances backed by Unity Catalog
256/// credential vending.
257#[derive(Clone)]
258pub struct UnityObjectStoreFactory {
259    creds: TemporaryCredentialClient,
260    uc: UnityCatalogClient,
261    aws_region: Option<String>,
262    /// Dedicated runtime for object-store HTTP I/O, if configured via
263    /// [`UnityObjectStoreFactoryBuilder::with_io_runtime`].
264    io_handle: Option<Handle>,
265}
266
267impl UnityObjectStoreFactory {
268    pub fn builder() -> UnityObjectStoreFactoryBuilder {
269        UnityObjectStoreFactoryBuilder::default()
270    }
271
272    /// Borrow the underlying [`UnityCatalogClient`] for catalog metadata
273    /// operations (listing volumes, resolving table names, …).
274    pub fn unity_client(&self) -> &UnityCatalogClient {
275        &self.uc
276    }
277
278    /// Borrow the underlying credential-vending client. Most users want
279    /// [`for_url`](Self::for_url) / [`for_volume`](Self::for_volume) /
280    /// [`for_table`](Self::for_table) / [`for_path`](Self::for_path) instead.
281    pub fn credentials_client(&self) -> &TemporaryCredentialClient {
282        &self.creds
283    }
284
285    /// Build an [`UCStore`] for any supported URL.
286    ///
287    /// See [`UCReference`] for the supported URL grammar. Raw cloud URLs
288    /// (`s3://`, `gs://`, `abfss://`, …) are routed to
289    /// [`for_path`](Self::for_path).
290    pub async fn for_url(&self, url: &str, op: Operation) -> Result<UCStore> {
291        let reference = UCReference::parse(url)
292            .map_err(crate::error::Error::from)
293            .map_err(object_store::Error::from)?;
294        match reference {
295            UCReference::Volume {
296                catalog,
297                schema,
298                volume,
299                path,
300            } => {
301                let name = format!("{catalog}.{schema}.{volume}");
302                let store = self.for_volume(name, op.into_volume()).await?;
303                if path.is_empty() {
304                    Ok(store)
305                } else {
306                    Ok(extend_prefix(store, &path))
307                }
308            }
309            UCReference::Table {
310                catalog,
311                schema,
312                table,
313            } => {
314                let name = format!("{catalog}.{schema}.{table}");
315                self.for_table(name, op.into_table()).await
316            }
317            UCReference::Path(url) => self.for_path(&url, op.into_path()).await,
318        }
319    }
320
321    /// Vend credentials for a table and return a prefixed store rooted at
322    /// the table's storage location.
323    ///
324    /// The `table` argument accepts a `Uuid`, a [`String`] / `&str`
325    /// containing a three-level `<catalog>.<schema>.<table>` name, or any
326    /// [`TableReference`].
327    pub async fn for_table(
328        &self,
329        table: impl Into<TableReference>,
330        operation: TableOperation,
331    ) -> Result<UCStore> {
332        let table = table.into();
333        // A table backed by local filesystem storage has no cloud credential
334        // to vend. Resolve its storage location up front (a name lookup, the
335        // same call name-based vending makes) and, when it is `file://`, build
336        // a local store directly — skipping the credential-vending round-trip.
337        //
338        // This resolution is only possible for name references; a caller that
339        // holds only the table UUID still vends (and a local-fs table addressed
340        // by UUID is an unsupported edge case — use the three-level name).
341        if let TableReference::Name(name) = &table
342            && let Some(location) = self.table_storage_location(name).await?
343            && let Ok(url) = Url::parse(&location)
344            && url.scheme() == "file"
345        {
346            let path_op = match operation {
347                TableOperation::Read => PathOperation::Read,
348                TableOperation::ReadWrite => PathOperation::ReadWrite,
349            };
350            return local_store(&url, path_op);
351        }
352        let (credential, table_id) = self
353            .creds
354            .temporary_table_credential(table, operation)
355            .await
356            .map_err(Error::from)?;
357        let securable = SecurableRef::Table(table_id, operation);
358        self.build_store(credential, securable).await
359    }
360
361    /// Look up a table's `storage_location` by its three-level name.
362    ///
363    /// Returns `None` when the table has no storage location set. Used by
364    /// [`for_table`](Self::for_table) to detect `file://`-backed tables before
365    /// vending.
366    async fn table_storage_location(&self, full_name: &str) -> Result<Option<String>> {
367        let table = self
368            .uc
369            .tables_client()
370            .get_table(&GetTableRequest {
371                full_name: full_name.to_string(),
372                include_browse: Some(false),
373                include_delta_metadata: Some(false),
374                include_manifest_capabilities: Some(false),
375            })
376            .await
377            .map_err(Error::from)?;
378        Ok(table.storage_location.filter(|s| !s.is_empty()))
379    }
380
381    /// Vend credentials for a volume and return a prefixed store rooted at
382    /// the volume's storage location.
383    ///
384    /// A volume whose storage location is a `file://` path is served by a local
385    /// [`LocalFileSystem`] store and never hits the credential-vending API —
386    /// local storage has no cloud credential to vend.
387    pub async fn for_volume(
388        &self,
389        volume: impl Into<VolumeReference>,
390        operation: VolumeOperation,
391    ) -> Result<UCStore> {
392        let volume = volume.into();
393        // As with `for_table`: a volume backed by local filesystem storage has
394        // no cloud credential to vend. Resolve its storage location up front
395        // (the same `GetVolume` lookup name-based vending makes) and, when it is
396        // `file://`, build a local store directly — skipping vending.
397        //
398        // Only possible for name references; a caller holding only the volume
399        // UUID still vends (a local-fs volume addressed by UUID is unsupported —
400        // use the three-level name).
401        if let VolumeReference::Name(name) = &volume
402            && let Some(location) = self.volume_storage_location(name).await?
403            && let Ok(url) = Url::parse(&location)
404            && url.scheme() == "file"
405        {
406            let path_op = match operation {
407                VolumeOperation::Read => PathOperation::Read,
408                VolumeOperation::ReadWrite => PathOperation::ReadWrite,
409            };
410            return local_store(&url, path_op);
411        }
412        let (credential, volume_id) = self
413            .creds
414            .temporary_volume_credential(volume, operation)
415            .await
416            .map_err(Error::from)?;
417        let securable = SecurableRef::Volume(volume_id, operation);
418        self.build_store(credential, securable).await
419    }
420
421    /// Look up a volume's `storage_location` by its three-level name.
422    ///
423    /// Returns `None` when the volume has no storage location set. Used by
424    /// [`for_volume`](Self::for_volume) to detect `file://`-backed volumes
425    /// before vending.
426    async fn volume_storage_location(&self, name: &str) -> Result<Option<String>> {
427        let volume = self
428            .uc
429            .volumes_client()
430            .get_volume(&GetVolumeRequest {
431                name: name.to_string(),
432                include_browse: Some(false),
433            })
434            .await
435            .map_err(Error::from)?;
436        Ok(Some(volume.storage_location).filter(|s| !s.is_empty()))
437    }
438
439    /// Vend credentials for a raw cloud URL (`s3://`, `gs://`, `abfss://`,
440    /// …). Uses `temporary-path-credentials` under the hood.
441    ///
442    /// `file://` URLs are served by a local [`LocalFileSystem`] store and
443    /// never hit the credential-vending API — local storage has no cloud
444    /// credential to vend.
445    pub async fn for_path(&self, path: &Url, operation: PathOperation) -> Result<UCStore> {
446        if path.scheme() == "file" {
447            return local_store(path, operation);
448        }
449        let (credential, _resolved) = self
450            .creds
451            .temporary_path_credential(path.clone(), operation, false)
452            .await
453            .map_err(Error::from)?;
454        let securable = SecurableRef::Path(path.clone(), operation, Some(false));
455        self.build_store(credential, securable).await
456    }
457
458    /// Vend credentials for a raw cloud URL with `dry_run` set to true.
459    /// The server validates that credentials *could* be issued but the
460    /// returned token is not usable for IO; useful for permission probes.
461    ///
462    /// For `file://` URLs there is nothing to probe — a local store is
463    /// returned directly, identical to [`for_path`](Self::for_path).
464    pub async fn dry_run_path(&self, path: &Url, operation: PathOperation) -> Result<UCStore> {
465        if path.scheme() == "file" {
466            return local_store(path, operation);
467        }
468        let (credential, _resolved) = self
469            .creds
470            .temporary_path_credential(path.clone(), operation, true)
471            .await
472            .map_err(Error::from)?;
473        let securable = SecurableRef::Path(path.clone(), operation, Some(true));
474        self.build_store(credential, securable).await
475    }
476
477    async fn build_store(
478        &self,
479        credential: TemporaryCredential,
480        securable: SecurableRef,
481    ) -> Result<UCStore> {
482        let url = Url::parse(&credential.url).map_err(Error::from)?;
483        // The emulator store is rooted at the container, so the prefix is the
484        // blob path *within* the container — not the full URL path (which for
485        // path-style Azurite also carries `/<account>/<container>`).
486        let path = match parse_azurite(&url) {
487            Some(loc) => Path::from(loc.prefix),
488            None => Path::from_url_path(url.path())?,
489        };
490        let store = self.to_store(credential, securable).await?;
491        Ok(UCStore {
492            root: store,
493            url,
494            path,
495        })
496    }
497
498    async fn to_store(
499        &self,
500        credential: TemporaryCredential,
501        securable: SecurableRef,
502    ) -> Result<Arc<dyn ObjectStore>> {
503        if as_azure(&credential).is_ok() {
504            let url = Url::parse(&credential.url).map_err(Error::from)?;
505
506            // Azurite (local Blob emulator): the URL is path-style and
507            // `with_url` does not understand it, and in emulator mode the
508            // builder ignores a `with_credentials` provider — it only honours
509            // an explicit access key / bearer / SAS. So set account + container
510            // explicitly and pass the vended SAS via `SasKey`.
511            if let Some(loc) = parse_azurite(&url) {
512                let sas = azure_sas_token(&credential).ok_or_else(|| {
513                    Error::invalid_config(
514                        "Azurite store requires a SAS-token credential".to_string(),
515                    )
516                })?;
517                let mut builder = MicrosoftAzureBuilder::new()
518                    .with_use_emulator(true)
519                    .with_account(loc.account)
520                    .with_container_name(loc.container)
521                    .with_config(object_store::azure::AzureConfigKey::SasKey, sas);
522                if let Some(handle) = &self.io_handle {
523                    builder =
524                        builder.with_http_connector(SpawnedReqwestConnector::new(handle.clone()));
525                }
526                let store = builder.build()?;
527                return Ok(Arc::new(store));
528            }
529
530            let provider = new_azure(self.creds.clone(), &credential, securable).await?;
531            let mut builder = MicrosoftAzureBuilder::new()
532                .with_url(url.to_string())
533                .with_credentials(Arc::new(provider));
534            if let Some(handle) = &self.io_handle {
535                builder = builder.with_http_connector(SpawnedReqwestConnector::new(handle.clone()));
536            }
537            let store = builder.build()?;
538            return Ok(Arc::new(store));
539        }
540
541        if as_aws(&credential).is_ok() {
542            let access_point = aws_access_point(&credential);
543            let provider = new_aws(self.creds.clone(), &credential, securable).await?;
544            let url = Url::parse(&credential.url).map_err(Error::from)?;
545            let mut builder = AmazonS3Builder::new()
546                .with_url(url.to_string())
547                .with_credentials(Arc::new(provider));
548            // Prefer an explicit override; otherwise honour `AWS_REGION`
549            // before falling back to the object_store default.
550            if let Some(region) = self
551                .aws_region
552                .clone()
553                .or_else(|| std::env::var("AWS_REGION").ok())
554            {
555                builder = builder.with_region(region);
556            }
557            // Where the server returns an S3 access-point ARN, use it as the
558            // bucket so SigV4 signatures match what STS authorised.
559            if let Some(ap) = access_point {
560                builder = builder.with_bucket_name(ap);
561            }
562            if let Some(handle) = &self.io_handle {
563                builder = builder.with_http_connector(SpawnedReqwestConnector::new(handle.clone()));
564            }
565            let store = builder.build()?;
566            return Ok(Arc::new(store));
567        }
568
569        if as_gcp(&credential).is_ok() {
570            let provider = new_gcp(self.creds.clone(), &credential, securable).await?;
571            let url = Url::parse(&credential.url).map_err(Error::from)?;
572            let mut builder = GoogleCloudStorageBuilder::new()
573                .with_url(url.to_string())
574                .with_credentials(Arc::new(provider));
575            if let Some(handle) = &self.io_handle {
576                builder = builder.with_http_connector(SpawnedReqwestConnector::new(handle.clone()));
577            }
578            let store = builder.build()?;
579            return Ok(Arc::new(store));
580        }
581
582        Err(
583            Error::InvalidCredential("Failed to match credential with storage type".to_string())
584                .into(),
585        )
586    }
587}
588
589/// Unified read/write operation used by [`UnityObjectStoreFactory::for_url`].
590///
591/// The factory translates this to the operation enum expected by each
592/// vending endpoint:
593///
594/// | `Operation`  | Volume        | Table       | Path             |
595/// |--------------|---------------|-------------|------------------|
596/// | `Read`       | `READ_VOLUME` | `READ`      | `PATH_READ`      |
597/// | `ReadWrite`  | `WRITE_VOLUME`| `READ_WRITE`| `PATH_READ_WRITE`|
598#[derive(Debug, Clone, Copy, PartialEq, Eq)]
599pub enum Operation {
600    Read,
601    ReadWrite,
602}
603
604impl Operation {
605    fn into_volume(self) -> VolumeOperation {
606        match self {
607            Operation::Read => VolumeOperation::Read,
608            Operation::ReadWrite => VolumeOperation::ReadWrite,
609        }
610    }
611
612    fn into_table(self) -> TableOperation {
613        match self {
614            Operation::Read => TableOperation::Read,
615            Operation::ReadWrite => TableOperation::ReadWrite,
616        }
617    }
618
619    fn into_path(self) -> PathOperation {
620        match self {
621            Operation::Read => PathOperation::Read,
622            Operation::ReadWrite => PathOperation::ReadWrite,
623        }
624    }
625}
626
627impl From<Operation> for TableOperation {
628    fn from(op: Operation) -> Self {
629        op.into_table()
630    }
631}
632
633impl From<Operation> for VolumeOperation {
634    fn from(op: Operation) -> Self {
635        op.into_volume()
636    }
637}
638
639impl From<Operation> for PathOperation {
640    fn from(op: Operation) -> Self {
641        op.into_path()
642    }
643}
644
645/// Build a [`UCStore`] backed by the local filesystem for a `file://` URL,
646/// bypassing credential vending entirely.
647///
648/// Mirrors the bucket-root + prefix model of vended cloud stores: `root` is an
649/// unrooted [`LocalFileSystem`] (paths are absolute, relative to the filesystem
650/// root) and `path` is the URL's directory. This keeps both consumption modes
651/// correct:
652///
653/// - [`UCStore::as_dyn`] wraps `root` in a `PrefixStore` at the directory, so
654///   callers address paths *relative* to the `file://` directory; and
655/// - [`UCStore::root`] is the unrooted store, so the DataFusion routing store
656///   — which forwards the full request path unchanged — resolves absolute
657///   table paths.
658///
659/// For [`PathOperation::ReadWrite`] / [`PathOperation::CreateTable`] the target
660/// directory is created if missing, so writes to a fresh local root succeed.
661///
662/// # Platform support
663///
664/// Local `file://` storage is **POSIX-only** for now. On Windows it returns an
665/// error: `object_store::path::Path` cannot represent a Windows absolute path
666/// (the drive-letter colon is percent-encoded), so an unrooted
667/// [`LocalFileSystem`] addressed by full path does not resolve back to the real
668/// on-disk location. Tracked for a follow-up; cloud schemes are unaffected.
669fn local_store(url: &Url, operation: PathOperation) -> Result<UCStore> {
670    if cfg!(windows) {
671        return Err(Error::invalid_config(format!(
672            "local (file://) storage is not supported on Windows: {url}"
673        ))
674        .into());
675    }
676
677    let dir = url
678        .to_file_path()
679        .map_err(|_| Error::invalid_url(format!("not a valid local file path: {url}")))?;
680
681    if matches!(
682        operation,
683        PathOperation::ReadWrite | PathOperation::CreateTable
684    ) {
685        std::fs::create_dir_all(&dir).map_err(|e| {
686            Error::invalid_config(format!("failed to create local directory {dir:?}: {e}"))
687        })?;
688    }
689
690    // Unrooted: object_store `Path`s are relative to the filesystem root, so a
691    // full path like `tmp/data/part-0` resolves to `/tmp/data/part-0`. The
692    // directory is carried as the `UCStore` prefix instead (see `build_store`).
693    let store = LocalFileSystem::new();
694    let path = Path::from_url_path(url.path())?;
695    Ok(UCStore {
696        root: Arc::new(store),
697        url: url.clone(),
698        path,
699    })
700}
701
702/// Parsed components of an Azurite (Azure Blob emulator) storage URL.
703///
704/// Azurite is path-style — the account, container, and blob prefix are all
705/// segments of the path rather than the host (real Azure encodes the account in
706/// the host: `https://<account>.blob.core.windows.net/<container>/<prefix>`).
707struct AzuriteLocation {
708    account: String,
709    container: String,
710    prefix: String,
711}
712
713/// Detect and parse an Azurite endpoint from a storage URL.
714///
715/// Recognised forms (mirroring the server's `is_azurite` / `StorageLocationUrl`):
716/// - `http://127.0.0.1:10000/<account>/<container>/<prefix>` (default Azurite
717///   blob endpoint on localhost / 127.0.0.1, port 10000), and
718/// - `azurite://<container>/<prefix>` (custom scheme; the account is implicit,
719///   defaulting to the well-known emulator account).
720///
721/// Returns `None` for any non-Azurite URL so the caller falls through to the
722/// real-Azure path. `object_store`'s `MicrosoftAzureBuilder::with_url` does not
723/// understand either form, so the Azurite branch must set account/container
724/// explicitly rather than going through `with_url`.
725fn parse_azurite(url: &Url) -> Option<AzuriteLocation> {
726    const EMULATOR_ACCOUNT: &str = "devstoreaccount1";
727
728    if url.scheme() == "azurite" {
729        // azurite://<container>/<prefix>
730        let container = url.host_str().filter(|s| !s.is_empty())?.to_owned();
731        let prefix = url.path().trim_start_matches('/').to_owned();
732        return Some(AzuriteLocation {
733            account: EMULATOR_ACCOUNT.to_owned(),
734            container,
735            prefix,
736        });
737    }
738
739    let is_localhost = matches!(url.host_str(), Some("localhost") | Some("127.0.0.1"));
740    if url.scheme() == "http" && is_localhost && url.port() == Some(10000) {
741        // http://127.0.0.1:10000/<account>/<container>/<prefix>
742        let mut segments = url.path_segments()?;
743        let account = segments.next().filter(|s| !s.is_empty())?.to_owned();
744        let container = segments.next().filter(|s| !s.is_empty())?.to_owned();
745        let prefix = segments.collect::<Vec<_>>().join("/");
746        return Some(AzuriteLocation {
747            account,
748            container,
749            prefix,
750        });
751    }
752
753    None
754}
755
756/// Extract the raw SAS query string (`k1=v1&k2=v2&…`) from a vended credential,
757/// if it carries an Azure User Delegation / service SAS.
758fn azure_sas_token(credential: &TemporaryCredential) -> Option<String> {
759    use unitycatalog_common::temporary_credentials::v1::temporary_credential::Credentials;
760    match credential.credentials.as_ref()? {
761        Credentials::AzureUserDelegationSas(sas) => Some(sas.sas_token.clone()),
762        _ => None,
763    }
764}
765
766/// Returns a [`UCStore`] whose prefix is `store.prefix() + extra`, leaving
767/// the underlying bucket-rooted store untouched.
768fn extend_prefix(store: UCStore, extra: &str) -> UCStore {
769    let mut url = store.url.clone();
770    // Append the extra path component(s), keeping the trailing slash.
771    {
772        let mut segs = url.path_segments_mut().expect("cloud URL has a path");
773        segs.pop_if_empty();
774        for part in extra.split('/').filter(|p| !p.is_empty()) {
775            segs.push(part);
776        }
777    }
778    let new_path = if store.path.as_ref().is_empty() {
779        Path::from(extra)
780    } else {
781        let base = store.path.as_ref().trim_end_matches('/');
782        let extra = extra.trim_start_matches('/');
783        Path::from(format!("{base}/{extra}"))
784    };
785    UCStore {
786        root: store.root,
787        url,
788        path: new_path,
789    }
790}
791
792#[cfg(test)]
793mod tests {
794    use futures::TryStreamExt;
795    // `put`/`PutPayload` are only used by the POSIX-only local-storage tests.
796    #[cfg(not(windows))]
797    use object_store::{ObjectStoreExt, PutPayload};
798
799    use super::*;
800
801    /// A factory whose UC endpoint points nowhere reachable. Any code path that
802    /// tries to vend a credential will fail to connect — so a successful local
803    /// operation proves vending was skipped entirely.
804    async fn offline_factory() -> UnityObjectStoreFactory {
805        UnityObjectStoreFactory::builder()
806            // An unroutable, non-listening endpoint: a vend attempt cannot succeed.
807            .with_uri("http://127.0.0.1:0/api/2.1/unity-catalog/")
808            .with_allow_unauthenticated(true)
809            .build()
810            .await
811            .unwrap()
812    }
813
814    /// `file://` URLs are served by a local store and round-trip read/write/list
815    /// without any credential-vending call (the factory points at a dead endpoint).
816    // Local file:// storage is POSIX-only for now (see `local_store`).
817    #[cfg(not(windows))]
818    #[tokio::test]
819    async fn for_url_file_roundtrips_without_vending() {
820        let dir = tempfile::tempdir().unwrap();
821        let url = Url::from_directory_path(dir.path()).unwrap();
822
823        let factory = offline_factory().await;
824        let store = factory
825            .for_url(url.as_str(), Operation::ReadWrite)
826            .await
827            .unwrap();
828
829        let dyn_store = store.as_dyn();
830        let path = object_store::path::Path::from("hello.txt");
831        dyn_store
832            .put(&path, PutPayload::from_static(b"world"))
833            .await
834            .unwrap();
835
836        let listing: Vec<_> = dyn_store.list(None).try_collect().await.unwrap();
837        assert_eq!(listing.len(), 1, "expected exactly one object");
838        assert_eq!(listing[0].location, path);
839
840        let got = dyn_store.get(&path).await.unwrap().bytes().await.unwrap();
841        assert_eq!(&got[..], b"world");
842    }
843
844    /// `for_path` with a `file://` URL returns a usable store with no network call.
845    #[cfg(not(windows))]
846    #[tokio::test]
847    async fn for_path_file_skips_vending() {
848        let dir = tempfile::tempdir().unwrap();
849        let url = Url::from_directory_path(dir.path()).unwrap();
850
851        let factory = offline_factory().await;
852        // Read-only: the directory already exists, nothing is created.
853        let store = factory.for_path(&url, PathOperation::Read).await.unwrap();
854        // Empty directory lists to nothing — and crucially, no vend was attempted.
855        let listing: Vec<_> = store.as_dyn().list(None).try_collect().await.unwrap();
856        assert!(listing.is_empty());
857        assert_eq!(store.url(), &url);
858    }
859
860    /// A read-write local store auto-creates a missing target directory.
861    #[cfg(not(windows))]
862    #[tokio::test]
863    async fn local_store_read_write_creates_dir() {
864        let dir = tempfile::tempdir().unwrap();
865        let missing = dir.path().join("not-yet-here");
866        let url = Url::from_directory_path(&missing).unwrap();
867
868        let store = local_store(&url, PathOperation::ReadWrite).unwrap();
869        assert!(missing.exists(), "ReadWrite must create the root directory");
870
871        let path = object_store::path::Path::from("a.bin");
872        store
873            .as_dyn()
874            .put(&path, PutPayload::from_static(b"x"))
875            .await
876            .unwrap();
877        assert!(missing.join("a.bin").exists());
878    }
879
880    /// The unrooted `root()` store resolves the **full** path (the DataFusion
881    /// routing-store contract): a write addressed by the full absolute path
882    /// round-trips through that same path, and lands on disk under the table
883    /// directory.
884    ///
885    /// On-disk placement is checked with a real `read_dir` of the table
886    /// directory, not a native `PathBuf::join` against an object_store `Path`.
887    /// POSIX-only: local file:// is gated off on Windows (see `local_store`),
888    /// where an unrooted store cannot round-trip a drive-letter absolute path.
889    #[cfg(not(windows))]
890    #[tokio::test]
891    async fn local_store_root_resolves_full_path() {
892        let dir = tempfile::tempdir().unwrap();
893        let table_dir = dir.path().join("mytable");
894        let url = Url::from_directory_path(&table_dir).unwrap();
895
896        let store = local_store(&url, PathOperation::ReadWrite).unwrap();
897
898        // `prefix()` is the table directory; the routing store registers and
899        // forwards the full path beneath it unchanged.
900        let full = Path::from(format!("{}/part-0.parquet", store.prefix()));
901        store
902            .root()
903            .put(&full, PutPayload::from_static(b"data"))
904            .await
905            .unwrap();
906
907        // Reading back the same full path proves the unrooted store resolves it
908        // consistently — exactly what the DataFusion routing store relies on.
909        let got = store
910            .root()
911            .get(&full)
912            .await
913            .unwrap()
914            .bytes()
915            .await
916            .unwrap();
917        assert_eq!(&got[..], b"data");
918
919        // And it landed on disk under the table directory. Check the real
920        // filesystem directly (not via an object_store `Path`) so the assertion
921        // is OS-agnostic.
922        let entries: Vec<_> = std::fs::read_dir(&table_dir)
923            .unwrap()
924            .map(|e| e.unwrap().file_name())
925            .collect();
926        assert_eq!(entries, vec![std::ffi::OsString::from("part-0.parquet")]);
927    }
928
929    /// A non-`file` URL handed to the local helper is a clean error, not a panic.
930    /// POSIX-only: on Windows `local_store` rejects every call up front, so the
931    /// specific "not a valid local file path" message does not apply.
932    #[cfg(not(windows))]
933    #[test]
934    fn local_store_rejects_non_file_url() {
935        let url = Url::parse("s3://bucket/prefix/").unwrap();
936        match local_store(&url, PathOperation::Read) {
937            Ok(_) => panic!("expected an error for a non-file URL"),
938            Err(e) => assert!(
939                e.to_string().contains("not a valid local file path"),
940                "unexpected error: {e}"
941            ),
942        }
943    }
944
945    /// Building a factory with a dedicated I/O runtime handle succeeds and the
946    /// handle is carried through to the factory. The `None` path (no handle) is
947    /// exercised everywhere else and must remain the default.
948    ///
949    /// A plain `#[test]` (not `#[tokio::test]`) so the dedicated I/O `Runtime`
950    /// can be dropped here — dropping a `Runtime` inside an async context panics.
951    #[test]
952    fn build_with_io_runtime_carries_handle() {
953        // A dedicated I/O runtime — the canonical segregation pattern (mirrors
954        // object_store's own spawn-connector test).
955        let io_runtime = tokio::runtime::Builder::new_current_thread()
956            .enable_all()
957            .build()
958            .unwrap();
959        let handle = io_runtime.handle().clone();
960
961        // Drive `build()` on a separate, lightweight runtime so this test owns
962        // (and can safely drop) `io_runtime` itself.
963        let driver = tokio::runtime::Builder::new_current_thread()
964            .enable_all()
965            .build()
966            .unwrap();
967
968        driver.block_on(async {
969            let factory = UnityObjectStoreFactory::builder()
970                .with_uri("http://localhost:8080/api/2.1/unity-catalog/")
971                .with_allow_unauthenticated(true)
972                .with_io_runtime(handle.clone())
973                .build()
974                .await
975                .unwrap();
976            assert!(factory.io_handle.is_some());
977
978            // Clearing via `None` is honoured.
979            let factory = UnityObjectStoreFactory::builder()
980                .with_uri("http://localhost:8080/api/2.1/unity-catalog/")
981                .with_allow_unauthenticated(true)
982                .with_io_runtime(handle.clone())
983                .with_io_runtime(None)
984                .build()
985                .await
986                .unwrap();
987            assert!(factory.io_handle.is_none());
988        });
989    }
990
991    /// Live test against a Databricks workspace. Requires
992    /// `DATABRICKS_HOST` + `DATABRICKS_TOKEN` to be set. Marked `#[ignore]`
993    /// because CI shouldn't hit a real workspace.
994    ///
995    /// The calling runtime is a current-thread runtime with **I/O disabled**
996    /// (no `enable_all`); a successful `list` therefore proves every request
997    /// was spawned onto the separate, I/O-enabled runtime via the connector —
998    /// exactly the assertion object_store's own spawn-connector test makes.
999    #[test]
1000    #[ignore]
1001    fn list_store_via_temp_credential_on_io_runtime() {
1002        let databricks_host = std::env::var("DATABRICKS_HOST").unwrap();
1003        let databricks_token = std::env::var("DATABRICKS_TOKEN").unwrap();
1004
1005        // Dedicated, I/O-enabled runtime on its own thread.
1006        let io_runtime = std::thread::spawn(|| {
1007            tokio::runtime::Builder::new_multi_thread()
1008                .enable_all()
1009                .build()
1010                .unwrap()
1011        })
1012        .join()
1013        .unwrap();
1014        let io_handle = io_runtime.handle().clone();
1015
1016        // Calling runtime: current-thread, no I/O driver enabled. Any request
1017        // not spawned onto `io_handle` would panic ("no reactor running").
1018        let main_runtime = tokio::runtime::Builder::new_current_thread()
1019            .build()
1020            .unwrap();
1021
1022        main_runtime.block_on(async move {
1023            let factory = UnityObjectStoreFactory::builder()
1024                .with_uri(format!("{databricks_host}/api/2.1/unity-catalog/"))
1025                .with_token(databricks_token)
1026                .with_aws_region("eu-north-1".to_string())
1027                .with_io_runtime(io_handle)
1028                .build()
1029                .await
1030                .unwrap();
1031
1032            let volume_path = url::Url::parse("s3://open-lakehouse-dev/volumes/").unwrap();
1033            let store = factory
1034                .for_path(&volume_path, PathOperation::Read)
1035                .await
1036                .unwrap();
1037            let files: Vec<_> = store.as_dyn().list(None).try_collect().await.unwrap();
1038            println!("files: {files:?}");
1039        });
1040    }
1041
1042    #[test]
1043    fn parse_azurite_path_style() {
1044        let url =
1045            Url::parse("http://127.0.0.1:10000/devstoreaccount1/mycontainer/some/prefix").unwrap();
1046        let loc = parse_azurite(&url).expect("should parse path-style azurite URL");
1047        assert_eq!(loc.account, "devstoreaccount1");
1048        assert_eq!(loc.container, "mycontainer");
1049        assert_eq!(loc.prefix, "some/prefix");
1050    }
1051
1052    #[test]
1053    fn parse_azurite_custom_scheme() {
1054        let url = Url::parse("azurite://mycontainer/some/prefix").unwrap();
1055        let loc = parse_azurite(&url).expect("should parse azurite:// URL");
1056        assert_eq!(loc.account, "devstoreaccount1");
1057        assert_eq!(loc.container, "mycontainer");
1058        assert_eq!(loc.prefix, "some/prefix");
1059    }
1060
1061    #[test]
1062    fn parse_azurite_localhost_alias() {
1063        let url = Url::parse("http://localhost:10000/acct/cont/p").unwrap();
1064        let loc = parse_azurite(&url).expect("localhost is also an azurite host");
1065        assert_eq!(loc.account, "acct");
1066        assert_eq!(loc.container, "cont");
1067        assert_eq!(loc.prefix, "p");
1068    }
1069
1070    #[test]
1071    fn parse_azurite_rejects_real_cloud_urls() {
1072        // Real Azure, S3, and a non-Azurite localhost port must all be `None`.
1073        for raw in [
1074            "https://acct.blob.core.windows.net/container/path",
1075            "s3://bucket/prefix",
1076            "http://127.0.0.1:9000/acct/cont/p", // not the Azurite port
1077        ] {
1078            let url = Url::parse(raw).unwrap();
1079            assert!(parse_azurite(&url).is_none(), "should not match: {raw}");
1080        }
1081    }
1082
1083    /// Building a store from an Azurite SAS credential targets the emulator and
1084    /// roots the prefix at the blob path within the container (not the full URL
1085    /// path, which also carries `/<account>/<container>`).
1086    #[tokio::test]
1087    async fn azurite_store_targets_emulator_and_roots_prefix() {
1088        use unitycatalog_common::temporary_credentials::v1::{
1089            AzureUserDelegationSas, temporary_credential::Credentials,
1090        };
1091
1092        let url = "http://127.0.0.1:10000/devstoreaccount1/mycontainer/tbl/data";
1093        let credential = TemporaryCredential {
1094            expiration_time: now_epoch_millis() + 3_600_000,
1095            url: url.to_string(),
1096            credentials: Some(Credentials::AzureUserDelegationSas(
1097                AzureUserDelegationSas {
1098                    // A minimal, well-formed SAS query string. `split_sas` parses it
1099                    // into query pairs; the emulator never sees it in this test.
1100                    sas_token: "sv=2021-08-06&ss=b&srt=co&sp=rl&se=2999-01-01T00:00:00Z&sig=AAAA"
1101                        .to_string(),
1102                },
1103            )),
1104        };
1105
1106        let factory = offline_factory().await;
1107        let securable =
1108            SecurableRef::Path(Url::parse(url).unwrap(), PathOperation::Read, Some(false));
1109        // `build_store` does no network I/O — it only constructs the emulator
1110        // store and computes the prefix — so the dead-endpoint factory is fine.
1111        let store = factory.build_store(credential, securable).await.unwrap();
1112
1113        // The container-relative blob prefix, with `/<account>/<container>` stripped.
1114        assert_eq!(store.prefix(), &Path::from("tbl/data"));
1115    }
1116
1117    /// `now_epoch_millis` is defined in the credential-vending crate, not here;
1118    /// the store crate just needs a future timestamp for the test credential.
1119    fn now_epoch_millis() -> i64 {
1120        use std::time::{SystemTime, UNIX_EPOCH};
1121        SystemTime::now()
1122            .duration_since(UNIX_EPOCH)
1123            .unwrap()
1124            .as_millis() as i64
1125    }
1126}