rtb_credentials/resolver.rs
1//! The [`Resolver`] — walks a [`CredentialRef`] through the
2//! precedence chain defined by the framework spec.
3
4use std::sync::Arc;
5
6use secrecy::SecretString;
7
8use crate::error::CredentialError;
9use crate::reference::CredentialRef;
10use crate::store::{CredentialStore, KeyringStore};
11
12/// Walks a [`CredentialRef`] through its resolution chain, returning
13/// the first successful hit. The chain order is deliberately fixed:
14///
15/// 1. `env` — read `std::env::var(cref.env)`.
16/// 2. `keychain` — ask the injected [`CredentialStore`].
17/// 3. `literal` — use the embedded value. Refused when
18/// `std::env::var("CI").as_deref() == Ok("true")`.
19/// 4. `fallback_env` — read the ecosystem-default env var.
20///
21/// If every step misses, returns [`CredentialError::NotFound`].
22pub struct Resolver {
23 keychain: Arc<dyn CredentialStore>,
24}
25
26/// Which precedence layer would resolve a [`CredentialRef`].
27/// Returned by [`Resolver::probe`] — see that method for the
28/// resolution chain.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum ResolutionSource {
32 /// Resolved via `cref.env` — a tool-specific env var set by the
33 /// operator.
34 Env,
35 /// Resolved via `cref.keychain` — a value stored in the OS
36 /// keychain.
37 Keychain,
38 /// Resolved via `cref.literal` — the secret embedded in config.
39 /// Only reachable when not running under `CI=true`.
40 Literal,
41 /// Resolved via `cref.fallback_env` — an ecosystem-default env
42 /// var (`ANTHROPIC_API_KEY`, `GITHUB_TOKEN`, …).
43 FallbackEnv,
44}
45
46/// Outcome of [`Resolver::probe`].
47///
48/// Distinct from `Result<ResolutionSource, CredentialError>` so the
49/// "would have resolved literally but CI mode refuses" case has its
50/// own variant — operators reading `credentials list` need to see
51/// that distinction explicitly.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53#[non_exhaustive]
54pub enum ResolutionOutcome {
55 /// The credential resolves cleanly via the given source.
56 Resolved(ResolutionSource),
57 /// Only the literal layer is configured and `CI=true` is set, so
58 /// the resolver would refuse the resolution at runtime.
59 LiteralRefusedInCi,
60 /// No layer resolves — equivalent to
61 /// [`CredentialError::NotFound`] from [`Resolver::resolve`].
62 Missing,
63}
64
65impl Resolver {
66 /// Construct with an injected keychain-backed [`CredentialStore`].
67 /// Tests typically pass a [`crate::MemoryStore`] here.
68 #[must_use]
69 pub fn new(keychain: Arc<dyn CredentialStore>) -> Self {
70 Self { keychain }
71 }
72
73 /// Convenience: build a [`Resolver`] over [`KeyringStore::new()`]
74 /// — the platform-native default. Equivalent to
75 /// `Resolver::new(Arc::new(KeyringStore::new()))`.
76 #[must_use]
77 pub fn with_platform_default() -> Self {
78 Self::new(Arc::new(KeyringStore::new()))
79 }
80
81 /// Walk the chain and return the resolution source without
82 /// returning the secret value. Used by `rtb-cli`'s v0.4
83 /// `credentials list / doctor` subcommands to report which
84 /// precedence layer would supply each credential.
85 ///
86 /// Returns:
87 ///
88 /// - [`ResolutionOutcome::Resolved`] with the [`ResolutionSource`]
89 /// that hit, **if** the underlying value was readable.
90 /// - [`ResolutionOutcome::LiteralRefusedInCi`] when only the
91 /// literal layer is configured and `CI=true` is set.
92 /// - [`ResolutionOutcome::Missing`] when nothing resolves.
93 ///
94 /// Does the same I/O as [`Self::resolve`] (including a keychain
95 /// fetch when configured); the secret value is read and dropped
96 /// rather than returned. Operators can run `credentials list`
97 /// without their console scrolling secrets — at the cost of one
98 /// keychain round-trip per ref.
99 pub async fn probe(&self, cref: &CredentialRef) -> Result<ResolutionOutcome, CredentialError> {
100 if let Some(name) = cref.env.as_deref() {
101 if std::env::var(name).is_ok() {
102 return Ok(ResolutionOutcome::Resolved(ResolutionSource::Env));
103 }
104 }
105 if let Some(keyref) = cref.keychain.as_ref() {
106 match self.keychain.get(&keyref.service, &keyref.account).await {
107 Ok(_) => return Ok(ResolutionOutcome::Resolved(ResolutionSource::Keychain)),
108 Err(CredentialError::NotFound { .. }) => { /* fall through */ }
109 Err(other) => return Err(other),
110 }
111 }
112 if cref.literal.is_some() {
113 if is_ci() {
114 return Ok(ResolutionOutcome::LiteralRefusedInCi);
115 }
116 return Ok(ResolutionOutcome::Resolved(ResolutionSource::Literal));
117 }
118 if let Some(name) = cref.fallback_env.as_deref() {
119 if std::env::var(name).is_ok() {
120 return Ok(ResolutionOutcome::Resolved(ResolutionSource::FallbackEnv));
121 }
122 }
123 Ok(ResolutionOutcome::Missing)
124 }
125
126 /// Walk the chain and return the first hit.
127 pub async fn resolve(&self, cref: &CredentialRef) -> Result<SecretString, CredentialError> {
128 // 1. Env var via the ref's explicit `env` field.
129 if let Some(name) = cref.env.as_deref() {
130 if let Ok(val) = std::env::var(name) {
131 return Ok(SecretString::from(val));
132 }
133 }
134
135 // 2. Keychain.
136 if let Some(keyref) = cref.keychain.as_ref() {
137 match self.keychain.get(&keyref.service, &keyref.account).await {
138 Ok(secret) => return Ok(secret),
139 Err(CredentialError::NotFound { .. }) => { /* fall through */ }
140 Err(other) => return Err(other),
141 }
142 }
143
144 // 3. Literal in config — refused under CI.
145 if let Some(literal) = cref.literal.as_ref() {
146 if is_ci() {
147 return Err(CredentialError::LiteralRefusedInCi);
148 }
149 // `SecretString::clone` keeps the value inside a
150 // zeroize-on-drop container for the whole copy. Going via
151 // `expose_secret().to_string()` would leave a plain
152 // `String` on the stack that isn't wiped on drop.
153 return Ok(literal.clone());
154 }
155
156 // 4. Ecosystem-default env var fallback.
157 if let Some(name) = cref.fallback_env.as_deref() {
158 if let Ok(val) = std::env::var(name) {
159 return Ok(SecretString::from(val));
160 }
161 }
162
163 Err(CredentialError::NotFound { name: diagnostic_name(cref) })
164 }
165}
166
167impl Default for Resolver {
168 /// Same as [`Resolver::with_platform_default`].
169 fn default() -> Self {
170 Self::with_platform_default()
171 }
172}
173
174fn is_ci() -> bool {
175 std::env::var("CI").as_deref() == Ok("true")
176}
177
178fn diagnostic_name(cref: &CredentialRef) -> String {
179 cref.fallback_env
180 .as_deref()
181 .map(String::from)
182 .or_else(|| cref.env.as_deref().map(String::from))
183 .or_else(|| cref.keychain.as_ref().map(|k| format!("{}/{}", k.service, k.account)))
184 .unwrap_or_else(|| "<unnamed credential>".to_string())
185}