Skip to main content

laminar_core/storage_location/
mod.rs

1//! Provider-neutral parsing for object-store locations.
2//!
3//! Parsing is deliberately separate from client construction. A location is
4//! parsed once, then adapted to the URL and non-secret options expected by a
5//! particular storage consumer.
6
7use std::fmt;
8
9/// Cloud or local storage provider selected by a location URL.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum StorageProvider {
12    /// Amazon S3, including separately classified S3-compatible endpoints.
13    AwsS3,
14    /// Azure Blob Storage or Azure Data Lake Storage Gen2.
15    AzureAdls,
16    /// Google Cloud Storage.
17    Gcs,
18    /// Local filesystem.
19    Local,
20}
21
22impl StorageProvider {
23    /// Detect a recognized provider URI.
24    #[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    /// Detect a provider, retaining the historical local-path fallback.
33    #[must_use]
34    pub fn detect(location: &str) -> Self {
35        Self::detect_uri(location).unwrap_or(Self::Local)
36    }
37
38    /// Whether a URL names a shared object store.
39    #[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    /// Whether a URL directly names the S3 log-store family admitted by cluster policy.
45    ///
46    /// Endpoint and conditional-write preflight remains a separate mandatory check.
47    #[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    /// Whether this provider may require cloud authentication.
56    #[must_use]
57    pub const fn requires_credentials(self) -> bool {
58        !matches!(self, Self::Local)
59    }
60
61    /// Stable provider name suitable for diagnostics.
62    #[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/// Storage client that will consume a parsed location.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum StorageConsumer {
108    /// The `object_store` checkpoint client.
109    ObjectStore,
110    /// Delta Lake / delta-rs.
111    Delta,
112    /// Iceberg's `OpenDAL` storage implementation.
113    Iceberg,
114}
115
116/// Redacted description of a configured endpoint override.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct EndpointOverride {
119    scheme: String,
120    has_path: bool,
121}
122
123impl EndpointOverride {
124    /// Endpoint transport scheme.
125    #[must_use]
126    pub fn scheme(&self) -> &str {
127        &self.scheme
128    }
129
130    /// Whether the endpoint includes a non-root path.
131    #[must_use]
132    pub const fn has_path(&self) -> bool {
133        self.has_path
134    }
135
136    /// Whether transport encryption is disabled.
137    #[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/// Native or compatibility classification of a storage endpoint.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum StorageEndpointClass {
152    /// Provider-native endpoint selected by the downstream default.
153    Native,
154    /// Explicit S3-compatible endpoint such as `MinIO`.
155    S3Compatible,
156    /// Explicit Azure or GCS endpoint, including emulators.
157    CustomOrEmulator,
158    /// Local filesystem.
159    Local,
160}
161
162impl StorageEndpointClass {
163    /// Stable low-cardinality value suitable for tracing fields.
164    #[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/// A validated storage location with the authority components consumers need.
182#[derive(Clone, PartialEq, Eq)]
183pub struct StorageLocation {
184    /// Parsed provider.
185    pub provider: StorageProvider,
186    /// Original, credential-free URL.
187    pub original_url: String,
188    /// Lowercase input scheme.
189    pub original_scheme: String,
190    /// Provider's canonical scheme (`s3`, `gs`, `az`, or `file`).
191    pub canonical_scheme: String,
192    /// Bucket, container, or Azure filesystem.
193    pub bucket_or_container: String,
194    /// Azure storage account from a fully qualified Hadoop-style URL.
195    pub account: Option<String>,
196    /// Azure filesystem/container from a fully qualified Hadoop-style URL.
197    pub filesystem: Option<String>,
198    /// Raw object prefix without the authority's first `/`.
199    pub prefix: String,
200    /// Redacted explicit endpoint information, when attached by configuration resolution.
201    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/// Consumer-ready URL plus non-secret properties derived from its authority.
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct AdaptedStorageLocation {
236    /// URL accepted by the selected consumer.
237    pub url: String,
238    /// Non-secret options required to preserve provider authority semantics.
239    pub derived_options: Vec<(String, String)>,
240}
241
242/// Errors from parsing or adapting a storage location.
243#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
244pub enum StorageLocationError {
245    /// The value is not a valid absolute URL.
246    #[error("invalid storage URL: {0}")]
247    InvalidUrl(String),
248    /// The scheme is outside `LaminarDB`'s object-store support boundary.
249    #[error("unsupported storage URL scheme '{0}'")]
250    UnsupportedScheme(String),
251    /// A bucket, container, or host is absent.
252    #[error("{0} storage URL is missing its bucket or container authority")]
253    MissingAuthority(&'static str),
254    /// User information is forbidden in storage URLs.
255    #[error("storage URLs must not embed credentials or user-info")]
256    EmbeddedCredentials,
257    /// Query parameters are forbidden because they commonly contain signed credentials.
258    #[error("storage URLs must not contain query parameters; configure credentials separately")]
259    QueryNotAllowed,
260    /// Fragments have no object-store semantics.
261    #[error("storage URLs must not contain fragments")]
262    FragmentNotAllowed,
263    /// A local URL does not identify an absolute local path.
264    #[error("file URL must identify an absolute local path without a remote authority")]
265    InvalidFileUrl,
266    /// A Hadoop-style Azure authority is incomplete or contradictory.
267    #[error("invalid Azure storage authority: {0}")]
268    InvalidAzureAuthority(&'static str),
269    /// The parsed provider cannot be represented safely for a particular consumer.
270    #[error("{consumer} does not support {scheme} locations in this build: {reason}")]
271    ConsumerUnsupported {
272        /// Consumer name.
273        consumer: &'static str,
274        /// Input scheme.
275        scheme: String,
276        /// Non-secret corrective guidance.
277        reason: &'static str,
278    },
279    /// An endpoint override is malformed or unsafe to retain in diagnostics.
280    #[error("invalid storage endpoint override: {0}")]
281    InvalidEndpoint(&'static str),
282}
283
284impl StorageLocation {
285    /// Parse a supported, credential-free absolute storage URL.
286    ///
287    /// Paths are retained from the input rather than reserialized through
288    /// `url::Url`, preserving repeated slashes and dot segments.
289    ///
290    /// # Errors
291    ///
292    /// Returns a typed error for unsupported schemes, malformed authorities,
293    /// embedded credentials, signed queries, or non-absolute file URLs.
294    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    /// Attach a redacted endpoint override classification.
335    ///
336    /// # Errors
337    ///
338    /// Returns an error for credentials, queries, fragments, or non-HTTP(S) endpoints.
339    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    /// Classify the effective endpoint without exposing its hostname.
368    #[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    /// Adapt this location to a downstream storage consumer.
391    ///
392    /// # Errors
393    ///
394    /// Returns an error when the selected consumer cannot safely represent the
395    /// location, such as an unqualified Azure path for Iceberg/OpenDAL.
396    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;