@@ -19,7 +19,7 @@ use std::env;
1919use std:: io:: { self , IsTerminal , Read } ;
2020use std:: path:: { Path , PathBuf } ;
2121use std:: process:: Command ;
22- use std:: sync:: Mutex ;
22+ use std:: sync:: { Arc , Mutex } ;
2323
2424/// Emits a warning when a provider in a fallback chain fails so the user
2525/// can see why a particular link was skipped, without aborting the chain.
@@ -63,6 +63,11 @@ fn sorted_bootstrap_entries(
6363}
6464
6565impl BootstrapSource {
66+ /// Credential-free provider text for prompts and diagnostics.
67+ pub ( crate ) fn display_provider ( & self ) -> String {
68+ crate :: audit:: redact_uri_strict ( & self . provider )
69+ }
70+
6671 /// The store location this source reads and writes: the pinned `ref`, or
6772 /// the convention path for the active project and profile. The single
6873 /// derivation both [`Secrets::resolve_bootstrap_overlay`] (read) and
@@ -81,14 +86,72 @@ impl BootstrapSource {
8186 /// (`onepassword+token://tok@Vault`), and this string reaches stderr and
8287 /// the `config provider login` output.
8388 fn location ( & self , project : & str , profile : & str , var : & str ) -> String {
84- let provider = crate :: audit :: redact_uri_strict ( & self . provider ) ;
89+ let provider = self . display_provider ( ) ;
8590 match & self . reference {
8691 Some ( reference) => format ! ( "{provider} at {}" , reference. render( ) ) ,
8792 None => format ! ( "{provider} at {project}/{profile}/{var}" ) ,
8893 }
8994 }
9095}
9196
97+ type BootstrapOverlayKey = ( String , String ) ;
98+ type BootstrapOverlaySlot = Arc < Mutex < Option < BootstrapEnv > > > ;
99+
100+ /// Memoized bootstrap overlays with single-flight population per key.
101+ ///
102+ /// The outer mutex protects only the key-to-slot map. Resolution runs while
103+ /// holding the selected slot, so callers for the same alias/profile wait for
104+ /// its first fetch while unrelated keys can populate concurrently.
105+ #[ derive( Default ) ]
106+ struct BootstrapOverlayCache {
107+ entries : Mutex < HashMap < BootstrapOverlayKey , BootstrapOverlaySlot > > ,
108+ }
109+
110+ impl BootstrapOverlayCache {
111+ fn get_or_try_init < F > ( & self , key : BootstrapOverlayKey , resolve : F ) -> Result < BootstrapEnv >
112+ where
113+ F : FnOnce ( ) -> Result < BootstrapEnv > ,
114+ {
115+ let slot = {
116+ let mut entries = self . entries . lock ( ) . unwrap ( ) ;
117+ Arc :: clone (
118+ entries
119+ . entry ( key. clone ( ) )
120+ . or_insert_with ( || Arc :: new ( Mutex :: new ( None ) ) ) ,
121+ )
122+ } ;
123+
124+ let mut cached = slot. lock ( ) . unwrap ( ) ;
125+ if let Some ( overlay) = cached. as_ref ( ) {
126+ return Ok ( overlay. clone ( ) ) ;
127+ }
128+
129+ match resolve ( ) {
130+ Ok ( overlay) => {
131+ * cached = Some ( overlay. clone ( ) ) ;
132+ Ok ( overlay)
133+ }
134+ Err ( err) => {
135+ // Do not memoize failures: a later operation may succeed after
136+ // credentials or provider availability change.
137+ drop ( cached) ;
138+ let mut entries = self . entries . lock ( ) . unwrap ( ) ;
139+ if entries
140+ . get ( & key)
141+ . is_some_and ( |current| Arc :: ptr_eq ( current, & slot) )
142+ {
143+ entries. remove ( & key) ;
144+ }
145+ Err ( err)
146+ }
147+ }
148+ }
149+
150+ fn clear ( & self ) {
151+ self . entries . lock ( ) . unwrap ( ) . clear ( ) ;
152+ }
153+ }
154+
92155/// Emits a warning when the primary provider for a batch fetch fails (either
93156/// during construction or during `get_many`); affected secrets will still be
94157/// retried via their per-secret fallback chain below.
@@ -194,7 +257,7 @@ pub struct Secrets {
194257 /// not reuse the other profile's credential. Cleared by
195258 /// [`Secrets::store_bootstrap_credential`] so a freshly stored credential
196259 /// is re-read.
197- bootstrap_overlays : Mutex < HashMap < ( String , String ) , BootstrapEnv > > ,
260+ bootstrap_overlays : BootstrapOverlayCache ,
198261}
199262
200263/// secretspec's own opt-in for marking the current process as an agent. Lets any
@@ -341,7 +404,7 @@ impl Secrets {
341404 reason : None ,
342405 require_reason : RequireReason :: Never ,
343406 audit : None ,
344- bootstrap_overlays : Mutex :: new ( HashMap :: new ( ) ) ,
407+ bootstrap_overlays : BootstrapOverlayCache :: default ( ) ,
345408 }
346409 }
347410
@@ -417,7 +480,7 @@ impl Secrets {
417480 profile : None ,
418481 reason : env_reason ( ) ,
419482 audit,
420- bootstrap_overlays : Mutex :: new ( HashMap :: new ( ) ) ,
483+ bootstrap_overlays : BootstrapOverlayCache :: default ( ) ,
421484 } )
422485 }
423486
@@ -531,25 +594,16 @@ impl Secrets {
531594 ) -> Result < Box < dyn ProviderTrait > > {
532595 // When `spec` names an alias with a bootstrap `env` map, resolve those
533596 // credentials from their source providers into an overlay the built
534- // provider reads before the process environment. Memoized per
535- // (profile, spec) so rebuilding a provider (per-secret chain walks,
597+ // provider uses when the process environment has no non-empty value.
598+ // Memoized per (profile, spec) so rebuilding a provider (per-secret chain walks,
536599 // interactive prompting) does not refetch the same credentials from
537600 // the source store, while a profile switch on this instance does not
538601 // reuse the other profile's credentials.
539602 let profile = self . resolve_profile_name ( profile) ;
540603 let key = ( profile. clone ( ) , spec. clone ( ) ) ;
541- let cached = self . bootstrap_overlays . lock ( ) . unwrap ( ) . get ( & key) . cloned ( ) ;
542- let overlay = match cached {
543- Some ( overlay) => overlay,
544- None => {
545- let overlay = self . resolve_bootstrap_overlay ( & spec, & profile) ?;
546- self . bootstrap_overlays
547- . lock ( )
548- . unwrap ( )
549- . insert ( key, overlay. clone ( ) ) ;
550- overlay
551- }
552- } ;
604+ let overlay = self
605+ . bootstrap_overlays
606+ . get_or_try_init ( key, || self . resolve_bootstrap_overlay ( & spec, & profile) ) ?;
553607 self . build_provider_with_overlay ( & spec, overlay)
554608 }
555609
@@ -583,7 +637,7 @@ impl Secrets {
583637
584638 /// Resolves the bootstrap overlay for a provider spec: for each `(VAR, source)`
585639 /// its alias declares in `env`, fetch the credential from the source provider
586- /// and collect it, so the built provider reads it before the environment.
640+ /// and collect it as a fallback for a missing or empty environment value .
587641 ///
588642 /// `profile` scopes the convention path a bare-string source reads from.
589643 /// Returns an empty overlay for a spec that is not an alias, or an alias with
@@ -702,12 +756,17 @@ impl Secrets {
702756 }
703757
704758 /// The bootstrap credentials a provider alias declares, sorted by variable
705- /// name, for the `config provider login` flow. Errors if the alias is not
706- /// defined; returns an empty list for an alias with no `env`.
759+ /// name, for the `config provider login` flow. Validates every source before
760+ /// returning any credentials. Errors if the alias is not defined; returns
761+ /// an empty list for an alias with no `env`.
707762 pub ( crate ) fn bootstrap_credentials (
708763 & self ,
709764 alias : & str ,
710765 ) -> Result < Vec < ( String , BootstrapSource ) > > {
766+ // Validate the complete map before returning any entry. The login CLI
767+ // prompts and writes only after this method succeeds, so a later-sorted
768+ // invalid source cannot leave earlier credentials partially stored.
769+ self . validate_bootstrap_sources ( alias) ?;
711770 let entry = self
712771 . lookup_provider_alias_entry ( alias)
713772 . ok_or_else ( || SecretSpecError :: ProviderNotFound ( alias. to_string ( ) ) ) ?;
@@ -752,7 +811,7 @@ impl Secrets {
752811 result?;
753812 // The stored credential replaces whatever an earlier resolution
754813 // memoized; drop the memo so the next build re-reads it.
755- self . bootstrap_overlays . lock ( ) . unwrap ( ) . clear ( ) ;
814+ self . bootstrap_overlays . clear ( ) ;
756815 Ok ( source. location ( & project, & profile, var) )
757816 }
758817
@@ -3207,6 +3266,54 @@ mod policy_tests {
32073266 }
32083267}
32093268
3269+ #[ cfg( test) ]
3270+ mod bootstrap_overlay_cache_tests {
3271+ use super :: * ;
3272+ use std:: sync:: atomic:: { AtomicUsize , Ordering } ;
3273+ use std:: sync:: { Arc , Barrier } ;
3274+ use std:: thread;
3275+ use std:: time:: Duration ;
3276+
3277+ #[ test]
3278+ fn concurrent_population_for_one_key_is_single_flight ( ) {
3279+ const CALLERS : usize = 8 ;
3280+ let cache = Arc :: new ( BootstrapOverlayCache :: default ( ) ) ;
3281+ let start = Arc :: new ( Barrier :: new ( CALLERS ) ) ;
3282+ let fetches = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
3283+
3284+ let threads: Vec < _ > = ( 0 ..CALLERS )
3285+ . map ( |_| {
3286+ let cache = Arc :: clone ( & cache) ;
3287+ let start = Arc :: clone ( & start) ;
3288+ let fetches = Arc :: clone ( & fetches) ;
3289+ thread:: spawn ( move || {
3290+ start. wait ( ) ;
3291+ cache
3292+ . get_or_try_init ( ( "default" . into ( ) , "target" . into ( ) ) , || {
3293+ fetches. fetch_add ( 1 , Ordering :: SeqCst ) ;
3294+ // Keep the first population in flight long enough for
3295+ // every caller to contend on the same key.
3296+ thread:: sleep ( Duration :: from_millis ( 50 ) ) ;
3297+ let mut overlay = BootstrapEnv :: new ( ) ;
3298+ overlay. insert ( "TOKEN" . into ( ) , SecretString :: new ( "value" . into ( ) ) ) ;
3299+ Ok ( overlay)
3300+ } )
3301+ . unwrap ( )
3302+ } )
3303+ } )
3304+ . collect ( ) ;
3305+
3306+ for thread in threads {
3307+ let overlay = thread. join ( ) . unwrap ( ) ;
3308+ assert_eq ! (
3309+ overlay. get( "TOKEN" ) . map( |value| value. expose_secret( ) ) ,
3310+ Some ( "value" )
3311+ ) ;
3312+ }
3313+ assert_eq ! ( fetches. load( Ordering :: SeqCst ) , 1 ) ;
3314+ }
3315+ }
3316+
32103317/// Serializes tests that mutate the process-global current directory. The current
32113318/// directory is shared across all threads, so two `set_current_dir` tests running
32123319/// concurrently (the default under `cargo test`) would corrupt each other. Any test
0 commit comments