//! Secret resolution with keychain integration
//!
//! Resolves credential patterns like `{keychain.SERVICE}` and `{env.VAR}`
//! from secure system keychains and environment variables.
use std::process::Command;
use dashmap::DashMap;
use crate::{Error, Result};
/// Secret resolver with caching
pub struct SecretResolver {
/// Cached resolved secrets for the session
cache: DashMap<String, String>,
}
impl SecretResolver {
/// Create a new secret resolver
#[must_use]
pub fn new() -> Self {
Self {
cache: DashMap::new(),
}
}
/// Resolve a value containing secret patterns
///
/// Supports:
/// - `{keychain.SERVICE}` - macOS Keychain or Linux secret-tool
/// - `{env.VAR}` - Environment variable
///
/// # Example
///
/// ```no_run
/// # use mcp_gateway::secrets::SecretResolver;
/// let resolver = SecretResolver::new();
/// let resolved = resolver.resolve("Bearer {keychain.my-api-token}").unwrap();
/// ```
///
/// # Errors
///
/// Returns an error if a keychain entry is not found or cannot be accessed.
///
/// # Panics
///
/// Panics if the hardcoded regex patterns are invalid (compile-time constant).
pub fn resolve(&self, value: &str) -> Result<String> {
let mut result = value.to_string();
// Find all {keychain.X} patterns
#[allow(clippy::unwrap_used)]
let keychain_pattern = regex::Regex::new(r"\{keychain\.([^}]+)\}").unwrap();
for caps in keychain_pattern.captures_iter(value) {
let service = &caps[1];
let placeholder = &caps[0];
// Check cache first
let secret = if let Some(cached) = self.cache.get(service) {
cached.clone()
} else {
// Fetch from keychain
let secret = Self::fetch_from_keychain(service)?;
// Cache it
self.cache.insert(service.to_string(), secret.clone());
secret
};
result = result.replace(placeholder, &secret);
}
// Find all {env.X} patterns
#[allow(clippy::unwrap_used)]
let env_pattern = regex::Regex::new(r"\{env\.([^}]+)\}").unwrap();
for caps in env_pattern.captures_iter(&result.clone()) {
let var_name = &caps[1];
let placeholder = &caps[0];
let value = std::env::var(var_name).unwrap_or_default();
result = result.replace(placeholder, &value);
}
Ok(result)
}
/// Fetch a secret from the system keychain
///
/// # Platform Support
///
/// - **macOS**: Uses `security find-generic-password`
/// - **Linux**: Uses `secret-tool lookup`
/// - **Other**: Returns error
#[cfg(target_os = "macos")]
fn fetch_from_keychain(service: &str) -> Result<String> {
let output = Command::new("security")
.args(["find-generic-password", "-s", service, "-w"])
.output()
.map_err(|e| {
Error::Config(format!("Failed to access macOS Keychain: {e}"))
})?;
if output.status.success() {
let secret = String::from_utf8_lossy(&output.stdout).trim().to_string();
if secret.is_empty() {
Err(Error::Config(format!(
"Keychain entry '{service}' is empty. Check with: security find-generic-password -s '{service}'"
)))
} else {
Ok(secret)
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!(service = service, error = %stderr, "Keychain lookup failed");
Err(Error::Config(format!(
"Keychain entry '{service}' not found. Add it with:\n \
security add-generic-password -s '{service}' -a 'mcp-gateway' -w 'YOUR_SECRET'"
)))
}
}
/// Fetch a secret from the system keychain (Linux)
#[cfg(target_os = "linux")]
fn fetch_from_keychain(service: &str) -> Result<String> {
let output = Command::new("secret-tool")
.args(["lookup", "service", service])
.output()
.map_err(|e| {
Error::Config(format!(
"Failed to access Linux secret service: {e}. \
Is libsecret installed?"
))
})?;
if output.status.success() {
let secret = String::from_utf8_lossy(&output.stdout).trim().to_string();
if secret.is_empty() {
Err(Error::Config(format!(
"Secret service entry for '{service}' is empty"
)))
} else {
Ok(secret)
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!(service = service, error = %stderr, "Secret service lookup failed");
Err(Error::Config(format!(
"Secret service entry for '{service}' not found. Add it with:\n \
secret-tool store --label='MCP Gateway: {service}' service {service}"
)))
}
}
/// Fetch from keychain (unsupported platforms)
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn fetch_from_keychain(_service: &str) -> Result<String> {
Err(Error::Config(
"Keychain access is only supported on macOS and Linux. \
Use {env.VAR} syntax instead."
.to_string(),
))
}
/// Clear the session cache
pub fn clear_cache(&self) {
self.cache.clear();
}
}
impl Default for SecretResolver {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_env_var() {
// Use PATH which is always set on all platforms
let resolver = SecretResolver::new();
let result = resolver.resolve("Path: {env.PATH}").unwrap();
assert!(result.starts_with("Path: /") || result.starts_with("Path: C"));
assert_ne!(result, "Path: ");
}
#[test]
fn test_resolve_multiple_patterns() {
// Use HOME and PATH which are always available
let resolver = SecretResolver::new();
let result = resolver
.resolve("Home: {env.HOME}, Path: {env.PATH}")
.unwrap();
assert!(!result.contains("{env."));
assert!(result.contains("Home: /") || result.contains("Home: C"));
}
#[test]
fn test_resolve_no_patterns() {
let resolver = SecretResolver::new();
let result = resolver.resolve("No patterns here").unwrap();
assert_eq!(result, "No patterns here");
}
#[test]
fn test_resolve_missing_env_var() {
let resolver = SecretResolver::new();
// Missing env var should resolve to empty string
let result = resolver.resolve("Value: {env.NONEXISTENT_VAR}").unwrap();
assert_eq!(result, "Value: ");
}
#[cfg(target_os = "macos")]
#[test]
fn test_keychain_pattern_detection() {
let resolver = SecretResolver::new();
let value = "Bearer {keychain.test-service}";
// This will fail if the keychain entry doesn't exist, which is expected
let result = resolver.resolve(value);
// We're just testing the pattern is recognized
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn test_default_impl() {
let resolver = SecretResolver::default();
let result = resolver.resolve("test").unwrap();
assert_eq!(result, "test");
}
#[test]
fn test_clear_cache() {
let resolver = SecretResolver::new();
// Set something in cache via env var (which gets cached)
let _ = resolver.resolve("{env.PATH}").unwrap();
resolver.clear_cache();
// Cache should be empty but resolve should still work
let result = resolver.resolve("{env.PATH}").unwrap();
assert!(!result.is_empty());
}
#[test]
fn test_mixed_patterns() {
let resolver = SecretResolver::new();
let result = resolver
.resolve("Path: {env.PATH}, Missing: {env.NONEXISTENT_VAR_12345}")
.unwrap();
assert!(result.contains("Path: /") || result.contains("Path: C"));
assert!(result.contains("Missing: "));
}
#[test]
fn test_env_pattern_in_json() {
let resolver = SecretResolver::new();
let json_value = r#"{"path": "{env.PATH}"}"#;
let result = resolver.resolve(json_value).unwrap();
assert!(!result.contains("{env.PATH}"));
assert!(result.contains("\"path\": \""));
}
#[test]
fn test_multiple_same_pattern() {
let resolver = SecretResolver::new();
let result = resolver
.resolve("{env.PATH} and {env.PATH} again")
.unwrap();
// Should replace both occurrences
assert!(!result.contains("{env.PATH}"));
}
}