Skip to main content

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}