Skip to main content

lance_io/object_store/
storage_options.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Storage options provider and accessor for dynamic credential fetching
5//!
6//! This module provides:
7//! - [`StorageOptionsProvider`] trait for fetching storage options from various sources
8//!   (namespace servers, secret managers, etc.) with support for expiration tracking
9//! - [`StorageOptionsAccessor`] for unified access to storage options with automatic
10//!   caching and refresh
11
12use std::collections::HashMap;
13use std::fmt;
14use std::sync::Arc;
15use std::time::Duration;
16
17#[cfg(test)]
18use mock_instant::thread_local::{SystemTime, UNIX_EPOCH};
19
20#[cfg(not(test))]
21use std::time::{SystemTime, UNIX_EPOCH};
22
23use async_trait::async_trait;
24use lance_namespace::LanceNamespace;
25use lance_namespace::models::DescribeTableRequest;
26use tokio::sync::RwLock;
27
28use crate::{Error, Result};
29
30/// Key for the expiration timestamp in storage options HashMap
31pub const EXPIRES_AT_MILLIS_KEY: &str = "expires_at_millis";
32
33/// Key for the refresh offset in storage options HashMap (milliseconds before expiry to refresh)
34pub const REFRESH_OFFSET_MILLIS_KEY: &str = "refresh_offset_millis";
35
36/// Default refresh offset: 60 seconds before expiration
37const DEFAULT_REFRESH_OFFSET_MILLIS: u64 = 60_000;
38
39/// Trait for providing storage options with expiration tracking
40///
41/// Implementations can fetch storage options from various sources (namespace servers,
42/// secret managers, etc.) and are usable from Python/Java.
43///
44/// # Current Use Cases
45///
46/// - **Temporary Credentials**: Fetch short-lived AWS temporary credentials that expire
47///   after a set time period, with automatic refresh before expiration
48///
49/// # Future Possible Use Cases
50///
51/// - **Dynamic Storage Location Resolution**: Resolve logical names to actual storage
52///   locations (bucket aliases, S3 Access Points, region-specific endpoints) that may
53///   change based on region, tier, data migration, or failover scenarios
54/// - **Runtime S3 Tags Assignment**: Inject cost allocation tags, security labels, or
55///   compliance metadata into S3 requests based on the current execution context (user,
56///   application, workspace, etc.)
57/// - **Dynamic Endpoint Configuration**: Update storage endpoints for disaster recovery,
58///   A/B testing, or gradual migration scenarios
59/// - **Just-in-time Permission Elevation**: Request elevated permissions only when needed
60///   for sensitive operations, then immediately revoke them
61/// - **Secret Manager Integration**: Fetch encryption keys from AWS Secrets Manager,
62///   Azure Key Vault, or Google Secret Manager with automatic rotation
63/// - **OIDC/SAML Federation**: Integrate with identity providers to obtain storage
64///   credentials based on user identity and group membership
65///
66/// # Equality and Hashing
67///
68/// Implementations must provide `provider_id()` which returns a unique identifier for
69/// equality and hashing purposes. Two providers with the same ID are considered equal
70/// and will share the same cached ObjectStore in the registry.
71#[async_trait]
72pub trait StorageOptionsProvider: Send + Sync + fmt::Debug {
73    /// Fetch fresh storage options
74    ///
75    /// Returns None if no storage options are available, or Some(HashMap) with the options.
76    /// If the [`EXPIRES_AT_MILLIS_KEY`] key is present in the HashMap, it should contain the
77    /// epoch time in milliseconds when the options expire, and credentials will automatically
78    /// refresh before expiration.
79    /// If [`EXPIRES_AT_MILLIS_KEY`] is not provided, the options are considered to never expire.
80    async fn fetch_storage_options(&self) -> Result<Option<HashMap<String, String>>>;
81
82    /// Fetch fresh storage options, bypassing caches along the chain.
83    ///
84    /// Providers that serve from an upstream cache (e.g. base-scoped wrappers
85    /// reading through a parent accessor) override this to force the upstream
86    /// to re-fetch. Defaults to [`Self::fetch_storage_options`].
87    async fn force_fetch_storage_options(&self) -> Result<Option<HashMap<String, String>>> {
88        self.fetch_storage_options().await
89    }
90
91    /// Return a human-readable unique identifier for this provider instance
92    ///
93    /// This is used for equality comparison and hashing in the object store registry.
94    /// Two providers with the same ID will be treated as equal and share the same cached
95    /// ObjectStore.
96    ///
97    /// The ID should be human-readable for debugging and logging purposes.
98    /// For example: `"namespace[dir(root=/data)],table[db$schema$table1]"`
99    ///
100    /// The ID should uniquely identify the provider's configuration.
101    fn provider_id(&self) -> String;
102}
103
104/// StorageOptionsProvider implementation that fetches options from a LanceNamespace
105pub struct LanceNamespaceStorageOptionsProvider {
106    namespace_client: Arc<dyn LanceNamespace>,
107    table_id: Vec<String>,
108}
109
110impl fmt::Debug for LanceNamespaceStorageOptionsProvider {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        write!(f, "{}", self.provider_id())
113    }
114}
115
116impl fmt::Display for LanceNamespaceStorageOptionsProvider {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        write!(f, "{}", self.provider_id())
119    }
120}
121
122impl LanceNamespaceStorageOptionsProvider {
123    /// Create a new LanceNamespaceStorageOptionsProvider
124    ///
125    /// # Arguments
126    /// * `namespace_client` - The namespace implementation to fetch storage options from
127    /// * `table_id` - The table identifier
128    pub fn new(namespace_client: Arc<dyn LanceNamespace>, table_id: Vec<String>) -> Self {
129        Self {
130            namespace_client,
131            table_id,
132        }
133    }
134}
135
136#[async_trait]
137impl StorageOptionsProvider for LanceNamespaceStorageOptionsProvider {
138    async fn fetch_storage_options(&self) -> Result<Option<HashMap<String, String>>> {
139        let request = DescribeTableRequest {
140            id: Some(self.table_id.clone()),
141            // Some server implementations may not return credentials unless explicitly requested
142            vend_credentials: Some(true),
143            ..Default::default()
144        };
145
146        let response = self
147            .namespace_client
148            .describe_table(request)
149            .await
150            .map_err(|e| {
151                Error::io_source(Box::new(std::io::Error::other(format!(
152                    "Failed to fetch storage options: {}",
153                    e
154                ))))
155            })?;
156
157        Ok(response.storage_options)
158    }
159
160    fn provider_id(&self) -> String {
161        format!(
162            "LanceNamespaceStorageOptionsProvider {{ namespace_client: {}, table_id: {:?} }}",
163            self.namespace_client.namespace_id(),
164            self.table_id
165        )
166    }
167}
168
169/// Prefix marking a storage option as scoped to a single registered base path.
170///
171/// A key of the form `base_<id>.<key>` applies `<key>` only to the base path
172/// with manifest id `<id>`, overriding the shared (unscoped) options for that
173/// base. For example `base_1.account_key = abc` gives the base with id 1 the
174/// option `account_key = abc` while it inherits every unscoped option.
175pub const BASE_SCOPED_OPTION_PREFIX: &str = "base_";
176
177/// Parse a base-scoped storage option key of the form `base_<id>.<key>`.
178///
179/// Returns `Some((base_id, key))` only for keys that match the convention
180/// exactly: the `base_` prefix, an all-digit u32 base id, a `.` separator, and
181/// a non-empty remainder. Any other key (e.g. `base_url`, `base_x.key`,
182/// `base_1.`) is not base-scoped.
183pub fn parse_base_scoped_key(key: &str) -> Option<(u32, &str)> {
184    let rest = key.strip_prefix(BASE_SCOPED_OPTION_PREFIX)?;
185    let (id_str, scoped_key) = rest.split_once('.')?;
186    if scoped_key.is_empty() || id_str.is_empty() || !id_str.bytes().all(|b| b.is_ascii_digit()) {
187        return None;
188    }
189    let id = id_str.parse::<u32>().ok()?;
190    Some((id, scoped_key))
191}
192
193/// Returns true if any key in `options` is base-scoped (`base_<id>.<key>`).
194pub fn has_base_scoped_options(options: &HashMap<String, String>) -> bool {
195    options
196        .keys()
197        .any(|key| parse_base_scoped_key(key).is_some())
198}
199
200/// Resolve the effective storage options for one base path scope.
201///
202/// All base-scoped keys are removed from the result. When `base_id` is
203/// `Some(id)`, entries scoped to that id are overlaid on the unscoped options,
204/// adding or overriding keys. `None` resolves the default scope (the primary
205/// dataset base), which simply drops every base-scoped entry.
206pub fn resolve_base_scoped_options(
207    options: &HashMap<String, String>,
208    base_id: Option<u32>,
209) -> HashMap<String, String> {
210    let mut resolved = HashMap::with_capacity(options.len());
211    let mut overrides = Vec::new();
212    for (key, value) in options {
213        match parse_base_scoped_key(key) {
214            Some((id, scoped_key)) => {
215                if Some(id) == base_id {
216                    overrides.push((scoped_key.to_string(), value.clone()));
217                }
218            }
219            None => {
220                resolved.insert(key.clone(), value.clone());
221            }
222        }
223    }
224    resolved.extend(overrides);
225    resolved
226}
227
228/// A [`StorageOptionsProvider`] that resolves another accessor's options for a
229/// single base path scope.
230///
231/// Fetching through this provider first refreshes the parent accessor when its
232/// options have expired, then resolves the refreshed options for the scope. As
233/// a result, dynamically vended per-base credentials (e.g. a namespace server
234/// returning `base_<id>.<key>` entries in one flat map) stay fresh per base.
235#[derive(Debug)]
236pub struct BaseScopedStorageOptionsProvider {
237    inner: Arc<StorageOptionsAccessor>,
238    base_id: Option<u32>,
239}
240
241impl BaseScopedStorageOptionsProvider {
242    pub fn new(inner: Arc<StorageOptionsAccessor>, base_id: Option<u32>) -> Self {
243        Self { inner, base_id }
244    }
245}
246
247#[async_trait]
248impl StorageOptionsProvider for BaseScopedStorageOptionsProvider {
249    async fn fetch_storage_options(&self) -> Result<Option<HashMap<String, String>>> {
250        let options = self.inner.get_storage_options().await?;
251        Ok(Some(resolve_base_scoped_options(&options.0, self.base_id)))
252    }
253
254    async fn force_fetch_storage_options(&self) -> Result<Option<HashMap<String, String>>> {
255        let options = self.inner.refresh_storage_options().await?;
256        Ok(Some(resolve_base_scoped_options(&options.0, self.base_id)))
257    }
258
259    fn provider_id(&self) -> String {
260        match self.base_id {
261            Some(id) => format!("base-scoped[base_id={}]({})", id, self.inner.accessor_id()),
262            None => format!("base-scoped[default]({})", self.inner.accessor_id()),
263        }
264    }
265}
266
267/// Unified access to storage options with automatic caching and refresh
268///
269/// This struct bundles static storage options with an optional dynamic provider,
270/// handling all caching and refresh logic internally. It provides a single entry point
271/// for accessing storage options regardless of whether they're static or dynamic.
272///
273/// # Behavior
274///
275/// - If only static options are provided, returns those options
276/// - If a provider is configured, fetches from provider and caches results
277/// - Automatically refreshes cached options before expiration (based on refresh_offset)
278/// - Uses `expires_at_millis` key to track expiration
279///
280/// # Thread Safety
281///
282/// The accessor is thread-safe and can be shared across multiple tasks.
283/// Concurrent refresh attempts are deduplicated using a try-lock mechanism.
284pub struct StorageOptionsAccessor {
285    /// Initial/fallback static storage options
286    initial_options: Option<HashMap<String, String>>,
287
288    /// Optional dynamic provider for refreshing options
289    provider: Option<Arc<dyn StorageOptionsProvider>>,
290
291    /// Cached storage options with expiration tracking
292    cache: Arc<RwLock<Option<CachedStorageOptions>>>,
293
294    /// Duration before expiry to trigger refresh
295    refresh_offset: Duration,
296
297    /// True when this accessor was produced by [`Self::scoped_to_base`]; its
298    /// options are already resolved for one base path scope, so scoping again
299    /// is a no-op.
300    scope_resolved: bool,
301}
302
303impl fmt::Debug for StorageOptionsAccessor {
304    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305        f.debug_struct("StorageOptionsAccessor")
306            .field("has_initial_options", &self.initial_options.is_some())
307            .field("has_provider", &self.provider.is_some())
308            .field("refresh_offset", &self.refresh_offset)
309            .finish()
310    }
311}
312
313#[derive(Debug, Clone)]
314struct CachedStorageOptions {
315    options: HashMap<String, String>,
316    expires_at_millis: Option<u64>,
317}
318
319impl StorageOptionsAccessor {
320    /// Extract refresh offset from storage options, or use default
321    fn extract_refresh_offset(options: &HashMap<String, String>) -> Duration {
322        options
323            .get(REFRESH_OFFSET_MILLIS_KEY)
324            .and_then(|s| s.parse::<u64>().ok())
325            .map(Duration::from_millis)
326            .unwrap_or(Duration::from_millis(DEFAULT_REFRESH_OFFSET_MILLIS))
327    }
328
329    /// Effective expiration of a raw options map: the minimum of the unscoped
330    /// `expires_at_millis` and every `base_<id>.expires_at_millis` entry.
331    ///
332    /// A flat map may vend per-base credentials that expire before the shared
333    /// ones. Refreshing when the earliest credential is due keeps base-scoped
334    /// accessors (which refresh through this accessor) from re-resolving stale
335    /// per-base credentials out of a still-"valid" cache.
336    fn effective_expires_at_millis(options: &HashMap<String, String>) -> Option<u64> {
337        options
338            .iter()
339            .filter(|(key, _)| {
340                key.as_str() == EXPIRES_AT_MILLIS_KEY
341                    || matches!(
342                        parse_base_scoped_key(key),
343                        Some((_, scoped_key)) if scoped_key == EXPIRES_AT_MILLIS_KEY
344                    )
345            })
346            .filter_map(|(_, value)| value.parse::<u64>().ok())
347            .min()
348    }
349
350    /// Create an accessor with only static options (no refresh capability)
351    ///
352    /// The returned accessor will always return the provided options.
353    /// This is useful when credentials don't expire or are managed externally.
354    pub fn with_static_options(options: HashMap<String, String>) -> Self {
355        let expires_at_millis = Self::effective_expires_at_millis(&options);
356        let refresh_offset = Self::extract_refresh_offset(&options);
357
358        Self {
359            initial_options: Some(options.clone()),
360            provider: None,
361            cache: Arc::new(RwLock::new(Some(CachedStorageOptions {
362                options,
363                expires_at_millis,
364            }))),
365            refresh_offset,
366            scope_resolved: false,
367        }
368    }
369
370    /// Create an accessor with a dynamic provider (no initial options)
371    ///
372    /// The accessor will fetch from the provider on first access and cache
373    /// the results. Refresh happens automatically before expiration.
374    /// Uses the default refresh offset (60 seconds) until options are fetched.
375    ///
376    /// # Arguments
377    /// * `provider` - The storage options provider for fetching fresh options
378    pub fn with_provider(provider: Arc<dyn StorageOptionsProvider>) -> Self {
379        Self {
380            initial_options: None,
381            provider: Some(provider),
382            cache: Arc::new(RwLock::new(None)),
383            refresh_offset: Duration::from_millis(DEFAULT_REFRESH_OFFSET_MILLIS),
384            scope_resolved: false,
385        }
386    }
387
388    /// Create an accessor with initial options and a dynamic provider
389    ///
390    /// Initial options are used until they expire, then the provider is called.
391    /// This avoids an immediate fetch when initial credentials are still valid.
392    /// The `refresh_offset_millis` key in initial_options controls refresh timing.
393    ///
394    /// # Arguments
395    /// * `initial_options` - Initial storage options to cache
396    /// * `provider` - The storage options provider for refreshing
397    pub fn with_initial_and_provider(
398        initial_options: HashMap<String, String>,
399        provider: Arc<dyn StorageOptionsProvider>,
400    ) -> Self {
401        let expires_at_millis = Self::effective_expires_at_millis(&initial_options);
402        let refresh_offset = Self::extract_refresh_offset(&initial_options);
403
404        Self {
405            initial_options: Some(initial_options.clone()),
406            provider: Some(provider),
407            cache: Arc::new(RwLock::new(Some(CachedStorageOptions {
408                options: initial_options,
409                expires_at_millis,
410            }))),
411            refresh_offset,
412            scope_resolved: false,
413        }
414    }
415
416    /// Get current valid storage options
417    ///
418    /// - Returns cached options if not expired
419    /// - Fetches from provider if expired or not cached
420    /// - Falls back to initial_options if provider returns None
421    ///
422    /// # Errors
423    ///
424    /// Returns an error if:
425    /// - The provider fails to fetch options
426    /// - No options are available (no cache, no provider, no initial options)
427    pub async fn get_storage_options(&self) -> Result<super::StorageOptions> {
428        loop {
429            match self.do_get_storage_options().await? {
430                Some(options) => return Ok(options),
431                None => {
432                    // Lock was busy, wait 10ms before retrying
433                    tokio::time::sleep(Duration::from_millis(10)).await;
434                    continue;
435                }
436            }
437        }
438    }
439
440    /// Fetch fresh options from the provider and update the cache.
441    ///
442    /// This bypasses the cache for callers that need to validate provider-vended
443    /// credentials even when initial metadata has no expiration. The force
444    /// propagates through provider chains (e.g. base-scoped wrappers), so the
445    /// origin provider is re-queried even when intermediate caches are valid.
446    pub(crate) async fn refresh_storage_options(&self) -> Result<super::StorageOptions> {
447        let Some(provider) = &self.provider else {
448            return self.get_storage_options().await;
449        };
450
451        log::debug!(
452            "Refreshing storage options from provider: {}",
453            provider.provider_id()
454        );
455
456        let storage_options_map = provider.force_fetch_storage_options().await.map_err(|e| {
457            Error::io_source(Box::new(std::io::Error::other(format!(
458                "Failed to fetch storage options: {}",
459                e
460            ))))
461        })?;
462
463        let Some(options) = storage_options_map else {
464            if let Some(initial) = &self.initial_options {
465                return Ok(super::StorageOptions(initial.clone()));
466            }
467            log::debug!(
468                "Provider {} returned no storage options, using default credentials",
469                provider.provider_id()
470            );
471            return Ok(super::StorageOptions(HashMap::new()));
472        };
473
474        let expires_at_millis = Self::effective_expires_at_millis(&options);
475
476        let mut cache = self.cache.write().await;
477        *cache = Some(CachedStorageOptions {
478            options: options.clone(),
479            expires_at_millis,
480        });
481
482        Ok(super::StorageOptions(options))
483    }
484
485    async fn do_get_storage_options(&self) -> Result<Option<super::StorageOptions>> {
486        // Check if we have valid cached options with read lock
487        {
488            let cached = self.cache.read().await;
489            if !self.needs_refresh(&cached)
490                && let Some(cached_opts) = &*cached
491            {
492                return Ok(Some(super::StorageOptions(cached_opts.options.clone())));
493            }
494        }
495
496        // If no provider, return initial options or use defaults
497        let Some(provider) = &self.provider else {
498            return if let Some(initial) = &self.initial_options {
499                Ok(Some(super::StorageOptions(initial.clone())))
500            } else {
501                // No provider and no initial options - use default credentials
502                Ok(Some(super::StorageOptions(HashMap::new())))
503            };
504        };
505
506        // Try to acquire write lock - if it fails, return None and let caller retry
507        let Ok(mut cache) = self.cache.try_write() else {
508            return Ok(None);
509        };
510
511        // Double-check if options are still stale after acquiring write lock
512        // (another thread might have refreshed them)
513        if !self.needs_refresh(&cache)
514            && let Some(cached_opts) = &*cache
515        {
516            return Ok(Some(super::StorageOptions(cached_opts.options.clone())));
517        }
518        log::debug!(
519            "Refreshing storage options from provider: {}",
520            provider.provider_id()
521        );
522
523        let storage_options_map = provider.fetch_storage_options().await.map_err(|e| {
524            Error::io_source(Box::new(std::io::Error::other(format!(
525                "Failed to fetch storage options: {}",
526                e
527            ))))
528        })?;
529
530        let Some(options) = storage_options_map else {
531            // Provider returned None, fall back to initial options or use defaults
532            if let Some(initial) = &self.initial_options {
533                return Ok(Some(super::StorageOptions(initial.clone())));
534            }
535            // Provider returned None and no initial options - use default credentials
536            // This is valid when namespace doesn't vend credentials (e.g., directory namespace
537            // where environment credentials are used)
538            log::debug!(
539                "Provider {} returned no storage options, using default credentials",
540                provider.provider_id()
541            );
542            return Ok(Some(super::StorageOptions(HashMap::new())));
543        };
544
545        let expires_at_millis = Self::effective_expires_at_millis(&options);
546
547        if let Some(expires_at) = expires_at_millis {
548            let now_ms = SystemTime::now()
549                .duration_since(UNIX_EPOCH)
550                .unwrap_or(Duration::from_secs(0))
551                .as_millis() as u64;
552            let expires_in_secs = (expires_at.saturating_sub(now_ms)) / 1000;
553            log::debug!(
554                "Successfully refreshed storage options from provider: {}, options expire in {} seconds",
555                provider.provider_id(),
556                expires_in_secs
557            );
558        } else {
559            log::debug!(
560                "Successfully refreshed storage options from provider: {} (no expiration)",
561                provider.provider_id()
562            );
563        }
564
565        *cache = Some(CachedStorageOptions {
566            options: options.clone(),
567            expires_at_millis,
568        });
569
570        Ok(Some(super::StorageOptions(options)))
571    }
572
573    fn needs_refresh(&self, cached: &Option<CachedStorageOptions>) -> bool {
574        match cached {
575            None => true,
576            Some(cached_opts) => {
577                if let Some(expires_at_millis) = cached_opts.expires_at_millis {
578                    let now_ms = SystemTime::now()
579                        .duration_since(UNIX_EPOCH)
580                        .unwrap_or(Duration::from_secs(0))
581                        .as_millis() as u64;
582
583                    // Refresh if we're within the refresh offset of expiration
584                    let refresh_offset_millis = self.refresh_offset.as_millis() as u64;
585                    now_ms + refresh_offset_millis >= expires_at_millis
586                } else {
587                    // No expiration means options never expire
588                    false
589                }
590            }
591        }
592    }
593
594    /// Get the initial storage options without refresh
595    ///
596    /// Returns the initial options that were provided when creating the accessor.
597    /// This does not trigger any refresh, even if the options have expired.
598    pub fn initial_storage_options(&self) -> Option<&HashMap<String, String>> {
599        self.initial_options.as_ref()
600    }
601
602    /// Get the accessor ID for equality/hashing
603    ///
604    /// Returns the provider_id if a provider exists, otherwise generates
605    /// a stable ID from the initial options hash.
606    pub fn accessor_id(&self) -> String {
607        if let Some(provider) = &self.provider {
608            provider.provider_id()
609        } else if let Some(initial) = &self.initial_options {
610            // Generate a stable ID from initial options
611            use std::collections::hash_map::DefaultHasher;
612            use std::hash::{Hash, Hasher};
613
614            let mut hasher = DefaultHasher::new();
615            let mut keys: Vec<_> = initial.keys().collect();
616            keys.sort();
617            for key in keys {
618                key.hash(&mut hasher);
619                initial.get(key).hash(&mut hasher);
620            }
621            format!("static_options_{:x}", hasher.finish())
622        } else {
623            "empty_accessor".to_string()
624        }
625    }
626
627    /// Resolve this accessor for a single base path scope.
628    ///
629    /// Storage options may carry base-scoped entries (`base_<id>.<key>`) that
630    /// apply only to one registered base path. The returned accessor resolves
631    /// options for `base_id`: entries scoped to that base overlay the unscoped
632    /// defaults, and all other scoped entries are dropped. `None` resolves the
633    /// default scope used for the primary dataset base.
634    ///
635    /// A static accessor whose options contain no base-scoped entries is
636    /// returned unchanged, preserving accessor identity (and thus object store
637    /// registry cache keys). A provider-backed accessor is always wrapped
638    /// through [`BaseScopedStorageOptionsProvider`] — fetched options may vend
639    /// base-scoped entries at any refresh, even when the initial options carry
640    /// none — so refreshed options are re-resolved for the scope on every
641    /// fetch. Accessors already produced by this method are returned unchanged.
642    pub fn scoped_to_base(self: &Arc<Self>, base_id: Option<u32>) -> Arc<Self> {
643        if self.scope_resolved {
644            return self.clone();
645        }
646        if self.has_provider() {
647            let provider = Arc::new(BaseScopedStorageOptionsProvider::new(self.clone(), base_id));
648            let mut scoped = match self.initial_storage_options() {
649                Some(initial) => Self::with_initial_and_provider(
650                    resolve_base_scoped_options(initial, base_id),
651                    provider,
652                ),
653                None => Self::with_provider(provider),
654            };
655            scoped.scope_resolved = true;
656            Arc::new(scoped)
657        } else {
658            match self.initial_storage_options() {
659                Some(initial) if has_base_scoped_options(initial) => {
660                    let mut scoped =
661                        Self::with_static_options(resolve_base_scoped_options(initial, base_id));
662                    scoped.scope_resolved = true;
663                    Arc::new(scoped)
664                }
665                // Static options never change, so there is nothing to scope.
666                _ => self.clone(),
667            }
668        }
669    }
670
671    /// Check if this accessor has a dynamic provider
672    pub fn has_provider(&self) -> bool {
673        self.provider.is_some()
674    }
675
676    /// Get the refresh offset duration
677    pub fn refresh_offset(&self) -> Duration {
678        self.refresh_offset
679    }
680
681    /// Get the storage options provider, if any
682    pub fn provider(&self) -> Option<&Arc<dyn StorageOptionsProvider>> {
683        self.provider.as_ref()
684    }
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690    use mock_instant::thread_local::MockClock;
691
692    #[derive(Debug)]
693    struct MockStorageOptionsProvider {
694        call_count: Arc<RwLock<usize>>,
695        expires_in_millis: Option<u64>,
696    }
697
698    impl MockStorageOptionsProvider {
699        fn new(expires_in_millis: Option<u64>) -> Self {
700            Self {
701                call_count: Arc::new(RwLock::new(0)),
702                expires_in_millis,
703            }
704        }
705
706        async fn get_call_count(&self) -> usize {
707            *self.call_count.read().await
708        }
709    }
710
711    #[async_trait]
712    impl StorageOptionsProvider for MockStorageOptionsProvider {
713        async fn fetch_storage_options(&self) -> Result<Option<HashMap<String, String>>> {
714            let count = {
715                let mut c = self.call_count.write().await;
716                *c += 1;
717                *c
718            };
719
720            let mut options = HashMap::from([
721                ("aws_access_key_id".to_string(), format!("AKID_{}", count)),
722                (
723                    "aws_secret_access_key".to_string(),
724                    format!("SECRET_{}", count),
725                ),
726                ("aws_session_token".to_string(), format!("TOKEN_{}", count)),
727            ]);
728
729            if let Some(expires_in) = self.expires_in_millis {
730                let now_ms = SystemTime::now()
731                    .duration_since(UNIX_EPOCH)
732                    .unwrap()
733                    .as_millis() as u64;
734                let expires_at = now_ms + expires_in;
735                options.insert(EXPIRES_AT_MILLIS_KEY.to_string(), expires_at.to_string());
736            }
737
738            Ok(Some(options))
739        }
740
741        fn provider_id(&self) -> String {
742            let ptr = Arc::as_ptr(&self.call_count) as usize;
743            format!("MockStorageOptionsProvider {{ id: {} }}", ptr)
744        }
745    }
746
747    #[tokio::test]
748    async fn test_static_options_only() {
749        let options = HashMap::from([
750            ("key1".to_string(), "value1".to_string()),
751            ("key2".to_string(), "value2".to_string()),
752        ]);
753        let accessor = StorageOptionsAccessor::with_static_options(options.clone());
754
755        let result = accessor.get_storage_options().await.unwrap();
756        assert_eq!(result.0, options);
757        assert!(!accessor.has_provider());
758        assert_eq!(accessor.initial_storage_options(), Some(&options));
759    }
760
761    #[tokio::test]
762    async fn test_provider_only() {
763        MockClock::set_system_time(Duration::from_secs(100_000));
764
765        let mock_provider = Arc::new(MockStorageOptionsProvider::new(Some(600_000)));
766        let accessor = StorageOptionsAccessor::with_provider(mock_provider.clone());
767
768        let result = accessor.get_storage_options().await.unwrap();
769        assert!(result.0.contains_key("aws_access_key_id"));
770        assert_eq!(result.0.get("aws_access_key_id").unwrap(), "AKID_1");
771        assert!(accessor.has_provider());
772        assert_eq!(accessor.initial_storage_options(), None);
773        assert_eq!(mock_provider.get_call_count().await, 1);
774    }
775
776    #[tokio::test]
777    async fn test_initial_and_provider_uses_initial_first() {
778        MockClock::set_system_time(Duration::from_secs(100_000));
779
780        let now_ms = MockClock::system_time().as_millis() as u64;
781        let expires_at = now_ms + 600_000; // 10 minutes from now
782
783        let initial = HashMap::from([
784            ("aws_access_key_id".to_string(), "INITIAL_KEY".to_string()),
785            (
786                "aws_secret_access_key".to_string(),
787                "INITIAL_SECRET".to_string(),
788            ),
789            (EXPIRES_AT_MILLIS_KEY.to_string(), expires_at.to_string()),
790        ]);
791        let mock_provider = Arc::new(MockStorageOptionsProvider::new(Some(600_000)));
792
793        let accessor = StorageOptionsAccessor::with_initial_and_provider(
794            initial.clone(),
795            mock_provider.clone(),
796        );
797
798        // First call uses initial
799        let result = accessor.get_storage_options().await.unwrap();
800        assert_eq!(result.0.get("aws_access_key_id").unwrap(), "INITIAL_KEY");
801        assert_eq!(mock_provider.get_call_count().await, 0); // Provider not called yet
802    }
803
804    #[tokio::test]
805    async fn test_caching_and_refresh() {
806        MockClock::set_system_time(Duration::from_secs(100_000));
807
808        let mock_provider = Arc::new(MockStorageOptionsProvider::new(Some(600_000))); // 10 min expiry
809        // Use with_initial_and_provider to set custom refresh_offset_millis (5 min = 300000ms)
810        let now_ms = MockClock::system_time().as_millis() as u64;
811        let expires_at = now_ms + 600_000; // 10 minutes from now
812        let initial = HashMap::from([
813            (EXPIRES_AT_MILLIS_KEY.to_string(), expires_at.to_string()),
814            (REFRESH_OFFSET_MILLIS_KEY.to_string(), "300000".to_string()), // 5 min refresh offset
815        ]);
816        let accessor =
817            StorageOptionsAccessor::with_initial_and_provider(initial, mock_provider.clone());
818
819        // First call uses initial cached options
820        let result = accessor.get_storage_options().await.unwrap();
821        assert!(result.0.contains_key(EXPIRES_AT_MILLIS_KEY));
822        assert_eq!(mock_provider.get_call_count().await, 0);
823
824        // Advance time to 6 minutes - should trigger refresh (within 5 min refresh offset)
825        MockClock::set_system_time(Duration::from_secs(100_000 + 360));
826        let result = accessor.get_storage_options().await.unwrap();
827        assert_eq!(result.0.get("aws_access_key_id").unwrap(), "AKID_1");
828        assert_eq!(mock_provider.get_call_count().await, 1);
829    }
830
831    #[tokio::test]
832    async fn test_expired_initial_triggers_refresh() {
833        MockClock::set_system_time(Duration::from_secs(100_000));
834
835        let now_ms = MockClock::system_time().as_millis() as u64;
836        let expired_time = now_ms - 1_000; // Expired 1 second ago
837
838        let initial = HashMap::from([
839            ("aws_access_key_id".to_string(), "EXPIRED_KEY".to_string()),
840            (EXPIRES_AT_MILLIS_KEY.to_string(), expired_time.to_string()),
841        ]);
842        let mock_provider = Arc::new(MockStorageOptionsProvider::new(Some(600_000)));
843
844        let accessor =
845            StorageOptionsAccessor::with_initial_and_provider(initial, mock_provider.clone());
846
847        // Should fetch from provider since initial is expired
848        let result = accessor.get_storage_options().await.unwrap();
849        assert_eq!(result.0.get("aws_access_key_id").unwrap(), "AKID_1");
850        assert_eq!(mock_provider.get_call_count().await, 1);
851    }
852
853    #[tokio::test]
854    async fn test_accessor_id_with_provider() {
855        let mock_provider = Arc::new(MockStorageOptionsProvider::new(None));
856        let accessor = StorageOptionsAccessor::with_provider(mock_provider);
857
858        let id = accessor.accessor_id();
859        assert!(id.starts_with("MockStorageOptionsProvider"));
860    }
861
862    #[tokio::test]
863    async fn test_accessor_id_static() {
864        let options = HashMap::from([("key".to_string(), "value".to_string())]);
865        let accessor = StorageOptionsAccessor::with_static_options(options);
866
867        let id = accessor.accessor_id();
868        assert!(id.starts_with("static_options_"));
869    }
870
871    #[tokio::test]
872    async fn test_concurrent_access() {
873        // Create a mock provider with far future expiration
874        let mock_provider = Arc::new(MockStorageOptionsProvider::new(Some(9999999999999)));
875
876        let accessor = Arc::new(StorageOptionsAccessor::with_provider(mock_provider.clone()));
877
878        // Spawn 10 concurrent tasks that all try to get options at the same time
879        let mut handles = vec![];
880        for i in 0..10 {
881            let acc = accessor.clone();
882            let handle = tokio::spawn(async move {
883                let result = acc.get_storage_options().await.unwrap();
884                assert_eq!(result.0.get("aws_access_key_id").unwrap(), "AKID_1");
885                i
886            });
887            handles.push(handle);
888        }
889
890        // Wait for all tasks to complete
891        let results: Vec<_> = futures::future::join_all(handles)
892            .await
893            .into_iter()
894            .map(|r| r.unwrap())
895            .collect();
896
897        // Verify all 10 tasks completed successfully
898        assert_eq!(results.len(), 10);
899
900        // The provider should have been called exactly once
901        let call_count = mock_provider.get_call_count().await;
902        assert_eq!(
903            call_count, 1,
904            "Provider should be called exactly once despite concurrent access"
905        );
906    }
907
908    #[tokio::test]
909    async fn test_no_expiration_never_refreshes() {
910        MockClock::set_system_time(Duration::from_secs(100_000));
911
912        let mock_provider = Arc::new(MockStorageOptionsProvider::new(None)); // No expiration
913        let accessor = StorageOptionsAccessor::with_provider(mock_provider.clone());
914
915        // First call fetches
916        accessor.get_storage_options().await.unwrap();
917        assert_eq!(mock_provider.get_call_count().await, 1);
918
919        // Advance time significantly
920        MockClock::set_system_time(Duration::from_secs(200_000));
921
922        // Should still use cached options
923        accessor.get_storage_options().await.unwrap();
924        assert_eq!(mock_provider.get_call_count().await, 1);
925    }
926
927    #[test]
928    fn test_parse_base_scoped_key() {
929        assert_eq!(
930            parse_base_scoped_key("base_1.account_key"),
931            Some((1, "account_key"))
932        );
933        assert_eq!(
934            parse_base_scoped_key("base_12.headers.x-ms-version"),
935            Some((12, "headers.x-ms-version"))
936        );
937        assert_eq!(parse_base_scoped_key("base_0.region"), Some((0, "region")));
938
939        // Not base-scoped keys
940        assert_eq!(parse_base_scoped_key("account_key"), None);
941        assert_eq!(parse_base_scoped_key("base_url"), None);
942        assert_eq!(parse_base_scoped_key("base_hot.account_key"), None);
943        assert_eq!(parse_base_scoped_key("base_1x.account_key"), None);
944        assert_eq!(parse_base_scoped_key("base_+1.account_key"), None);
945        assert_eq!(parse_base_scoped_key("base_.account_key"), None);
946        assert_eq!(parse_base_scoped_key("base_1."), None);
947        assert_eq!(parse_base_scoped_key("base_1"), None);
948        // Overflows u32
949        assert_eq!(parse_base_scoped_key("base_4294967296.key"), None);
950    }
951
952    #[test]
953    fn test_resolve_base_scoped_options() {
954        let options = HashMap::from([
955            ("region".to_string(), "us-east-1".to_string()),
956            ("account_key".to_string(), "shared-key".to_string()),
957            ("base_1.account_key".to_string(), "base1-key".to_string()),
958            ("base_2.account_key".to_string(), "base2-key".to_string()),
959            ("base_2.endpoint".to_string(), "http://b2".to_string()),
960        ]);
961        assert!(has_base_scoped_options(&options));
962
963        let base1 = resolve_base_scoped_options(&options, Some(1));
964        assert_eq!(
965            base1,
966            HashMap::from([
967                ("region".to_string(), "us-east-1".to_string()),
968                ("account_key".to_string(), "base1-key".to_string()),
969            ])
970        );
971
972        let base2 = resolve_base_scoped_options(&options, Some(2));
973        assert_eq!(
974            base2,
975            HashMap::from([
976                ("region".to_string(), "us-east-1".to_string()),
977                ("account_key".to_string(), "base2-key".to_string()),
978                ("endpoint".to_string(), "http://b2".to_string()),
979            ])
980        );
981
982        // A base without scoped entries inherits only the unscoped options
983        let base3 = resolve_base_scoped_options(&options, Some(3));
984        assert_eq!(
985            base3,
986            HashMap::from([
987                ("region".to_string(), "us-east-1".to_string()),
988                ("account_key".to_string(), "shared-key".to_string()),
989            ])
990        );
991
992        // The default scope drops every scoped entry
993        let default = resolve_base_scoped_options(&options, None);
994        assert_eq!(default, base3);
995
996        assert!(!has_base_scoped_options(&HashMap::from([(
997            "account_key".to_string(),
998            "shared-key".to_string()
999        )])));
1000    }
1001
1002    #[tokio::test]
1003    async fn test_scoped_to_base_identity_and_idempotency() {
1004        // Static accessors without scoped keys are returned unchanged.
1005        let accessor = Arc::new(StorageOptionsAccessor::with_static_options(HashMap::from(
1006            [("account_key".to_string(), "shared-key".to_string())],
1007        )));
1008        assert!(Arc::ptr_eq(&accessor.scoped_to_base(Some(1)), &accessor));
1009        assert!(Arc::ptr_eq(&accessor.scoped_to_base(None), &accessor));
1010
1011        // Scoping an already-scoped accessor is a no-op (the registry choke
1012        // point re-applies the default scope to every params it sees).
1013        let scoped = Arc::new(StorageOptionsAccessor::with_static_options(HashMap::from(
1014            [
1015                ("account_key".to_string(), "shared-key".to_string()),
1016                ("base_1.account_key".to_string(), "base1-key".to_string()),
1017            ],
1018        )))
1019        .scoped_to_base(Some(1));
1020        assert!(Arc::ptr_eq(&scoped.scoped_to_base(None), &scoped));
1021
1022        let provider_scoped = Arc::new(StorageOptionsAccessor::with_provider(Arc::new(
1023            MockStorageOptionsProvider::new(None),
1024        )))
1025        .scoped_to_base(Some(1));
1026        assert!(Arc::ptr_eq(
1027            &provider_scoped.scoped_to_base(None),
1028            &provider_scoped
1029        ));
1030    }
1031
1032    #[tokio::test]
1033    async fn test_scoped_to_base_provider_only_resolves_vended_options() {
1034        MockClock::set_system_time(Duration::from_secs(100_000));
1035
1036        // No initial options: scoped entries arrive only through the provider.
1037        let provider = Arc::new(MockBaseScopedVendingProvider {
1038            call_count: Arc::new(RwLock::new(0)),
1039            expires_in_millis: 600_000,
1040        });
1041        let parent = Arc::new(StorageOptionsAccessor::with_provider(provider.clone()));
1042
1043        let base1 = parent.scoped_to_base(Some(1));
1044        assert!(!Arc::ptr_eq(&base1, &parent));
1045        let result = base1.get_storage_options().await.unwrap();
1046        assert_eq!(result.0.get("account_key").unwrap(), "BASE1_1");
1047        assert!(!result.0.contains_key("base_1.account_key"));
1048
1049        let default = parent.scoped_to_base(None);
1050        let result = default.get_storage_options().await.unwrap();
1051        assert_eq!(result.0.get("account_key").unwrap(), "SHARED_1");
1052        assert!(!result.0.contains_key("base_1.account_key"));
1053
1054        // Both scopes were served from one parent fetch.
1055        assert_eq!(*provider.call_count.read().await, 1);
1056    }
1057
1058    #[tokio::test]
1059    async fn test_scoped_earlier_base_expiry_refreshes_parent() {
1060        MockClock::set_system_time(Duration::from_secs(100_000));
1061        let now_ms = MockClock::system_time().as_millis() as u64;
1062
1063        // Base 1 credentials expire before the shared ones; the parent must
1064        // refresh when the earliest credential is due, or the scoped accessor
1065        // would keep re-resolving stale base-1 credentials from a still-
1066        // "valid" parent cache.
1067        let provider = Arc::new(MockBaseScopedVendingProvider {
1068            call_count: Arc::new(RwLock::new(0)),
1069            expires_in_millis: 600_000,
1070        });
1071        let initial = HashMap::from([
1072            ("account_key".to_string(), "SHARED_0".to_string()),
1073            ("base_1.account_key".to_string(), "BASE1_0".to_string()),
1074            (
1075                EXPIRES_AT_MILLIS_KEY.to_string(),
1076                (now_ms + 600_000).to_string(),
1077            ),
1078            (
1079                format!("base_1.{}", EXPIRES_AT_MILLIS_KEY),
1080                (now_ms + 120_000).to_string(),
1081            ),
1082        ]);
1083        let parent = Arc::new(StorageOptionsAccessor::with_initial_and_provider(
1084            initial,
1085            provider.clone(),
1086        ));
1087
1088        let base1 = parent.scoped_to_base(Some(1));
1089        let result = base1.get_storage_options().await.unwrap();
1090        assert_eq!(result.0.get("account_key").unwrap(), "BASE1_0");
1091        assert_eq!(*provider.call_count.read().await, 0);
1092
1093        // Past the base-1 expiry but before the shared expiry: the parent's
1094        // effective expiry is the earlier one, so the refresh chain fetches
1095        // fresh credentials instead of re-serving BASE1_0.
1096        MockClock::set_system_time(Duration::from_secs(100_000 + 121));
1097        let result = base1.get_storage_options().await.unwrap();
1098        assert_eq!(result.0.get("account_key").unwrap(), "BASE1_1");
1099        assert_eq!(*provider.call_count.read().await, 1);
1100    }
1101
1102    #[tokio::test]
1103    async fn test_scoped_to_base_static() {
1104        let accessor = Arc::new(StorageOptionsAccessor::with_static_options(HashMap::from(
1105            [
1106                ("account_key".to_string(), "shared-key".to_string()),
1107                ("base_1.account_key".to_string(), "base1-key".to_string()),
1108            ],
1109        )));
1110
1111        let base1 = accessor.scoped_to_base(Some(1));
1112        let result = base1.get_storage_options().await.unwrap();
1113        assert_eq!(
1114            result.0,
1115            HashMap::from([("account_key".to_string(), "base1-key".to_string())])
1116        );
1117        assert!(!base1.has_provider());
1118
1119        let default = accessor.scoped_to_base(None);
1120        let result = default.get_storage_options().await.unwrap();
1121        assert_eq!(
1122            result.0,
1123            HashMap::from([("account_key".to_string(), "shared-key".to_string())])
1124        );
1125
1126        // Scoped accessor ids are stable across derivations and distinct per scope
1127        assert_eq!(
1128            accessor.scoped_to_base(Some(1)).accessor_id(),
1129            base1.accessor_id()
1130        );
1131        assert_ne!(base1.accessor_id(), default.accessor_id());
1132        assert_ne!(base1.accessor_id(), accessor.accessor_id());
1133    }
1134
1135    #[derive(Debug)]
1136    struct MockBaseScopedVendingProvider {
1137        call_count: Arc<RwLock<usize>>,
1138        expires_in_millis: u64,
1139    }
1140
1141    #[async_trait]
1142    impl StorageOptionsProvider for MockBaseScopedVendingProvider {
1143        async fn fetch_storage_options(&self) -> Result<Option<HashMap<String, String>>> {
1144            let count = {
1145                let mut c = self.call_count.write().await;
1146                *c += 1;
1147                *c
1148            };
1149            let now_ms = SystemTime::now()
1150                .duration_since(UNIX_EPOCH)
1151                .unwrap()
1152                .as_millis() as u64;
1153            Ok(Some(HashMap::from([
1154                ("account_key".to_string(), format!("SHARED_{}", count)),
1155                ("base_1.account_key".to_string(), format!("BASE1_{}", count)),
1156                (
1157                    EXPIRES_AT_MILLIS_KEY.to_string(),
1158                    (now_ms + self.expires_in_millis).to_string(),
1159                ),
1160            ])))
1161        }
1162
1163        fn provider_id(&self) -> String {
1164            "MockBaseScopedVendingProvider".to_string()
1165        }
1166    }
1167
1168    #[tokio::test]
1169    async fn test_scoped_to_base_refreshes_through_parent() {
1170        MockClock::set_system_time(Duration::from_secs(100_000));
1171        let now_ms = MockClock::system_time().as_millis() as u64;
1172
1173        let provider = Arc::new(MockBaseScopedVendingProvider {
1174            call_count: Arc::new(RwLock::new(0)),
1175            expires_in_millis: 600_000,
1176        });
1177        let initial = HashMap::from([
1178            ("account_key".to_string(), "SHARED_0".to_string()),
1179            ("base_1.account_key".to_string(), "BASE1_0".to_string()),
1180            (
1181                EXPIRES_AT_MILLIS_KEY.to_string(),
1182                (now_ms + 600_000).to_string(),
1183            ),
1184        ]);
1185        let parent = Arc::new(StorageOptionsAccessor::with_initial_and_provider(
1186            initial,
1187            provider.clone(),
1188        ));
1189
1190        let base1 = parent.scoped_to_base(Some(1));
1191        let default = parent.scoped_to_base(None);
1192        assert!(base1.has_provider());
1193
1194        // Initial options are resolved per scope without fetching
1195        let result = base1.get_storage_options().await.unwrap();
1196        assert_eq!(result.0.get("account_key").unwrap(), "BASE1_0");
1197        assert!(!result.0.contains_key("base_1.account_key"));
1198        let result = default.get_storage_options().await.unwrap();
1199        assert_eq!(result.0.get("account_key").unwrap(), "SHARED_0");
1200        assert_eq!(*provider.call_count.read().await, 0);
1201
1202        // Expire the vended options; the scoped accessor refreshes through the
1203        // parent and re-resolves the refreshed options for its scope.
1204        MockClock::set_system_time(Duration::from_secs(100_000 + 601));
1205        let result = base1.get_storage_options().await.unwrap();
1206        assert_eq!(result.0.get("account_key").unwrap(), "BASE1_1");
1207        assert_eq!(*provider.call_count.read().await, 1);
1208
1209        // The parent refresh is shared: other scopes see it without refetching
1210        let result = default.get_storage_options().await.unwrap();
1211        assert_eq!(result.0.get("account_key").unwrap(), "SHARED_1");
1212        assert_eq!(*provider.call_count.read().await, 1);
1213    }
1214
1215    #[tokio::test]
1216    async fn test_scoped_forced_refresh_reaches_origin_provider() {
1217        MockClock::set_system_time(Duration::from_secs(100_000));
1218        let now_ms = MockClock::system_time().as_millis() as u64;
1219
1220        let provider = Arc::new(MockBaseScopedVendingProvider {
1221            call_count: Arc::new(RwLock::new(0)),
1222            expires_in_millis: 600_000,
1223        });
1224        let initial = HashMap::from([
1225            ("account_key".to_string(), "SHARED_0".to_string()),
1226            ("base_1.account_key".to_string(), "BASE1_0".to_string()),
1227            (
1228                EXPIRES_AT_MILLIS_KEY.to_string(),
1229                (now_ms + 600_000).to_string(),
1230            ),
1231        ]);
1232        let parent = Arc::new(StorageOptionsAccessor::with_initial_and_provider(
1233            initial,
1234            provider.clone(),
1235        ));
1236        let base1 = parent.scoped_to_base(Some(1));
1237
1238        // A forced refresh must reach the origin provider even though both the
1239        // scoped and the parent caches are still valid.
1240        let result = base1.refresh_storage_options().await.unwrap();
1241        assert_eq!(result.0.get("account_key").unwrap(), "BASE1_1");
1242        assert_eq!(*provider.call_count.read().await, 1);
1243    }
1244}