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}