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