Skip to content

Commit 444d195

Browse files
committed
fix bootstrap credential handling
1 parent 6fac526 commit 444d195

4 files changed

Lines changed: 216 additions & 28 deletions

File tree

secretspec/src/cli/mod.rs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,9 @@ enum ProviderAction {
174174
/// Provider URI (e.g., "keyring://", "onepassword://Shared", "dotenv://.env.local")
175175
uri: String,
176176
/// Bootstrap credential binding `VAR=PROVIDER` (repeatable): the alias's
177-
/// provider reads `VAR` from `PROVIDER` before the environment. Only the
178-
/// bare-string source form is expressible here; add a `ref` by editing
179-
/// the config.
177+
/// provider uses a non-empty `VAR` from the environment first, falling
178+
/// back to `PROVIDER`. Only the bare-string source form is expressible
179+
/// here; add a `ref` by editing the config.
180180
#[arg(long = "env", value_name = "VAR=PROVIDER")]
181181
env: Vec<String>,
182182
},
@@ -626,7 +626,7 @@ pub fn main() -> Result<()> {
626626
for (var, source) in credentials {
627627
let entered = inquire::Password::new(&format!(
628628
"Enter {var} for provider '{name}' (source: {}):",
629-
source.provider
629+
source.display_provider()
630630
))
631631
.without_confirmation()
632632
.prompt()
@@ -1302,6 +1302,18 @@ mod tests {
13021302
}
13031303
}
13041304

1305+
#[test]
1306+
fn provider_add_help_describes_environment_first_precedence() {
1307+
let help = Cli::try_parse_from(["secretspec", "config", "provider", "add", "--help"])
1308+
.err()
1309+
.expect("--help should stop parsing")
1310+
.to_string();
1311+
1312+
assert!(help.contains("uses a non-empty `VAR` from the environment first"));
1313+
assert!(help.contains("falling back to `PROVIDER`"));
1314+
assert!(!help.contains("from `PROVIDER` before the environment"));
1315+
}
1316+
13051317
#[test]
13061318
fn provider_login_parses() {
13071319
let cli =

secretspec/src/provider/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -655,7 +655,7 @@ pub trait Provider: Send + Sync {
655655
fn with_base_dir(&mut self, _base_dir: &std::path::Path) {}
656656

657657
/// Hands the provider its bootstrap-credential overlay (see [`BootstrapEnv`])
658-
/// so credential reads can prefer it over the process environment.
658+
/// as a fallback when the process environment has no non-empty value.
659659
///
660660
/// Called once inside the registration factory, on the concrete provider
661661
/// value *before* any `Arc`/`Box` wrapping. This must not be a

secretspec/src/secrets.rs

Lines changed: 130 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use std::env;
1919
use std::io::{self, IsTerminal, Read};
2020
use std::path::{Path, PathBuf};
2121
use 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

6565
impl 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

secretspec/src/tests.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6123,6 +6123,46 @@ fn bootstrap_overlay_reads_convention_credential_from_source() {
61236123
);
61246124
}
61256125

6126+
#[test]
6127+
fn bootstrap_source_credential_reaches_target_provider_end_to_end() {
6128+
let _guard = scrub_resolution_env();
6129+
let _token = EnvVarGuard::remove("OP_SERVICE_ACCOUNT_TOKEN");
6130+
let temp = TempDir::new().unwrap();
6131+
6132+
let target_scope = |filename: &str, token: &str| {
6133+
let source = temp.path().join(filename);
6134+
std::fs::write(&source, format!("OP_SERVICE_ACCOUNT_TOKEN={token}\n")).unwrap();
6135+
let secrets = secrets_with_bootstrap_alias(
6136+
"onepassword://Private",
6137+
HashMap::from([(
6138+
"OP_SERVICE_ACCOUNT_TOKEN".to_string(),
6139+
BootstrapSource::from(format!("dotenv://{}", source.display())),
6140+
)]),
6141+
);
6142+
6143+
secrets
6144+
.get_provider(Some("target"), Some("default"))
6145+
.expect("the source credential should build the target provider")
6146+
.auth_scope_key()
6147+
.expect("onepassword should identify its effective authentication scope")
6148+
};
6149+
6150+
let first = target_scope("first.env", "source-token-a");
6151+
let same = target_scope("same.env", "source-token-a");
6152+
let different = target_scope("different.env", "source-token-b");
6153+
6154+
assert_eq!(
6155+
first, same,
6156+
"the same fetched credential yields the same scope"
6157+
);
6158+
assert_ne!(
6159+
first, different,
6160+
"changing the source credential must change the target's effective auth scope"
6161+
);
6162+
assert!(!first.contains("source-token-a"));
6163+
assert!(!different.contains("source-token-b"));
6164+
}
6165+
61266166
#[test]
61276167
fn bootstrap_overlay_reads_ref_addressed_credential() {
61286168
let _guard = scrub_resolution_env();
@@ -6224,6 +6264,35 @@ fn bootstrap_source_must_name_a_known_provider() {
62246264
);
62256265
}
62266266

6267+
#[test]
6268+
fn bootstrap_credentials_validates_every_source_before_login() {
6269+
let secrets = secrets_with_bootstrap_alias(
6270+
"bws://project",
6271+
HashMap::from([
6272+
(
6273+
"A_VALID_FIRST".to_string(),
6274+
BootstrapSource::from("dotenv://source.env"),
6275+
),
6276+
(
6277+
"Z_INVALID_LAST".to_string(),
6278+
BootstrapSource::from("not_a_real_provider"),
6279+
),
6280+
]),
6281+
);
6282+
6283+
let error = secrets.bootstrap_credentials("target").unwrap_err();
6284+
assert!(error.to_string().contains("Z_INVALID_LAST"));
6285+
}
6286+
6287+
#[test]
6288+
fn bootstrap_source_display_redacts_inline_credentials() {
6289+
let source = BootstrapSource::from("onepassword+token://ops_secret@Vault");
6290+
let displayed = source.display_provider();
6291+
6292+
assert_eq!(displayed, "onepassword+token://Vault");
6293+
assert!(!displayed.contains("ops_secret"));
6294+
}
6295+
62276296
#[test]
62286297
fn bootstrap_chain_is_limited_to_one_hop() {
62296298
let mut config = resolve_test_config(HashMap::new());

0 commit comments

Comments
 (0)