use crate::auth_prompt::prompt_and_run_auth;
use crate::config::{Config, IfMissing, SecretConfig};
use crate::env;
use crate::error::{FnoxError, Result};
use crate::providers::{ProviderConfig, get_provider_resolved};
use crate::settings::Settings;
use crate::source_registry;
use crate::suggest::{find_similar, format_suggestions};
use indexmap::IndexMap;
use miette::SourceSpan;
use std::collections::{HashMap, HashSet};
fn extract_json_path(json_str: &str, path: &str) -> Result<String> {
let value: serde_json::Value = serde_json::from_str(json_str)
.map_err(|e| FnoxError::Config(format!("Failed to parse JSON secret: {}", e)))?;
let mut current = &value;
for part in split_key_path(path) {
current = current.get(&part).ok_or_else(|| {
FnoxError::Config(format!("JSON path '{}' not found in secret", path))
})?;
}
match current {
serde_json::Value::String(s) => Ok(s.clone()),
serde_json::Value::Null => Ok("null".to_string()),
other => Ok(other.to_string()), }
}
fn split_key_path(key: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut chars = key.chars();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(next_char) = chars.next() {
current.push(next_char);
} else {
current.push('\\');
}
} else if c == '.' {
parts.push(std::mem::take(&mut current));
} else {
current.push(c);
}
}
parts.push(current);
parts
}
fn apply_post_processing(value: String, secret_config: &SecretConfig) -> Result<String> {
if let Some(ref json_path) = secret_config.json_path {
if json_path.is_empty() {
return Err(FnoxError::Config("json_path must not be empty".to_string()));
}
extract_json_path(&value, json_path)
} else {
Ok(value)
}
}
fn create_provider_not_configured_error(
provider_name: &str,
profile: &str,
secret_config: &SecretConfig,
config: &Config,
) -> FnoxError {
let providers = config.get_providers(profile);
let available_providers: Vec<_> = providers.keys().map(|s| s.as_str()).collect();
let similar = find_similar(provider_name, available_providers);
let suggestion = format_suggestions(&similar);
if let (Some(path), Some(span)) = (&secret_config.source_path, secret_config.provider_span())
&& let Some(src) = source_registry::get_named_source(path)
{
return FnoxError::ProviderNotConfiguredWithSource {
provider: provider_name.to_string(),
profile: profile.to_string(),
suggestion,
src,
span: SourceSpan::new(span.start.into(), span.end - span.start),
};
}
FnoxError::ProviderNotConfigured {
provider: provider_name.to_string(),
profile: profile.to_string(),
config_path: secret_config.source_path.clone(),
suggestion,
}
}
pub fn resolve_if_missing_behavior(secret_config: &SecretConfig, config: &Config) -> IfMissing {
Settings::try_get()
.ok()
.and_then(|s| {
s.if_missing
.as_ref()
.map(|value| match value.to_lowercase().as_str() {
"error" => IfMissing::Error,
"warn" => IfMissing::Warn,
"ignore" => IfMissing::Ignore,
_ => {
eprintln!(
"Warning: Invalid if_missing value '{}', using 'warn'",
value
);
IfMissing::Warn
}
})
})
.or(secret_config.if_missing)
.or(config.if_missing)
.or_else(|| {
Settings::try_get().ok().and_then(|s| {
s.if_missing_default
.as_ref()
.map(|value| match value.to_lowercase().as_str() {
"error" => IfMissing::Error,
"warn" => IfMissing::Warn,
"ignore" => IfMissing::Ignore,
_ => {
eprintln!(
"Warning: Invalid FNOX_IF_MISSING_DEFAULT value '{}', using 'warn'",
value
);
IfMissing::Warn
}
})
})
})
.unwrap_or(IfMissing::Warn)
}
pub fn handle_provider_error(
key: &str,
error: FnoxError,
if_missing: IfMissing,
use_tracing: bool,
) -> Option<FnoxError> {
match if_missing {
IfMissing::Error => {
if use_tracing {
tracing::error!("Error resolving secret '{}': {}", key, error);
} else {
eprintln!("Error resolving secret '{}': {}", key, error);
}
Some(error)
}
IfMissing::Warn => {
if use_tracing {
tracing::warn!("Error resolving secret '{}': {}", key, error);
} else {
eprintln!("Warning: Error resolving secret '{}': {}", key, error);
}
None
}
IfMissing::Ignore => {
None
}
}
}
pub async fn resolve_secret(
config: &Config,
profile: &str,
key: &str,
secret_config: &SecretConfig,
) -> Result<Option<String>> {
let value_to_process =
if let Some(value) = try_resolve_from_provider(config, profile, secret_config).await? {
Some(value)
} else if let Some(default) = &secret_config.default {
tracing::debug!("Using default value for secret '{}'", key);
Some(default.clone())
} else if let Ok(env_value) = env::var(key) {
tracing::debug!("Found secret '{}' in current environment", key);
Some(env_value)
} else {
None
};
if let Some(value) = value_to_process {
let processed = apply_post_processing(value, secret_config)?;
return Ok(Some(processed));
}
handle_missing_secret(key, secret_config, config)
}
async fn try_resolve_from_provider(
config: &Config,
profile: &str,
secret_config: &SecretConfig,
) -> Result<Option<String>> {
let (provider_name, provider_value) = if let Some(ref sync) = secret_config.sync {
(sync.provider.clone(), sync.value.clone())
} else {
let Some(pv) = secret_config.value() else {
return Ok(None);
};
let pn = if let Some(provider_name) = secret_config.provider() {
provider_name.to_string()
} else if let Some(default_provider) = config.get_default_provider(profile)? {
default_provider
} else {
return Ok(None);
};
(pn, pv.to_string())
};
let providers = config.get_providers(profile);
let provider_config = providers.get(&provider_name).ok_or_else(|| {
create_provider_not_configured_error(&provider_name, profile, secret_config, config)
})?;
try_resolve_with_auth_retry(
config,
profile,
&provider_name,
provider_config,
&provider_value,
)
.await
}
async fn try_resolve_with_auth_retry(
config: &Config,
profile: &str,
provider_name: &str,
provider_config: &ProviderConfig,
provider_value: &str,
) -> Result<Option<String>> {
match try_get_secret(
config,
profile,
provider_name,
provider_config,
provider_value,
)
.await
{
Ok(value) => Ok(Some(value)),
Err(error) => {
if prompt_and_run_auth(config, provider_config, provider_name, &error)? {
try_get_secret(
config,
profile,
provider_name,
provider_config,
provider_value,
)
.await
.map(Some)
} else {
Err(error)
}
}
}
}
async fn try_get_secret(
config: &Config,
profile: &str,
provider_name: &str,
provider_config: &ProviderConfig,
provider_value: &str,
) -> Result<String> {
if crate::env::is_non_interactive() && provider_config.requires_interactive_auth() {
return Err(FnoxError::Provider(format!(
"Provider '{}' requires interactive authentication and cannot be used in non-interactive mode. Use 'fnox exec' instead.",
provider_name
)));
}
let provider = get_provider_resolved(config, profile, provider_name, provider_config).await?;
provider.get_secret(provider_value).await
}
fn handle_missing_secret(
key: &str,
secret_config: &SecretConfig,
config: &Config,
) -> Result<Option<String>> {
let if_missing = resolve_if_missing_behavior(secret_config, config);
match if_missing {
IfMissing::Error => Err(FnoxError::Config(format!(
"Secret '{}' not found and no default provided",
key
))),
IfMissing::Warn => {
eprintln!(
"Warning: Secret '{}' not found and no default provided",
key
);
Ok(None)
}
IfMissing::Ignore => Ok(None),
}
}
pub async fn resolve_secrets_batch(
config: &Config,
profile: &str,
secrets: &IndexMap<String, SecretConfig>,
) -> Result<IndexMap<String, Option<String>>> {
let mut secret_provider: HashMap<String, (String, String)> = HashMap::new(); let mut no_provider = Vec::new();
let providers = config.get_providers(profile);
for (key, secret_config) in secrets {
if let Some(ref sync) = secret_config.sync {
secret_provider.insert(key.clone(), (sync.provider.clone(), sync.value.clone()));
continue;
}
if let Some(provider_value) = secret_config.value() {
let provider_name = if let Some(provider_name) = secret_config.provider() {
provider_name.to_string()
} else if let Ok(Some(default_provider)) = config.get_default_provider(profile) {
default_provider
} else {
no_provider.push(key.clone());
continue;
};
secret_provider.insert(key.clone(), (provider_name, provider_value.to_string()));
} else {
no_provider.push(key.clone());
}
}
let env_deps_for_secret: HashMap<String, &[&str]> = secret_provider
.iter()
.map(|(key, (provider_name, _))| {
let deps = providers
.get(provider_name)
.map(|pc| pc.env_dependencies())
.unwrap_or(&[]);
(key.clone(), deps)
})
.collect();
let all_keys: Vec<String> = secrets.keys().cloned().collect();
let no_provider_set: HashSet<&str> = no_provider.iter().map(|s| s.as_str()).collect();
let (levels, cycle) =
compute_resolution_levels(&all_keys, &env_deps_for_secret, &no_provider_set);
let mut temp_results: HashMap<String, Option<String>> = HashMap::new();
for ready in &levels {
let level_results = resolve_level(
config,
profile,
secrets,
&secret_provider,
&no_provider,
ready,
)
.await?;
for (key, value) in &level_results {
if let Some(val) = value {
env::set_var(key, val);
}
}
temp_results.extend(level_results);
}
if !cycle.is_empty() {
tracing::warn!(
"Detected dependency cycle among secrets: {}. Resolving best-effort.",
cycle.join(", ")
);
let level_results = resolve_level(
config,
profile,
secrets,
&secret_provider,
&no_provider,
&cycle,
)
.await?;
temp_results.extend(level_results);
}
let mut results = IndexMap::new();
for (key, _secret_config) in secrets {
if let Some(value) = temp_results.remove(key) {
results.insert(key.clone(), value);
}
}
Ok(results)
}
fn compute_resolution_levels(
all_keys: &[String],
env_deps_for_secret: &HashMap<String, &[&str]>,
no_provider: &HashSet<&str>,
) -> (Vec<Vec<String>>, Vec<String>) {
let secret_keys: HashSet<&str> = all_keys.iter().map(|k| k.as_str()).collect();
let mut in_degree: HashMap<String, usize> = HashMap::new();
let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
for (key, deps) in env_deps_for_secret {
let mut degree = 0usize;
for dep_env in *deps {
if secret_keys.contains(dep_env) && *dep_env != key.as_str() {
degree += 1;
dependents
.entry(dep_env.to_string())
.or_default()
.push(key.clone());
}
}
in_degree.insert(key.clone(), degree);
}
for key in all_keys {
if no_provider.contains(key.as_str()) {
in_degree.insert(key.clone(), 0);
}
}
let mut remaining: std::collections::HashSet<String> = in_degree.keys().cloned().collect();
let mut levels = Vec::new();
loop {
let ready: Vec<String> = remaining
.iter()
.filter(|k| in_degree.get(*k).copied().unwrap_or(0) == 0)
.cloned()
.collect();
if ready.is_empty() {
break;
}
for k in &ready {
remaining.remove(k);
}
for key in &ready {
if let Some(deps) = dependents.get(key) {
for dep in deps {
if let Some(d) = in_degree.get_mut(dep) {
*d = d.saturating_sub(1);
}
}
}
}
levels.push(ready);
}
let cycle: Vec<String> = remaining.into_iter().collect();
(levels, cycle)
}
async fn resolve_level(
config: &Config,
profile: &str,
secrets: &IndexMap<String, SecretConfig>,
secret_provider: &HashMap<String, (String, String)>,
no_provider: &[String],
ready: &[String],
) -> Result<HashMap<String, Option<String>>> {
use futures::stream::{self, StreamExt};
let mut by_provider: HashMap<String, Vec<(String, String)>> = HashMap::new();
let mut level_no_provider = Vec::new();
for key in ready {
if let Some((provider_name, provider_value)) = secret_provider.get(key) {
by_provider
.entry(provider_name.clone())
.or_default()
.push((key.clone(), provider_value.clone()));
} else if no_provider.contains(key) {
level_no_provider.push(key.clone());
}
}
let mut temp_results = HashMap::new();
let provider_results: Vec<_> = stream::iter(by_provider)
.map(|(provider_name, provider_secrets)| async move {
resolve_provider_batch(config, profile, secrets, &provider_name, provider_secrets).await
})
.buffer_unordered(10)
.collect()
.await;
for provider_result in provider_results {
temp_results.extend(provider_result?);
}
let no_provider_results: Vec<Result<_>> = stream::iter(level_no_provider)
.map(|key| async move {
let secret_config = &secrets[&key];
let value = resolve_secret(config, profile, &key, secret_config).await?;
Ok((key, value))
})
.buffer_unordered(10)
.collect()
.await;
for result in no_provider_results {
let (key, value) = result?;
temp_results.insert(key, value);
}
Ok(temp_results)
}
async fn resolve_provider_batch(
config: &Config,
profile: &str,
secrets: &IndexMap<String, SecretConfig>,
provider_name: &str,
provider_secrets: Vec<(String, String)>,
) -> Result<HashMap<String, Option<String>>> {
let mut results = HashMap::new();
tracing::debug!(
"Resolving {} secrets from provider '{}' using batch",
provider_secrets.len(),
provider_name
);
let providers = config.get_providers(profile);
let provider_config = match providers.get(provider_name) {
Some(config) => config,
None => {
let available_providers: Vec<_> = providers.keys().map(|s| s.as_str()).collect();
let similar = find_similar(provider_name, available_providers);
let suggestion = format_suggestions(&similar);
for (key, _) in &provider_secrets {
let secret_config = &secrets[key];
let if_missing = resolve_if_missing_behavior(secret_config, config);
let error = FnoxError::ProviderNotConfigured {
provider: provider_name.to_string(),
profile: profile.to_string(),
config_path: config.provider_sources.get(provider_name).cloned(),
suggestion: suggestion.clone(),
};
if let Some(error) = handle_provider_error(key, error, if_missing, true) {
return Err(error);
}
results.insert(key.clone(), None);
}
return Ok(results);
}
};
if crate::env::is_non_interactive() && provider_config.requires_interactive_auth() {
for (key, _) in &provider_secrets {
let secret_config = &secrets[key];
let if_missing = resolve_if_missing_behavior(secret_config, config);
let error = FnoxError::Provider(format!(
"Provider '{}' requires interactive authentication and cannot be used in non-interactive mode. Use 'fnox exec' instead.",
provider_name
));
if let Some(error) = handle_provider_error(key, error, if_missing, true) {
return Err(error);
}
results.insert(key.clone(), None);
}
return Ok(results);
}
try_batch_with_auth_retry(
config,
profile,
secrets,
provider_name,
provider_config,
&provider_secrets,
&mut results,
)
.await
}
async fn try_batch_with_auth_retry(
config: &Config,
profile: &str,
secrets: &IndexMap<String, SecretConfig>,
provider_name: &str,
provider_config: &ProviderConfig,
provider_secrets: &[(String, String)],
results: &mut HashMap<String, Option<String>>,
) -> Result<HashMap<String, Option<String>>> {
match try_get_secrets_batch(
config,
profile,
provider_name,
provider_config,
provider_secrets,
)
.await
{
Ok(batch_results) => {
let auth_error = extract_auth_error_from_batch(&batch_results);
if let Some(ref auth_err) = auth_error
&& prompt_and_run_auth(config, provider_config, provider_name, auth_err)?
{
let retry_results = try_get_secrets_batch(
config,
profile,
provider_name,
provider_config,
provider_secrets,
)
.await?;
process_batch_results(secrets, config, retry_results, results)?;
return Ok(std::mem::take(results));
}
process_batch_results(secrets, config, batch_results, results)?;
Ok(std::mem::take(results))
}
Err(error) => {
if prompt_and_run_auth(config, provider_config, provider_name, &error)? {
match try_get_secrets_batch(
config,
profile,
provider_name,
provider_config,
provider_secrets,
)
.await
{
Ok(batch_results) => {
process_batch_results(secrets, config, batch_results, results)?;
Ok(std::mem::take(results))
}
Err(retry_error) => Err(retry_error),
}
} else {
handle_batch_error(secrets, config, provider_secrets, &error, results)
}
}
}
}
fn handle_batch_error(
secrets: &IndexMap<String, SecretConfig>,
config: &Config,
provider_secrets: &[(String, String)],
error: &FnoxError,
results: &mut HashMap<String, Option<String>>,
) -> Result<HashMap<String, Option<String>>> {
for (key, _) in provider_secrets {
let secret_config = &secrets[key];
let if_missing = resolve_if_missing_behavior(secret_config, config);
let provider_error = FnoxError::Provider(error.to_string());
if let Some(err) = handle_provider_error(key, provider_error, if_missing, true) {
return Err(err);
}
results.insert(key.clone(), None);
}
Ok(std::mem::take(results))
}
fn extract_auth_error_from_batch(
batch_results: &HashMap<String, Result<String>>,
) -> Option<FnoxError> {
batch_results.values().find_map(|result| match result {
Err(FnoxError::ProviderAuthFailed {
provider,
details,
hint,
url,
}) => Some(FnoxError::ProviderAuthFailed {
provider: provider.clone(),
details: details.clone(),
hint: hint.clone(),
url: url.clone(),
}),
_ => None,
})
}
async fn try_get_secrets_batch(
config: &Config,
profile: &str,
provider_name: &str,
provider_config: &ProviderConfig,
provider_secrets: &[(String, String)],
) -> Result<HashMap<String, Result<String>>> {
let provider = get_provider_resolved(config, profile, provider_name, provider_config).await?;
Ok(provider.get_secrets_batch(provider_secrets).await)
}
fn process_batch_results(
secrets: &IndexMap<String, SecretConfig>,
config: &Config,
batch_results: HashMap<String, Result<String>>,
results: &mut HashMap<String, Option<String>>,
) -> Result<()> {
for (key, result) in batch_results {
let secret_config = &secrets[&key];
match result {
Ok(value) => {
match apply_post_processing(value, secret_config) {
Ok(processed) => {
results.insert(key, Some(processed));
}
Err(e) => {
return Err(e);
}
}
}
Err(e) => {
let if_missing = resolve_if_missing_behavior(secret_config, config);
if let Some(error) = handle_provider_error(&key, e, if_missing, true) {
return Err(error);
}
results.insert(key, None);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn compute_sorted(
all_keys: &[&str],
env_deps: &[(&str, &[&str])],
no_provider: &[&str],
) -> (Vec<Vec<String>>, Vec<String>) {
let all: Vec<String> = all_keys.iter().map(|s| s.to_string()).collect();
let deps: HashMap<String, &[&str]> =
env_deps.iter().map(|(k, v)| (k.to_string(), *v)).collect();
let np: HashSet<&str> = no_provider.iter().copied().collect();
let (mut levels, mut cycle) = compute_resolution_levels(&all, &deps, &np);
for level in &mut levels {
level.sort();
}
cycle.sort();
(levels, cycle)
}
#[test]
fn test_no_dependencies() {
let (levels, cycle) =
compute_sorted(&["A", "B", "C"], &[("A", &[]), ("B", &[]), ("C", &[])], &[]);
assert!(cycle.is_empty());
assert_eq!(levels.len(), 1);
assert_eq!(levels[0], vec!["A", "B", "C"]);
}
#[test]
fn test_linear_dependency_chain() {
let (levels, cycle) = compute_sorted(
&["A", "B", "C"],
&[("A", &[]), ("B", &["A"]), ("C", &["B"])],
&[],
);
assert!(cycle.is_empty());
assert_eq!(levels.len(), 3);
assert_eq!(levels[0], vec!["A"]);
assert_eq!(levels[1], vec!["B"]);
assert_eq!(levels[2], vec!["C"]);
}
#[test]
fn test_diamond_dependency() {
let (levels, cycle) = compute_sorted(
&["A", "B", "C", "D"],
&[("A", &[]), ("B", &["A"]), ("C", &["A"]), ("D", &["B", "C"])],
&[],
);
assert!(cycle.is_empty());
assert_eq!(levels.len(), 3);
assert_eq!(levels[0], vec!["A"]);
assert_eq!(levels[1], vec!["B", "C"]);
assert_eq!(levels[2], vec!["D"]);
}
#[test]
fn test_cycle_detection() {
let (levels, cycle) = compute_sorted(&["A", "B"], &[("A", &["B"]), ("B", &["A"])], &[]);
assert!(levels.is_empty());
assert_eq!(cycle, vec!["A", "B"]);
}
#[test]
fn test_partial_cycle() {
let (levels, cycle) = compute_sorted(
&["A", "B", "C"],
&[("A", &[]), ("B", &["C"]), ("C", &["B"])],
&[],
);
assert_eq!(levels.len(), 1);
assert_eq!(levels[0], vec!["A"]);
assert_eq!(cycle, vec!["B", "C"]);
}
#[test]
fn test_no_provider_secrets_at_level_zero() {
let (levels, cycle) = compute_sorted(
&["NO_PROV", "OP_SECRET"],
&[("OP_SECRET", &["NO_PROV"])],
&["NO_PROV"],
);
assert!(cycle.is_empty());
assert_eq!(levels.len(), 2);
assert_eq!(levels[0], vec!["NO_PROV"]);
assert_eq!(levels[1], vec!["OP_SECRET"]);
}
#[test]
fn test_dep_on_nonexistent_key_ignored() {
let (levels, cycle) = compute_sorted(&["A", "B"], &[("A", &[]), ("B", &["MISSING"])], &[]);
assert!(cycle.is_empty());
assert_eq!(levels.len(), 1);
assert_eq!(levels[0], vec!["A", "B"]);
}
#[test]
fn test_self_dependency_ignored() {
let (levels, cycle) = compute_sorted(&["A"], &[("A", &["A"])], &[]);
assert!(cycle.is_empty());
assert_eq!(levels.len(), 1);
assert_eq!(levels[0], vec!["A"]);
}
#[test]
fn test_real_world_scenario() {
let (levels, cycle) = compute_sorted(
&[
"OP_SERVICE_ACCOUNT_TOKEN",
"TUNNEL_TOKEN",
"DB_PASSWORD",
"PLAIN_VAR",
],
&[
("OP_SERVICE_ACCOUNT_TOKEN", &[]), (
"TUNNEL_TOKEN",
&["OP_SERVICE_ACCOUNT_TOKEN", "FNOX_OP_SERVICE_ACCOUNT_TOKEN"],
), (
"DB_PASSWORD",
&["OP_SERVICE_ACCOUNT_TOKEN", "FNOX_OP_SERVICE_ACCOUNT_TOKEN"],
), ],
&["PLAIN_VAR"],
);
assert!(cycle.is_empty());
assert_eq!(levels.len(), 2);
assert_eq!(levels[0], vec!["OP_SERVICE_ACCOUNT_TOKEN", "PLAIN_VAR"]);
assert_eq!(levels[1], vec!["DB_PASSWORD", "TUNNEL_TOKEN"]);
}
#[test]
fn test_split_key_path_simple() {
assert_eq!(split_key_path("foo"), vec!["foo"]);
assert_eq!(split_key_path("foo.bar"), vec!["foo", "bar"]);
assert_eq!(split_key_path("a.b.c"), vec!["a", "b", "c"]);
}
#[test]
fn test_split_key_path_escaped_dot() {
assert_eq!(split_key_path(r"foo\.bar"), vec!["foo.bar"]);
assert_eq!(split_key_path(r"a.b\.c.d"), vec!["a", "b.c", "d"]);
assert_eq!(split_key_path(r"foo\.bar\.baz"), vec!["foo.bar.baz"]);
}
#[test]
fn test_split_key_path_escaped_backslash() {
assert_eq!(split_key_path(r"foo\\.bar"), vec!["foo\\", "bar"]);
assert_eq!(split_key_path(r"foo\\\.bar"), vec!["foo\\.bar"]);
}
#[test]
fn test_split_key_path_edge_cases() {
assert_eq!(split_key_path(""), vec![""]);
assert_eq!(split_key_path("."), vec!["", ""]);
assert_eq!(split_key_path("foo."), vec!["foo", ""]);
assert_eq!(split_key_path(".foo"), vec!["", "foo"]);
assert_eq!(split_key_path(r"foo\"), vec!["foo\\"]);
}
}