1use std::fmt;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum StorageProvider {
12 AwsS3,
14 AzureAdls,
16 Gcs,
18 Local,
20}
21
22impl StorageProvider {
23 #[must_use]
25 pub fn detect_uri(location: &str) -> Option<Self> {
26 if let Ok(parsed) = url::Url::parse(location) {
27 return provider_for_scheme(parsed.scheme());
28 }
29 lexical_url_scheme(location).and_then(provider_for_scheme)
30 }
31
32 #[must_use]
34 pub fn detect(location: &str) -> Self {
35 Self::detect_uri(location).unwrap_or(Self::Local)
36 }
37
38 #[must_use]
40 pub fn is_shared_uri(location: &str) -> bool {
41 Self::detect_uri(location).is_some_and(|provider| provider != Self::Local)
42 }
43
44 #[must_use]
48 pub fn is_direct_s3_uri(location: &str) -> bool {
49 StorageLocation::parse(location).is_ok_and(|parsed| {
50 parsed.provider == Self::AwsS3
51 && matches!(parsed.original_scheme.as_str(), "s3" | "s3a")
52 })
53 }
54
55 #[must_use]
57 pub const fn requires_credentials(self) -> bool {
58 !matches!(self, Self::Local)
59 }
60
61 #[must_use]
63 pub const fn name(self) -> &'static str {
64 match self {
65 Self::AwsS3 => "AWS S3",
66 Self::AzureAdls => "Azure ADLS",
67 Self::Gcs => "Google Cloud Storage",
68 Self::Local => "Local Filesystem",
69 }
70 }
71}
72
73fn provider_for_scheme(scheme: &str) -> Option<StorageProvider> {
74 match scheme.to_ascii_lowercase().as_str() {
75 "s3" | "s3a" => Some(StorageProvider::AwsS3),
76 "gs" | "gcs" => Some(StorageProvider::Gcs),
77 "az" | "abfs" | "abfss" | "wasb" | "wasbs" => Some(StorageProvider::AzureAdls),
78 "file" => Some(StorageProvider::Local),
79 _ => None,
80 }
81}
82
83fn lexical_url_scheme(location: &str) -> Option<&str> {
84 let (scheme, remainder) = location.split_once(':')?;
85 if !remainder.starts_with("//") {
86 return None;
87 }
88 let mut chars = scheme.chars();
89 if !chars
90 .next()
91 .is_some_and(|first| first.is_ascii_alphabetic())
92 || !chars.all(|character| character.is_ascii_alphanumeric() || "+-.".contains(character))
93 {
94 return None;
95 }
96 Some(scheme)
97}
98
99impl fmt::Display for StorageProvider {
100 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
101 formatter.write_str(self.name())
102 }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum StorageConsumer {
108 ObjectStore,
110 Delta,
112 Iceberg,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct EndpointOverride {
119 scheme: String,
120 has_path: bool,
121}
122
123impl EndpointOverride {
124 #[must_use]
126 pub fn scheme(&self) -> &str {
127 &self.scheme
128 }
129
130 #[must_use]
132 pub const fn has_path(&self) -> bool {
133 self.has_path
134 }
135
136 #[must_use]
138 pub fn uses_http(&self) -> bool {
139 self.scheme == "http"
140 }
141}
142
143impl fmt::Display for EndpointOverride {
144 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
145 write!(formatter, "<custom-{}-endpoint>", self.scheme)
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum StorageEndpointClass {
152 Native,
154 S3Compatible,
156 CustomOrEmulator,
158 Local,
160}
161
162impl StorageEndpointClass {
163 #[must_use]
165 pub const fn name(self) -> &'static str {
166 match self {
167 Self::Native => "native",
168 Self::S3Compatible => "s3-compatible",
169 Self::CustomOrEmulator => "custom-or-emulator",
170 Self::Local => "local",
171 }
172 }
173}
174
175impl fmt::Display for StorageEndpointClass {
176 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
177 formatter.write_str(self.name())
178 }
179}
180
181#[derive(Clone, PartialEq, Eq)]
183pub struct StorageLocation {
184 pub provider: StorageProvider,
186 pub original_url: String,
188 pub original_scheme: String,
190 pub canonical_scheme: String,
192 pub bucket_or_container: String,
194 pub account: Option<String>,
196 pub filesystem: Option<String>,
198 pub prefix: String,
200 pub endpoint_override: Option<EndpointOverride>,
202 authority: String,
203 azure_service: Option<String>,
204 azure_endpoint_suffix: Option<String>,
205}
206
207impl fmt::Debug for StorageLocation {
208 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
209 formatter
210 .debug_struct("StorageLocation")
211 .field("provider", &self.provider)
212 .field("original_url", &"<redacted-location>")
213 .field("original_scheme", &self.original_scheme)
214 .field("canonical_scheme", &self.canonical_scheme)
215 .field("bucket_or_container", &"<configured>")
216 .field("account", &self.account.as_ref().map(|_| "<configured>"))
217 .field(
218 "filesystem",
219 &self.filesystem.as_ref().map(|_| "<configured>"),
220 )
221 .field("prefix", &"<configured>")
222 .field("endpoint_override", &self.endpoint_override)
223 .finish_non_exhaustive()
224 }
225}
226
227impl fmt::Display for StorageLocation {
228 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
229 write!(formatter, "{} ({})", self.provider, self.original_scheme)
230 }
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct AdaptedStorageLocation {
236 pub url: String,
238 pub derived_options: Vec<(String, String)>,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
244pub enum StorageLocationError {
245 #[error("invalid storage URL: {0}")]
247 InvalidUrl(String),
248 #[error("unsupported storage URL scheme '{0}'")]
250 UnsupportedScheme(String),
251 #[error("{0} storage URL is missing its bucket or container authority")]
253 MissingAuthority(&'static str),
254 #[error("storage URLs must not embed credentials or user-info")]
256 EmbeddedCredentials,
257 #[error("storage URLs must not contain query parameters; configure credentials separately")]
259 QueryNotAllowed,
260 #[error("storage URLs must not contain fragments")]
262 FragmentNotAllowed,
263 #[error("file URL must identify an absolute local path without a remote authority")]
265 InvalidFileUrl,
266 #[error("invalid Azure storage authority: {0}")]
268 InvalidAzureAuthority(&'static str),
269 #[error("{consumer} does not support {scheme} locations in this build: {reason}")]
271 ConsumerUnsupported {
272 consumer: &'static str,
274 scheme: String,
276 reason: &'static str,
278 },
279 #[error("invalid storage endpoint override: {0}")]
281 InvalidEndpoint(&'static str),
282}
283
284impl StorageLocation {
285 pub fn parse(input: &str) -> Result<Self, StorageLocationError> {
295 let parsed = url::Url::parse(input)
296 .map_err(|error| StorageLocationError::InvalidUrl(error.to_string()))?;
297 if parsed.query().is_some() {
298 return Err(StorageLocationError::QueryNotAllowed);
299 }
300 if parsed.fragment().is_some() {
301 return Err(StorageLocationError::FragmentNotAllowed);
302 }
303 let (raw_scheme, authority, prefix) = raw_url_components(input)?;
304 let original_scheme = raw_scheme.to_ascii_lowercase();
305 match original_scheme.as_str() {
306 "s3" | "s3a" => Self::parse_bucket(
307 input,
308 &parsed,
309 original_scheme,
310 authority,
311 prefix,
312 StorageProvider::AwsS3,
313 "s3",
314 "S3",
315 ),
316 "gs" | "gcs" => Self::parse_bucket(
317 input,
318 &parsed,
319 original_scheme,
320 authority,
321 prefix,
322 StorageProvider::Gcs,
323 "gs",
324 "GCS",
325 ),
326 "az" | "abfs" | "abfss" | "wasb" | "wasbs" => {
327 Self::parse_azure(input, &parsed, original_scheme, authority, prefix)
328 }
329 "file" => Self::parse_file(input, &parsed, original_scheme, authority, prefix),
330 _ => Err(StorageLocationError::UnsupportedScheme(original_scheme)),
331 }
332 }
333
334 pub fn with_endpoint_override(mut self, endpoint: &str) -> Result<Self, StorageLocationError> {
340 let parsed = url::Url::parse(endpoint)
341 .map_err(|_| StorageLocationError::InvalidEndpoint("expected an absolute URL"))?;
342 if !matches!(parsed.scheme(), "http" | "https") {
343 return Err(StorageLocationError::InvalidEndpoint(
344 "only http and https endpoints are supported",
345 ));
346 }
347 if !parsed.username().is_empty() || parsed.password().is_some() {
348 return Err(StorageLocationError::InvalidEndpoint(
349 "credentials and user-info are forbidden",
350 ));
351 }
352 if parsed.host_str().is_none() {
353 return Err(StorageLocationError::InvalidEndpoint("host is required"));
354 }
355 if parsed.query().is_some() || parsed.fragment().is_some() {
356 return Err(StorageLocationError::InvalidEndpoint(
357 "query parameters and fragments are forbidden",
358 ));
359 }
360 self.endpoint_override = Some(EndpointOverride {
361 scheme: parsed.scheme().to_string(),
362 has_path: !matches!(parsed.path(), "" | "/"),
363 });
364 Ok(self)
365 }
366
367 #[must_use]
369 pub fn endpoint_class(&self) -> StorageEndpointClass {
370 let custom_azure_authority = self.provider == StorageProvider::AzureAdls
371 && self
372 .azure_endpoint_suffix
373 .as_deref()
374 .is_some_and(|suffix| !is_native_azure_endpoint_suffix(suffix));
375 match (self.provider, self.endpoint_override.is_some()) {
376 (StorageProvider::Local, _) => StorageEndpointClass::Local,
377 (StorageProvider::AwsS3, true) => StorageEndpointClass::S3Compatible,
378 (StorageProvider::AzureAdls | StorageProvider::Gcs, true) => {
379 StorageEndpointClass::CustomOrEmulator
380 }
381 (StorageProvider::AzureAdls, false) if custom_azure_authority => {
382 StorageEndpointClass::CustomOrEmulator
383 }
384 (StorageProvider::AwsS3 | StorageProvider::AzureAdls | StorageProvider::Gcs, false) => {
385 StorageEndpointClass::Native
386 }
387 }
388 }
389
390 pub fn adapt(
397 &self,
398 consumer: StorageConsumer,
399 ) -> Result<AdaptedStorageLocation, StorageLocationError> {
400 match self.provider {
401 StorageProvider::AwsS3 => Ok(AdaptedStorageLocation {
402 url: self.rebuild(&self.original_scheme, &self.authority),
403 derived_options: Vec::new(),
404 }),
405 StorageProvider::Gcs => Ok(AdaptedStorageLocation {
406 url: self.rebuild("gs", &self.authority),
407 derived_options: Vec::new(),
408 }),
409 StorageProvider::AzureAdls => self.adapt_azure(consumer),
410 StorageProvider::Local => Ok(AdaptedStorageLocation {
411 url: format!("file://{}", raw_path(&self.prefix)),
412 derived_options: Vec::new(),
413 }),
414 }
415 }
416
417 fn parse_bucket(
418 input: &str,
419 parsed: &url::Url,
420 original_scheme: String,
421 authority: String,
422 prefix: String,
423 provider: StorageProvider,
424 canonical_scheme: &str,
425 provider_name: &'static str,
426 ) -> Result<Self, StorageLocationError> {
427 if !parsed.username().is_empty() || parsed.password().is_some() {
428 return Err(StorageLocationError::EmbeddedCredentials);
429 }
430 if parsed.port().is_some() {
431 return Err(StorageLocationError::InvalidUrl(
432 "storage URL authorities must not contain a port; use an endpoint override".into(),
433 ));
434 }
435 let bucket = parsed
436 .host_str()
437 .filter(|host| !host.is_empty())
438 .ok_or(StorageLocationError::MissingAuthority(provider_name))?;
439 Ok(Self {
440 provider,
441 original_url: input.to_string(),
442 original_scheme,
443 canonical_scheme: canonical_scheme.to_string(),
444 bucket_or_container: bucket.to_string(),
445 account: None,
446 filesystem: None,
447 prefix,
448 endpoint_override: None,
449 authority,
450 azure_service: None,
451 azure_endpoint_suffix: None,
452 })
453 }
454
455 fn parse_azure(
456 input: &str,
457 parsed: &url::Url,
458 original_scheme: String,
459 authority: String,
460 prefix: String,
461 ) -> Result<Self, StorageLocationError> {
462 if parsed.password().is_some() || authority.matches('@').count() > 1 {
463 return Err(StorageLocationError::EmbeddedCredentials);
464 }
465 if original_scheme == "az" && !parsed.username().is_empty() {
466 return Err(StorageLocationError::EmbeddedCredentials);
467 }
468 if parsed.port().is_some() {
469 return Err(StorageLocationError::InvalidAzureAuthority(
470 "ports belong in an explicit endpoint override",
471 ));
472 }
473 let host = parsed
474 .host_str()
475 .filter(|host| !host.is_empty())
476 .ok_or(StorageLocationError::MissingAuthority("Azure"))?;
477 let qualified = !parsed.username().is_empty();
478 let (container, account, service, suffix, filesystem) = if qualified {
479 let filesystem = parsed.username();
480 if filesystem.contains(':') || filesystem.is_empty() {
481 return Err(StorageLocationError::InvalidAzureAuthority(
482 "filesystem/container is missing",
483 ));
484 }
485 let mut host_parts = host.splitn(3, '.');
486 let account = host_parts.next().unwrap_or_default();
487 let service = host_parts.next();
488 let suffix = host_parts.next();
489 if account.is_empty() || service.is_some() != suffix.is_some() {
490 return Err(StorageLocationError::InvalidAzureAuthority(
491 "expected <filesystem>@<account>.<service>.<endpoint-suffix>",
492 ));
493 }
494 if let Some(service) = service {
495 let expected_service = match original_scheme.as_str() {
496 "abfs" | "abfss" => "dfs",
497 "wasb" | "wasbs" => "blob",
498 "az" => service,
499 _ => unreachable!("scheme matched before Azure parsing"),
500 };
501 if !matches!(service, "dfs" | "blob") || service != expected_service {
502 return Err(StorageLocationError::InvalidAzureAuthority(
503 "scheme and blob/dfs service are inconsistent",
504 ));
505 }
506 }
507 (
508 filesystem.to_string(),
509 Some(account.to_string()),
510 service.map(str::to_string),
511 suffix.map(str::to_string),
512 Some(filesystem.to_string()),
513 )
514 } else {
515 if host.contains('.') && original_scheme != "az" {
516 return Err(StorageLocationError::InvalidAzureAuthority(
517 "fully qualified Hadoop URLs require a filesystem/container before '@'",
518 ));
519 }
520 (host.to_string(), None, None, None, None)
521 };
522 Ok(Self {
523 provider: StorageProvider::AzureAdls,
524 original_url: input.to_string(),
525 original_scheme,
526 canonical_scheme: "az".to_string(),
527 bucket_or_container: container,
528 account,
529 filesystem,
530 prefix,
531 endpoint_override: None,
532 authority,
533 azure_service: service,
534 azure_endpoint_suffix: suffix,
535 })
536 }
537
538 fn parse_file(
539 input: &str,
540 parsed: &url::Url,
541 original_scheme: String,
542 authority: String,
543 prefix: String,
544 ) -> Result<Self, StorageLocationError> {
545 if !parsed.username().is_empty() || parsed.password().is_some() {
546 return Err(StorageLocationError::EmbeddedCredentials);
547 }
548 if parsed
549 .host_str()
550 .is_some_and(|host| !host.eq_ignore_ascii_case("localhost"))
551 || !parsed.path().starts_with('/')
552 || parsed.path() == "/"
553 {
554 return Err(StorageLocationError::InvalidFileUrl);
555 }
556 Ok(Self {
557 provider: StorageProvider::Local,
558 original_url: input.to_string(),
559 original_scheme,
560 canonical_scheme: "file".to_string(),
561 bucket_or_container: String::new(),
562 account: None,
563 filesystem: None,
564 prefix,
565 endpoint_override: None,
566 authority,
567 azure_service: None,
568 azure_endpoint_suffix: None,
569 })
570 }
571
572 fn adapt_azure(
573 &self,
574 consumer: StorageConsumer,
575 ) -> Result<AdaptedStorageLocation, StorageLocationError> {
576 if consumer == StorageConsumer::Iceberg {
577 if self.account.is_none()
578 || self.azure_service.is_none()
579 || self.azure_endpoint_suffix.is_none()
580 || !matches!(
581 self.original_scheme.as_str(),
582 "abfs" | "abfss" | "wasb" | "wasbs"
583 )
584 {
585 return Err(StorageLocationError::ConsumerUnsupported {
586 consumer: "Iceberg/OpenDAL",
587 scheme: self.original_scheme.clone(),
588 reason: "use a fully qualified abfs[s] or wasb[s] URL",
589 });
590 }
591 return Ok(AdaptedStorageLocation {
592 url: self.rebuild(&self.original_scheme, &self.authority),
593 derived_options: Vec::new(),
594 });
595 }
596
597 let Some(account) = &self.account else {
598 return Ok(AdaptedStorageLocation {
599 url: self.rebuild("az", &self.bucket_or_container),
600 derived_options: Vec::new(),
601 });
602 };
603 let Some(service) = self.azure_service.as_deref() else {
604 return Ok(AdaptedStorageLocation {
605 url: self.rebuild("az", &self.bucket_or_container),
606 derived_options: vec![
607 ("azure_storage_account_name".into(), account.clone()),
608 (
609 "azure_container_name".into(),
610 self.bucket_or_container.clone(),
611 ),
612 ],
613 });
614 };
615 let suffix = self.azure_endpoint_suffix.as_deref().ok_or(
616 StorageLocationError::InvalidAzureAuthority("endpoint suffix is missing"),
617 )?;
618 let transport = match self.original_scheme.as_str() {
619 "abfs" | "wasb" => "http",
620 "abfss" | "wasbs" | "az" => "https",
621 _ => unreachable!("scheme matched before Azure adaptation"),
622 };
623 Ok(AdaptedStorageLocation {
624 url: self.rebuild("az", &self.bucket_or_container),
625 derived_options: vec![
626 ("azure_storage_account_name".into(), account.clone()),
627 (
628 "azure_container_name".into(),
629 self.bucket_or_container.clone(),
630 ),
631 (
632 "azure_endpoint".into(),
633 format!("{transport}://{account}.{service}.{suffix}"),
634 ),
635 ],
636 })
637 }
638
639 fn rebuild(&self, scheme: &str, authority: &str) -> String {
640 format!("{scheme}://{authority}{}", raw_path(&self.prefix))
641 }
642}
643
644fn raw_url_components(input: &str) -> Result<(String, String, String), StorageLocationError> {
645 let separator = input.find("://").ok_or_else(|| {
646 StorageLocationError::InvalidUrl("absolute storage URL requires '://'".into())
647 })?;
648 let scheme = &input[..separator];
649 if scheme.is_empty() {
650 return Err(StorageLocationError::InvalidUrl("scheme is missing".into()));
651 }
652 let remainder = &input[separator + 3..];
653 let authority_end = remainder.find('/').unwrap_or(remainder.len());
654 let authority = &remainder[..authority_end];
655 let path = remainder.get(authority_end..).unwrap_or_default();
656 let prefix = path.strip_prefix('/').unwrap_or(path);
657 Ok((
658 scheme.to_string(),
659 authority.to_string(),
660 prefix.to_string(),
661 ))
662}
663
664fn raw_path(prefix: &str) -> String {
665 format!("/{prefix}")
666}
667
668fn is_native_azure_endpoint_suffix(suffix: &str) -> bool {
669 [
670 "core.windows.net",
671 "core.usgovcloudapi.net",
672 "core.chinacloudapi.cn",
673 "core.cloudapi.de",
674 "fabric.microsoft.com",
675 ]
676 .iter()
677 .any(|native| suffix.eq_ignore_ascii_case(native))
678}
679
680#[cfg(test)]
681mod tests;