Skip to main content

fnox_core/providers/
resolver.rs

1//! Provider configuration resolution.
2//!
3//! This module handles resolving `ProviderConfig` to `ResolvedProviderConfig` by
4//! looking up any secret references in the configuration or environment.
5//!
6//! The resolution process supports recursive secret references (a provider's config
7//! can reference a secret from another provider) and detects circular dependencies.
8
9use crate::config::Config;
10use crate::env;
11use crate::error::{FnoxError, Result};
12use crate::suggest::{find_similar, format_suggestions};
13use std::collections::HashSet;
14
15use super::secret_ref::{OptionStringOrSecretRef, StringOrSecretRef};
16use super::{ProviderConfig, ResolvedProviderConfig};
17
18/// Context for resolving provider configurations, tracking the resolution stack
19/// to detect circular dependencies.
20pub struct ResolutionContext {
21    /// Stack of provider names currently being resolved (for cycle detection)
22    provider_stack: HashSet<String>,
23    /// Stack path for error messages
24    resolution_path: Vec<String>,
25}
26
27impl ResolutionContext {
28    /// Create a new resolution context
29    pub fn new() -> Self {
30        Self {
31            provider_stack: HashSet::new(),
32            resolution_path: Vec::new(),
33        }
34    }
35
36    /// Check if we're already resolving this provider (cycle detection)
37    fn is_resolving(&self, provider_name: &str) -> bool {
38        self.provider_stack.contains(provider_name)
39    }
40
41    /// Push a provider onto the resolution stack
42    pub fn push(&mut self, provider_name: &str) {
43        self.provider_stack.insert(provider_name.to_string());
44        self.resolution_path.push(provider_name.to_string());
45    }
46
47    /// Pop a provider from the resolution stack
48    pub fn pop(&mut self) {
49        if let Some(provider_name) = self.resolution_path.pop() {
50            self.provider_stack.remove(&provider_name);
51        }
52    }
53
54    /// Get the current resolution path as a string for error messages
55    fn path_string(&self) -> String {
56        self.resolution_path.join(" -> ")
57    }
58}
59
60impl Default for ResolutionContext {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66/// Resolve a `ProviderConfig` to a `ResolvedProviderConfig` by resolving any secret references.
67///
68/// This function handles recursive resolution - if a provider's config references a secret
69/// that itself uses another provider, that provider's config will also be resolved.
70///
71/// # Arguments
72/// * `config` - The full configuration containing secrets and providers
73/// * `profile` - The profile to use for secret lookups
74/// * `provider_name` - The name of the provider being resolved (for cycle detection)
75/// * `provider_config` - The provider configuration to resolve
76///
77/// # Returns
78/// A `ResolvedProviderConfig` with all secret references replaced with actual values.
79pub async fn resolve_provider_config(
80    config: &Config,
81    profile: &str,
82    provider_name: &str,
83    provider_config: &ProviderConfig,
84) -> Result<ResolvedProviderConfig> {
85    let mut ctx = ResolutionContext::new();
86    resolve_provider_config_with_context(config, profile, provider_name, provider_config, &mut ctx)
87        .await
88}
89
90/// Internal function that carries the resolution context for cycle detection.
91pub fn resolve_provider_config_with_context<'a>(
92    config: &'a Config,
93    profile: &'a str,
94    provider_name: &'a str,
95    provider_config: &'a ProviderConfig,
96    ctx: &'a mut ResolutionContext,
97) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ResolvedProviderConfig>> + Send + 'a>>
98{
99    Box::pin(async move {
100        // Check for circular dependency
101        if ctx.is_resolving(provider_name) {
102            return Err(FnoxError::ProviderConfigCycle {
103                provider: provider_name.to_string(),
104                cycle: format!("{} -> {}", ctx.path_string(), provider_name),
105            });
106        }
107
108        // Push onto resolution stack
109        ctx.push(provider_name);
110
111        // Resolve using generated match (capturing result to ensure cleanup on error)
112        let result = super::generated::providers_resolver::resolve_provider_config_match(
113            config,
114            profile,
115            provider_name,
116            provider_config,
117            ctx,
118        )
119        .await;
120
121        // Pop from resolution stack (always runs, even on error)
122        ctx.pop();
123
124        result
125    })
126}
127
128/// Resolve a required `StringOrSecretRef` field to its actual string value.
129pub fn resolve_required<'a>(
130    config: &'a Config,
131    profile: &'a str,
132    provider_name: &'a str,
133    _field_name: &'a str,
134    value: &'a StringOrSecretRef,
135    ctx: &'a mut ResolutionContext,
136) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
137    Box::pin(async move {
138        match value {
139            StringOrSecretRef::Literal(s) => Ok(s.clone()),
140            StringOrSecretRef::SecretRef { secret } => {
141                resolve_secret_ref(config, profile, provider_name, secret, ctx).await
142            }
143        }
144    })
145}
146
147/// Resolve an optional `OptionStringOrSecretRef` field to its actual value.
148pub fn resolve_option<'a>(
149    config: &'a Config,
150    profile: &'a str,
151    provider_name: &'a str,
152    value: &'a OptionStringOrSecretRef,
153    ctx: &'a mut ResolutionContext,
154) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Option<String>>> + Send + 'a>> {
155    Box::pin(async move {
156        match value.as_ref() {
157            None => Ok(None),
158            Some(StringOrSecretRef::Literal(s)) => Ok(Some(s.clone())),
159            Some(StringOrSecretRef::SecretRef { secret }) => {
160                let resolved =
161                    resolve_secret_ref(config, profile, provider_name, secret, ctx).await?;
162                Ok(Some(resolved))
163            }
164        }
165    })
166}
167
168/// Resolve a secret reference by name.
169///
170/// This looks up the secret in config first, then falls back to environment variable.
171/// If the secret is defined in config and uses another provider, that provider's
172/// config will also be resolved recursively.
173fn resolve_secret_ref<'a>(
174    config: &'a Config,
175    profile: &'a str,
176    provider_name: &'a str,
177    secret_name: &'a str,
178    ctx: &'a mut ResolutionContext,
179) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
180    Box::pin(async move {
181        // First, try to find the secret in config
182        let secrets = config.get_secrets(profile).unwrap_or_default();
183
184        if let Some(secret_config) = secrets.get(secret_name) {
185            // Secret found in config - resolve it
186            if let Some(secret_provider_name) = secret_config.provider()
187                && let Some(provider_value) = secret_config.value()
188            {
189                // This secret uses a provider - need to resolve that provider first
190                let providers = config.get_providers(profile);
191                if let Some(secret_provider_config) = providers.get(secret_provider_name) {
192                    if env::is_non_interactive()
193                        && secret_provider_config.requires_interactive_auth()
194                    {
195                        return Err(FnoxError::Provider(format!(
196                            "Provider '{}' requires interactive authentication and cannot be used in non-interactive mode. Use 'fnox exec' instead.",
197                            secret_provider_name
198                        )));
199                    }
200
201                    // Recursively resolve the provider's config
202                    let resolved_provider = resolve_provider_config_with_context(
203                        config,
204                        profile,
205                        secret_provider_name,
206                        secret_provider_config,
207                        ctx,
208                    )
209                    .await?;
210
211                    // Create the provider and get the secret
212                    let provider = super::get_provider_from_resolved(
213                        secret_provider_name,
214                        &resolved_provider,
215                    )?;
216                    return provider.get_secret(provider_value).await;
217                } else {
218                    // Find similar provider names for suggestion
219                    let available_providers: Vec<_> =
220                        providers.keys().map(|s| s.as_str()).collect();
221                    let similar = find_similar(secret_provider_name, available_providers);
222                    let suggestion = format_suggestions(&similar);
223
224                    return Err(FnoxError::ProviderNotConfigured {
225                        provider: secret_provider_name.to_string(),
226                        profile: profile.to_string(),
227                        config_path: config.provider_sources.get(secret_provider_name).cloned(),
228                        suggestion,
229                    });
230                }
231            }
232
233            // Secret has a default value
234            if let Some(ref default) = secret_config.default {
235                return Ok(default.clone());
236            }
237        }
238
239        // Fall back to environment variable
240        env::var(secret_name).map_err(|_| FnoxError::ProviderConfigResolutionFailed {
241            provider: provider_name.to_string(),
242            secret: secret_name.to_string(),
243            details: format!(
244                "Secret '{}' not found in config or environment",
245                secret_name
246            ),
247        })
248    })
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn test_resolution_context_cycle_detection() {
257        let mut ctx = ResolutionContext::new();
258
259        assert!(!ctx.is_resolving("provider_a"));
260
261        ctx.push("provider_a");
262        assert!(ctx.is_resolving("provider_a"));
263        assert!(!ctx.is_resolving("provider_b"));
264
265        ctx.push("provider_b");
266        assert!(ctx.is_resolving("provider_a"));
267        assert!(ctx.is_resolving("provider_b"));
268
269        ctx.pop();
270        assert!(ctx.is_resolving("provider_a"));
271        assert!(!ctx.is_resolving("provider_b"));
272
273        ctx.pop();
274        assert!(!ctx.is_resolving("provider_a"));
275    }
276
277    #[test]
278    fn test_resolution_path() {
279        let mut ctx = ResolutionContext::new();
280
281        ctx.push("a");
282        ctx.push("b");
283        ctx.push("c");
284
285        assert_eq!(ctx.path_string(), "a -> b -> c");
286    }
287}