Skip to main content

lance_namespace_impls/
credentials.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Credential vending for cloud storage access.
5//!
6//! This module provides credential vending functionality that generates
7//! temporary, scoped credentials for accessing cloud storage. Similar to
8//! Apache Polaris's credential vending, it supports:
9//!
10//! - **AWS**: STS AssumeRole with scoped IAM policies (requires `credential-vendor-aws` feature)
11//! - **GCP**: OAuth2 tokens with access boundaries (requires `credential-vendor-gcp` feature)
12//! - **Azure**: SAS tokens with user delegation keys (requires `credential-vendor-azure` feature)
13//!
14//! The appropriate vendor is automatically selected based on the table location URI scheme:
15//! - `s3://` for AWS
16//! - `gs://` for GCP
17//! - `az://` for Azure
18//!
19//! ## Configuration via Properties
20//!
21//! Credential vendors are configured via properties with the `credential_vendor.` prefix.
22//!
23//! ### Properties format:
24//!
25//! ```text
26//! # Required to enable credential vending
27//! credential_vendor.enabled = "true"
28//!
29//! # Common properties (apply to all providers)
30//! credential_vendor.permission = "read"          # read, write, or admin (default: read)
31//!
32//! # AWS-specific properties (for s3:// locations)
33//! credential_vendor.aws_role_arn = "arn:aws:iam::123456789012:role/MyRole"  # required for AWS
34//! credential_vendor.aws_external_id = "my-external-id"
35//! credential_vendor.aws_region = "us-west-2"
36//! credential_vendor.aws_role_session_name = "my-session"
37//! credential_vendor.aws_duration_millis = "3600000"  # 1 hour (default, range: 15min-12hrs)
38//!
39//! # GCP-specific properties (for gs:// locations)
40//! # Note: GCP token duration cannot be configured; it's determined by the STS endpoint
41//! # To use a service account key file, set GOOGLE_APPLICATION_CREDENTIALS env var before starting
42//! credential_vendor.gcp_service_account = "my-sa@project.iam.gserviceaccount.com"
43//! credential_vendor.gcp_workload_identity_provider = "projects/123456/locations/global/workloadIdentityPools/pool/providers/provider"
44//! credential_vendor.gcp_impersonation_service_account = "my-sa@project.iam.gserviceaccount.com"
45//!
46//! # Azure-specific properties (for az:// locations)
47//! credential_vendor.azure_account_name = "mystorageaccount"  # required for Azure
48//! credential_vendor.azure_tenant_id = "my-tenant-id"
49//! credential_vendor.azure_federated_client_id = "my-app-client-id"
50//! credential_vendor.azure_duration_millis = "3600000"  # 1 hour (default, up to 7 days)
51//! ```
52//!
53//! ### Example using ConnectBuilder:
54//!
55//! ```ignore
56//! ConnectBuilder::new("dir")
57//!     .property("root", "s3://bucket/path")
58//!     .property("credential_vendor.enabled", "true")
59//!     .property("credential_vendor.aws_role_arn", "arn:aws:iam::123456789012:role/MyRole")
60//!     .property("credential_vendor.permission", "read")
61//!     .connect()
62//!     .await?;
63//! ```
64
65#[cfg(feature = "credential-vendor-aws")]
66pub mod aws;
67
68#[cfg(feature = "credential-vendor-azure")]
69pub mod azure;
70
71#[cfg(feature = "credential-vendor-gcp")]
72pub mod gcp;
73
74/// Credential caching module.
75/// Available when any credential vendor feature is enabled.
76#[cfg(any(
77    feature = "credential-vendor-aws",
78    feature = "credential-vendor-azure",
79    feature = "credential-vendor-gcp"
80))]
81pub mod cache;
82
83use std::collections::HashMap;
84use std::str::FromStr;
85
86use async_trait::async_trait;
87use lance_core::Result;
88use lance_io::object_store::uri_to_url;
89use lance_namespace::models::Identity;
90
91/// Default credential duration: 1 hour (3600000 milliseconds)
92pub const DEFAULT_CREDENTIAL_DURATION_MILLIS: u64 = 3600 * 1000;
93
94/// Redact a credential string for logging, showing first and last few characters.
95///
96/// This is useful for debugging while avoiding exposure of sensitive data.
97/// Format: `AKIAIOSF***MPLE` (first 8 + "***" + last 4)
98///
99/// Shows 8 characters at the start (useful since AWS keys always start with AKIA/ASIA)
100/// and 4 characters at the end. For short strings, shows only the first few with "***".
101///
102/// # Security Note
103///
104/// This function should only be used for identifiers and tokens, never for secrets
105/// like `aws_secret_access_key` which should never be logged even in redacted form.
106pub fn redact_credential(credential: &str) -> String {
107    const SHOW_START: usize = 8;
108    const SHOW_END: usize = 4;
109    const MIN_LENGTH_FOR_BOTH_ENDS: usize = SHOW_START + SHOW_END + 4; // Need at least 16 chars
110
111    if credential.is_empty() {
112        return "[empty]".to_string();
113    }
114
115    if credential.len() < MIN_LENGTH_FOR_BOTH_ENDS {
116        // For short credentials, just show beginning
117        let show = credential.len().min(SHOW_START);
118        format!("{}***", &credential[..show])
119    } else {
120        // Show first 8 and last 4 characters
121        format!(
122            "{}***{}",
123            &credential[..SHOW_START],
124            &credential[credential.len() - SHOW_END..]
125        )
126    }
127}
128
129/// Permission level for vended credentials.
130///
131/// This determines what access the vended credentials will have:
132/// - `Read`: Read-only access to all table content
133/// - `Write`: Full read and write access (no delete)
134/// - `Admin`: Full read, write, and delete access
135///
136/// Permission enforcement by cloud provider:
137/// - **AWS**: Permissions are enforced via scoped IAM policies attached to the AssumeRole request
138/// - **Azure**: Permissions are enforced via SAS token permissions
139/// - **GCP**: Permissions are enforced via Credential Access Boundaries (CAB) that downscope
140///   the OAuth2 token to specific GCS IAM roles
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
142pub enum VendedPermission {
143    /// Read-only access to all table content (metadata, indices, data files)
144    #[default]
145    Read,
146    /// Full read and write access (no delete)
147    /// This is intended ONLY for testing purposes to generate a write-only permission set.
148    /// Technically, any user with write permission could "delete" the file by
149    /// overwriting the file with empty content.
150    /// So this cannot really prevent malicious use cases.
151    Write,
152    /// Full read, write, and delete access
153    Admin,
154}
155
156impl VendedPermission {
157    /// Returns true if this permission allows writing
158    pub fn can_write(&self) -> bool {
159        matches!(self, Self::Write | Self::Admin)
160    }
161
162    /// Returns true if this permission allows deleting
163    pub fn can_delete(&self) -> bool {
164        matches!(self, Self::Admin)
165    }
166}
167
168impl FromStr for VendedPermission {
169    type Err = String;
170
171    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
172        match s.to_lowercase().as_str() {
173            "read" => Ok(Self::Read),
174            "write" => Ok(Self::Write),
175            "admin" => Ok(Self::Admin),
176            _ => Err(format!(
177                "Invalid permission '{}'. Must be one of: read, write, admin",
178                s
179            )),
180        }
181    }
182}
183
184impl std::fmt::Display for VendedPermission {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        match self {
187            Self::Read => write!(f, "read"),
188            Self::Write => write!(f, "write"),
189            Self::Admin => write!(f, "admin"),
190        }
191    }
192}
193
194/// Property key prefix for credential vendor properties.
195/// Properties with this prefix are stripped when using `from_properties`.
196pub const PROPERTY_PREFIX: &str = "credential_vendor.";
197
198/// Common property key to explicitly enable credential vending (short form).
199pub const ENABLED: &str = "enabled";
200
201/// Common property key for permission level (short form).
202pub const PERMISSION: &str = "permission";
203
204/// Common property key to enable credential caching (short form).
205/// Default: true. Set to "false" to disable caching.
206pub const CACHE_ENABLED: &str = "cache_enabled";
207
208/// Common property key for API key salt (short form).
209/// Used to hash API keys before comparison: SHA256(api_key + ":" + salt)
210pub const API_KEY_SALT: &str = "api_key_salt";
211
212/// Property key prefix for API key hash to permission mappings (short form).
213/// Format: `api_key_hash.<sha256_hash> = "<permission>"`
214pub const API_KEY_HASH_PREFIX: &str = "api_key_hash.";
215
216/// AWS-specific property keys (short form, without prefix)
217#[cfg(feature = "credential-vendor-aws")]
218pub mod aws_props {
219    pub const ROLE_ARN: &str = "aws_role_arn";
220    pub const EXTERNAL_ID: &str = "aws_external_id";
221    pub const REGION: &str = "aws_region";
222    pub const ROLE_SESSION_NAME: &str = "aws_role_session_name";
223    /// AWS credential duration in milliseconds.
224    /// Default: 3600000 (1 hour). Range: 900000 (15 min) to 43200000 (12 hours).
225    pub const DURATION_MILLIS: &str = "aws_duration_millis";
226
227    /// When "true", the scoped assume is performed via `AssumeRoleWithWebIdentity`
228    /// using the pod's projected service-account OIDC token
229    pub const ASSUME_VIA_POD_WEB_IDENTITY: &str = "aws_assume_via_pod_web_identity";
230
231    /// Explicit path to the pod's projected SA OIDC token file. Overrides
232    /// `AWS_WEB_IDENTITY_TOKEN_FILE` when set.
233    pub const POD_WEB_IDENTITY_TOKEN_FILE: &str = "aws_pod_web_identity_token_file";
234}
235
236/// GCP-specific property keys (short form, without prefix)
237#[cfg(feature = "credential-vendor-gcp")]
238pub mod gcp_props {
239    pub const SERVICE_ACCOUNT: &str = "gcp_service_account";
240
241    /// Workload Identity Provider resource name for OIDC token exchange.
242    /// Format: //iam.googleapis.com/projects/{project}/locations/global/workloadIdentityPools/{pool}/providers/{provider}
243    pub const WORKLOAD_IDENTITY_PROVIDER: &str = "gcp_workload_identity_provider";
244
245    /// Service account to impersonate after Workload Identity Federation (optional).
246    /// If not set, uses the federated identity directly.
247    pub const IMPERSONATION_SERVICE_ACCOUNT: &str = "gcp_impersonation_service_account";
248}
249
250/// Azure-specific property keys (short form, without prefix)
251#[cfg(feature = "credential-vendor-azure")]
252pub mod azure_props {
253    pub const TENANT_ID: &str = "azure_tenant_id";
254    /// Azure storage account name. Required for credential vending.
255    pub const ACCOUNT_NAME: &str = "azure_account_name";
256    /// Azure credential duration in milliseconds.
257    /// Default: 3600000 (1 hour). Azure SAS tokens can be valid up to 7 days.
258    pub const DURATION_MILLIS: &str = "azure_duration_millis";
259
260    /// Client ID of the Azure AD App Registration for Workload Identity Federation.
261    /// Required when using auth_token identity for OIDC token exchange.
262    pub const FEDERATED_CLIENT_ID: &str = "azure_federated_client_id";
263}
264
265/// Vended credentials with expiration information.
266#[derive(Clone)]
267pub struct VendedCredentials {
268    /// Storage options map containing credential keys.
269    /// - For AWS: `aws_access_key_id`, `aws_secret_access_key`, `aws_session_token`
270    /// - For GCP: `google_storage_token`
271    /// - For Azure: `azure_storage_sas_token`, `azure_storage_account_name`
272    pub storage_options: HashMap<String, String>,
273
274    /// Expiration time in milliseconds since Unix epoch.
275    pub expires_at_millis: u64,
276}
277
278impl std::fmt::Debug for VendedCredentials {
279    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280        f.debug_struct("VendedCredentials")
281            .field(
282                "storage_options",
283                &format!("[{} keys redacted]", self.storage_options.len()),
284            )
285            .field("expires_at_millis", &self.expires_at_millis)
286            .finish()
287    }
288}
289
290impl VendedCredentials {
291    /// Create new vended credentials.
292    pub fn new(storage_options: HashMap<String, String>, expires_at_millis: u64) -> Self {
293        Self {
294            storage_options,
295            expires_at_millis,
296        }
297    }
298
299    /// Check if the credentials have expired.
300    pub fn is_expired(&self) -> bool {
301        let now_millis = std::time::SystemTime::now()
302            .duration_since(std::time::UNIX_EPOCH)
303            .expect("time went backwards")
304            .as_millis() as u64;
305        now_millis >= self.expires_at_millis
306    }
307}
308
309/// Trait for credential vendors that generate temporary credentials.
310///
311/// Each cloud provider has its own configuration passed via the vendor
312/// implementation. The permission level is configured at vendor creation time
313/// via [`VendedPermission`].
314#[async_trait]
315pub trait CredentialVendor: Send + Sync + std::fmt::Debug {
316    /// Vend credentials for accessing the specified table location.
317    ///
318    /// The permission level (read/write/admin) is determined by the vendor's
319    /// configuration, not per-request. When identity is provided, the vendor
320    /// may use different authentication flows:
321    ///
322    /// - `auth_token`: Use AssumeRoleWithWebIdentity (AWS validates the token)
323    /// - `api_key`: Validate against configured API key hashes and use AssumeRole
324    /// - `None`: Use static configuration with AssumeRole
325    ///
326    /// # Arguments
327    ///
328    /// * `table_location` - The table URI to vend credentials for
329    /// * `identity` - Optional identity from the request (api_key OR auth_token, mutually exclusive)
330    ///
331    /// # Returns
332    ///
333    /// Returns vended credentials with expiration information.
334    ///
335    /// # Errors
336    ///
337    /// Returns error if identity validation fails (no fallback to static config).
338    async fn vend_credentials(
339        &self,
340        table_location: &str,
341        identity: Option<&Identity>,
342    ) -> Result<VendedCredentials>;
343
344    /// Returns the cloud provider name (e.g., "aws", "gcp", "azure").
345    fn provider_name(&self) -> &'static str;
346
347    /// Returns the permission level configured for this vendor.
348    fn permission(&self) -> VendedPermission;
349}
350
351/// Detect the cloud provider from a URI scheme.
352///
353/// Supported schemes for credential vending:
354/// - AWS S3: `s3://`
355/// - GCP GCS: `gs://`
356/// - Azure Blob: `az://`
357///
358/// Returns "aws", "gcp", "azure", or "unknown".
359pub fn detect_provider_from_uri(uri: &str) -> &'static str {
360    let Ok(url) = uri_to_url(uri) else {
361        return "unknown";
362    };
363
364    match url.scheme() {
365        "s3" => "aws",
366        "gs" => "gcp",
367        "az" | "abfss" => "azure",
368        _ => "unknown",
369    }
370}
371
372/// Check if credential vending is enabled.
373///
374/// Returns true only if the `enabled` property is set to "true".
375/// This expects properties with short names (prefix already stripped).
376pub fn has_credential_vendor_config(properties: &HashMap<String, String>) -> bool {
377    properties
378        .get(ENABLED)
379        .map(|v| v.eq_ignore_ascii_case("true"))
380        .unwrap_or(false)
381}
382
383/// Create a credential vendor for the specified table location based on its URI scheme.
384///
385/// This function automatically detects the cloud provider from the table location
386/// and creates the appropriate credential vendor using the provided properties.
387///
388/// # Arguments
389///
390/// * `table_location` - The table URI to create a vendor for (e.g., "s3://bucket/path")
391/// * `properties` - Configuration properties for credential vendors
392///
393/// # Returns
394///
395/// Returns `Some(vendor)` if the provider is detected and configured, `None` if:
396/// - The provider cannot be detected from the URI (e.g., local file path)
397/// - The required feature is not enabled for the detected provider
398///
399/// # Errors
400///
401/// Returns an error if the provider is detected but required configuration is missing:
402/// - AWS: `credential_vendor.aws_role_arn` is required
403/// - Azure: `credential_vendor.azure_account_name` is required
404#[allow(unused_variables)]
405pub async fn create_credential_vendor_for_location(
406    table_location: &str,
407    properties: &HashMap<String, String>,
408) -> Result<Option<Box<dyn CredentialVendor>>> {
409    let provider = detect_provider_from_uri(table_location);
410
411    let vendor: Option<Box<dyn CredentialVendor>> = match provider {
412        #[cfg(feature = "credential-vendor-aws")]
413        "aws" => create_aws_vendor(properties).await?,
414
415        #[cfg(feature = "credential-vendor-gcp")]
416        "gcp" => create_gcp_vendor(properties).await?,
417
418        #[cfg(feature = "credential-vendor-azure")]
419        "azure" => create_azure_vendor(properties)?,
420
421        _ => None,
422    };
423
424    // Wrap with caching if enabled (default: true)
425    #[cfg(any(
426        feature = "credential-vendor-aws",
427        feature = "credential-vendor-azure",
428        feature = "credential-vendor-gcp"
429    ))]
430    if let Some(v) = vendor {
431        let cache_enabled = properties
432            .get(CACHE_ENABLED)
433            .map(|s| !s.eq_ignore_ascii_case("false"))
434            .unwrap_or(true);
435
436        if cache_enabled {
437            return Ok(Some(Box::new(cache::CachingCredentialVendor::new(v))));
438        } else {
439            return Ok(Some(v));
440        }
441    }
442
443    #[cfg(not(any(
444        feature = "credential-vendor-aws",
445        feature = "credential-vendor-azure",
446        feature = "credential-vendor-gcp"
447    )))]
448    let _ = vendor;
449
450    Ok(None)
451}
452
453/// Parse permission from properties, defaulting to Read
454#[cfg(any(
455    test,
456    feature = "credential-vendor-aws",
457    feature = "credential-vendor-azure",
458    feature = "credential-vendor-gcp"
459))]
460fn parse_permission(properties: &HashMap<String, String>) -> VendedPermission {
461    properties
462        .get(PERMISSION)
463        .and_then(|s| s.parse().ok())
464        .unwrap_or_default()
465}
466
467/// Parse duration from properties using a vendor-specific key, defaulting to DEFAULT_CREDENTIAL_DURATION_MILLIS
468#[cfg(any(
469    test,
470    feature = "credential-vendor-aws",
471    feature = "credential-vendor-azure",
472    feature = "credential-vendor-gcp"
473))]
474fn parse_duration_millis(properties: &HashMap<String, String>, key: &str) -> u64 {
475    properties
476        .get(key)
477        .and_then(|s| s.parse::<u64>().ok())
478        .unwrap_or(DEFAULT_CREDENTIAL_DURATION_MILLIS)
479}
480
481#[cfg(feature = "credential-vendor-aws")]
482async fn create_aws_vendor(
483    properties: &HashMap<String, String>,
484) -> Result<Option<Box<dyn CredentialVendor>>> {
485    use aws::{AwsCredentialVendor, AwsCredentialVendorConfig};
486    use lance_namespace::error::NamespaceError;
487
488    // AWS requires role_arn to be configured
489    let role_arn = properties.get(aws_props::ROLE_ARN).ok_or_else(|| {
490        lance_core::Error::from(NamespaceError::InvalidInput {
491            message: "AWS credential vending requires 'credential_vendor.aws_role_arn' to be set"
492                .to_string(),
493        })
494    })?;
495
496    let duration_millis = parse_duration_millis(properties, aws_props::DURATION_MILLIS);
497
498    let permission = parse_permission(properties);
499
500    let mut config = AwsCredentialVendorConfig::new(role_arn)
501        .with_duration_millis(duration_millis)
502        .with_permission(permission);
503
504    if let Some(external_id) = properties.get(aws_props::EXTERNAL_ID) {
505        config = config.with_external_id(external_id);
506    }
507    if let Some(region) = properties.get(aws_props::REGION) {
508        config = config.with_region(region);
509    }
510    if let Some(session_name) = properties.get(aws_props::ROLE_SESSION_NAME) {
511        config = config.with_role_session_name(session_name);
512    }
513
514    // Direct (non-chained) web-identity assume for the pod, when enabled. An
515    // explicit token-file path wins; otherwise, if opted in, resolve the
516    // EKS-injected `AWS_WEB_IDENTITY_TOKEN_FILE`. Falling back to the chained
517    // AssumeRole path when neither is present keeps existing behavior.
518    let assume_via_pod = properties
519        .get(aws_props::ASSUME_VIA_POD_WEB_IDENTITY)
520        .map(|v| v.eq_ignore_ascii_case("true"))
521        .unwrap_or(false);
522    let pod_token_file = properties
523        .get(aws_props::POD_WEB_IDENTITY_TOKEN_FILE)
524        .cloned()
525        .or_else(|| {
526            assume_via_pod
527                .then(|| std::env::var("AWS_WEB_IDENTITY_TOKEN_FILE").ok())
528                .flatten()
529        });
530    // Log the resolved assume path once at vendor init so a deployment can
531    // confirm at runtime which branch `assume_scoped` will take.
532    match &pod_token_file {
533        Some(path) => log::info!(
534            "AWS credential vendor (role {role_arn}): direct AssumeRoleWithWebIdentity \
535             via pod token file '{path}'"
536        ),
537        None if assume_via_pod => log::warn!(
538            "AWS credential vendor (role {role_arn}): aws_assume_via_pod_web_identity=true \
539             but no token file resolved (aws_pod_web_identity_token_file unset and \
540             AWS_WEB_IDENTITY_TOKEN_FILE not in env); falling back to chained AssumeRole"
541        ),
542        None => log::info!(
543            "AWS credential vendor (role {role_arn}): chained AssumeRole \
544             (pod web-identity not enabled)"
545        ),
546    }
547    if let Some(path) = pod_token_file {
548        config = config.with_pod_web_identity_token_file(path);
549    }
550
551    let vendor = AwsCredentialVendor::new(config).await?;
552    Ok(Some(Box::new(vendor)))
553}
554
555#[cfg(feature = "credential-vendor-gcp")]
556async fn create_gcp_vendor(
557    properties: &HashMap<String, String>,
558) -> Result<Option<Box<dyn CredentialVendor>>> {
559    use gcp::{GcpCredentialVendor, GcpCredentialVendorConfig};
560
561    let permission = parse_permission(properties);
562
563    let mut config = GcpCredentialVendorConfig::new().with_permission(permission);
564
565    if let Some(sa) = properties.get(gcp_props::SERVICE_ACCOUNT) {
566        config = config.with_service_account(sa);
567    }
568    if let Some(provider) = properties.get(gcp_props::WORKLOAD_IDENTITY_PROVIDER) {
569        config = config.with_workload_identity_provider(provider);
570    }
571    if let Some(service_account) = properties.get(gcp_props::IMPERSONATION_SERVICE_ACCOUNT) {
572        config = config.with_impersonation_service_account(service_account);
573    }
574
575    let vendor = GcpCredentialVendor::new(config)?;
576    Ok(Some(Box::new(vendor)))
577}
578
579#[cfg(feature = "credential-vendor-azure")]
580fn create_azure_vendor(
581    properties: &HashMap<String, String>,
582) -> Result<Option<Box<dyn CredentialVendor>>> {
583    use azure::{AzureCredentialVendor, AzureCredentialVendorConfig};
584    use lance_namespace::error::NamespaceError;
585
586    // Azure requires account_name to be configured
587    let account_name = properties.get(azure_props::ACCOUNT_NAME).ok_or_else(|| {
588        lance_core::Error::from(NamespaceError::InvalidInput {
589            message:
590                "Azure credential vending requires 'credential_vendor.azure_account_name' to be set"
591                    .to_string(),
592        })
593    })?;
594
595    let duration_millis = parse_duration_millis(properties, azure_props::DURATION_MILLIS);
596    let permission = parse_permission(properties);
597
598    let mut config = AzureCredentialVendorConfig::new()
599        .with_account_name(account_name)
600        .with_duration_millis(duration_millis)
601        .with_permission(permission);
602
603    if let Some(tenant_id) = properties.get(azure_props::TENANT_ID) {
604        config = config.with_tenant_id(tenant_id);
605    }
606    if let Some(client_id) = properties.get(azure_props::FEDERATED_CLIENT_ID) {
607        config = config.with_federated_client_id(client_id);
608    }
609
610    let vendor = AzureCredentialVendor::new(config);
611    Ok(Some(Box::new(vendor)))
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617
618    #[test]
619    fn test_detect_provider_from_uri() {
620        // AWS (supported scheme: s3://)
621        assert_eq!(detect_provider_from_uri("s3://bucket/path"), "aws");
622        assert_eq!(detect_provider_from_uri("S3://bucket/path"), "aws");
623
624        // GCP (supported scheme: gs://)
625        assert_eq!(detect_provider_from_uri("gs://bucket/path"), "gcp");
626        assert_eq!(detect_provider_from_uri("GS://bucket/path"), "gcp");
627
628        // Azure (supported schemes: az:// and abfss://)
629        assert_eq!(detect_provider_from_uri("az://container/path"), "azure");
630        assert_eq!(
631            detect_provider_from_uri("az://container@account.blob.core.windows.net/path"),
632            "azure"
633        );
634        assert_eq!(
635            detect_provider_from_uri("abfss://container@account.dfs.core.windows.net/path"),
636            "azure"
637        );
638
639        // Unknown (unsupported schemes)
640        assert_eq!(detect_provider_from_uri("/local/path"), "unknown");
641        assert_eq!(detect_provider_from_uri("file:///local/path"), "unknown");
642        assert_eq!(detect_provider_from_uri("memory://test"), "unknown");
643        // Hadoop-style schemes not supported by lance-io
644        assert_eq!(detect_provider_from_uri("s3a://bucket/path"), "unknown");
645        assert_eq!(
646            detect_provider_from_uri("wasbs://container@account.blob.core.windows.net/path"),
647            "unknown"
648        );
649    }
650
651    #[test]
652    fn test_vended_permission_from_str() {
653        // Valid values (case-insensitive)
654        assert_eq!(
655            "read".parse::<VendedPermission>().unwrap(),
656            VendedPermission::Read
657        );
658        assert_eq!(
659            "READ".parse::<VendedPermission>().unwrap(),
660            VendedPermission::Read
661        );
662        assert_eq!(
663            "write".parse::<VendedPermission>().unwrap(),
664            VendedPermission::Write
665        );
666        assert_eq!(
667            "WRITE".parse::<VendedPermission>().unwrap(),
668            VendedPermission::Write
669        );
670        assert_eq!(
671            "admin".parse::<VendedPermission>().unwrap(),
672            VendedPermission::Admin
673        );
674        assert_eq!(
675            "Admin".parse::<VendedPermission>().unwrap(),
676            VendedPermission::Admin
677        );
678
679        // Invalid values should return error
680        let err = "invalid".parse::<VendedPermission>().unwrap_err();
681        assert!(err.contains("Invalid permission"));
682        assert!(err.contains("invalid"));
683
684        let err = "".parse::<VendedPermission>().unwrap_err();
685        assert!(err.contains("Invalid permission"));
686
687        let err = "readwrite".parse::<VendedPermission>().unwrap_err();
688        assert!(err.contains("Invalid permission"));
689    }
690
691    #[test]
692    fn test_vended_permission_display() {
693        assert_eq!(VendedPermission::Read.to_string(), "read");
694        assert_eq!(VendedPermission::Write.to_string(), "write");
695        assert_eq!(VendedPermission::Admin.to_string(), "admin");
696    }
697
698    #[test]
699    fn test_parse_permission_with_invalid_values() {
700        // Invalid permission should default to Read
701        let mut props = HashMap::new();
702        props.insert(PERMISSION.to_string(), "invalid".to_string());
703        assert_eq!(parse_permission(&props), VendedPermission::Read);
704
705        // Empty permission should default to Read
706        props.insert(PERMISSION.to_string(), "".to_string());
707        assert_eq!(parse_permission(&props), VendedPermission::Read);
708
709        // Missing permission should default to Read
710        let empty_props: HashMap<String, String> = HashMap::new();
711        assert_eq!(parse_permission(&empty_props), VendedPermission::Read);
712    }
713
714    #[test]
715    fn test_parse_duration_millis_with_invalid_values() {
716        const TEST_KEY: &str = "test_duration_millis";
717
718        // Invalid duration should default to DEFAULT_CREDENTIAL_DURATION_MILLIS
719        let mut props = HashMap::new();
720        props.insert(TEST_KEY.to_string(), "not_a_number".to_string());
721        assert_eq!(
722            parse_duration_millis(&props, TEST_KEY),
723            DEFAULT_CREDENTIAL_DURATION_MILLIS
724        );
725
726        // Negative number (parsed as u64 fails)
727        props.insert(TEST_KEY.to_string(), "-1000".to_string());
728        assert_eq!(
729            parse_duration_millis(&props, TEST_KEY),
730            DEFAULT_CREDENTIAL_DURATION_MILLIS
731        );
732
733        // Empty string should default
734        props.insert(TEST_KEY.to_string(), "".to_string());
735        assert_eq!(
736            parse_duration_millis(&props, TEST_KEY),
737            DEFAULT_CREDENTIAL_DURATION_MILLIS
738        );
739
740        // Missing duration should default
741        let empty_props: HashMap<String, String> = HashMap::new();
742        assert_eq!(
743            parse_duration_millis(&empty_props, TEST_KEY),
744            DEFAULT_CREDENTIAL_DURATION_MILLIS
745        );
746
747        // Valid duration should work
748        props.insert(TEST_KEY.to_string(), "7200000".to_string());
749        assert_eq!(parse_duration_millis(&props, TEST_KEY), 7200000);
750    }
751
752    #[test]
753    fn test_has_credential_vendor_config() {
754        // enabled = true
755        let mut props = HashMap::new();
756        props.insert(ENABLED.to_string(), "true".to_string());
757        assert!(has_credential_vendor_config(&props));
758
759        // enabled = TRUE (case-insensitive)
760        props.insert(ENABLED.to_string(), "TRUE".to_string());
761        assert!(has_credential_vendor_config(&props));
762
763        // enabled = false
764        props.insert(ENABLED.to_string(), "false".to_string());
765        assert!(!has_credential_vendor_config(&props));
766
767        // enabled = invalid value
768        props.insert(ENABLED.to_string(), "yes".to_string());
769        assert!(!has_credential_vendor_config(&props));
770
771        // enabled missing
772        let empty_props: HashMap<String, String> = HashMap::new();
773        assert!(!has_credential_vendor_config(&empty_props));
774    }
775
776    #[test]
777    fn test_vended_credentials_debug_redacts_secrets() {
778        let mut storage_options = HashMap::new();
779        storage_options.insert(
780            "aws_access_key_id".to_string(),
781            "AKIAIOSFODNN7EXAMPLE".to_string(),
782        );
783        storage_options.insert(
784            "aws_secret_access_key".to_string(),
785            "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
786        );
787        storage_options.insert(
788            "aws_session_token".to_string(),
789            "FwoGZXIvYXdzE...".to_string(),
790        );
791
792        let creds = VendedCredentials::new(storage_options, 1234567890);
793        let debug_output = format!("{:?}", creds);
794
795        // Should NOT contain actual secrets
796        assert!(!debug_output.contains("AKIAIOSFODNN7EXAMPLE"));
797        assert!(!debug_output.contains("wJalrXUtnFEMI"));
798        assert!(!debug_output.contains("FwoGZXIvYXdzE"));
799
800        // Should contain redacted message
801        assert!(debug_output.contains("redacted"));
802        assert!(debug_output.contains("3 keys"));
803
804        // Should contain expiration time
805        assert!(debug_output.contains("1234567890"));
806    }
807
808    #[test]
809    fn test_vended_credentials_is_expired() {
810        // Create credentials that expired in the past
811        let past_millis = std::time::SystemTime::now()
812            .duration_since(std::time::UNIX_EPOCH)
813            .unwrap()
814            .as_millis() as u64
815            - 1000; // 1 second ago
816
817        let expired_creds = VendedCredentials::new(HashMap::new(), past_millis);
818        assert!(expired_creds.is_expired());
819
820        // Create credentials that expire in the future
821        let future_millis = std::time::SystemTime::now()
822            .duration_since(std::time::UNIX_EPOCH)
823            .unwrap()
824            .as_millis() as u64
825            + 3600000; // 1 hour from now
826
827        let valid_creds = VendedCredentials::new(HashMap::new(), future_millis);
828        assert!(!valid_creds.is_expired());
829    }
830
831    #[test]
832    fn test_redact_credential() {
833        // Long credential: shows first 8 and last 4
834        assert_eq!(redact_credential("AKIAIOSFODNN7EXAMPLE"), "AKIAIOSF***MPLE");
835
836        // Exactly 16 chars: shows first 8 and last 4
837        assert_eq!(redact_credential("1234567890123456"), "12345678***3456");
838
839        // Short credential (< 16 chars): shows only first few
840        assert_eq!(redact_credential("short1234567"), "short123***");
841        assert_eq!(redact_credential("short123"), "short123***");
842        assert_eq!(redact_credential("tiny"), "tiny***");
843        assert_eq!(redact_credential("ab"), "ab***");
844        assert_eq!(redact_credential("a"), "a***");
845
846        // Empty string
847        assert_eq!(redact_credential(""), "[empty]");
848
849        // Real-world examples
850        // AWS access key ID (20 chars) - shows AKIA + 4 more chars which helps identify the key
851        assert_eq!(redact_credential("AKIAIOSFODNN7EXAMPLE"), "AKIAIOSF***MPLE");
852
853        // GCP token (typically very long)
854        let long_token = "ya29.a0AfH6SMBx1234567890abcdefghijklmnopqrstuvwxyz";
855        assert_eq!(redact_credential(long_token), "ya29.a0A***wxyz");
856
857        // Azure SAS token
858        let sas_token = "sv=2021-06-08&ss=b&srt=sco&sp=rwdlacuiytfx&se=2024-12-31";
859        assert_eq!(redact_credential(sas_token), "sv=2021-***2-31");
860    }
861}