Skip to main content

life_paths/
credentials.rs

1use std::fmt;
2use std::path::PathBuf;
3
4use crate::{discovery, env as env_loader, keychain};
5
6/// Where a credential was resolved from.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum CredentialSource {
9    /// Project-local `.life/credentials/.env`
10    ProjectEnv,
11    /// System keychain (macOS Keychain / Linux secret-tool)
12    Keychain,
13    /// Global `~/.life/credentials/.env`
14    GlobalEnv,
15    /// Process environment variable (already set)
16    EnvironmentVariable,
17}
18
19impl fmt::Display for CredentialSource {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        match self {
22            Self::ProjectEnv => write!(f, "project .env"),
23            Self::Keychain => write!(f, "keychain"),
24            Self::GlobalEnv => write!(f, "global .env"),
25            Self::EnvironmentVariable => write!(f, "environment variable"),
26        }
27    }
28}
29
30/// A credential value together with its source.
31#[derive(Debug, Clone)]
32pub struct ResolvedCredential {
33    pub value: String,
34    pub source: CredentialSource,
35}
36
37/// Resolve a credential using the following cascade:
38/// 1. Project-local `.life/credentials/.env`
39/// 2. System keychain
40/// 3. Global `~/.life/credentials/.env`
41/// 4. Process environment variable
42pub fn resolve_credential(
43    env_var_name: &str,
44    keychain_account: &str,
45) -> Option<ResolvedCredential> {
46    // 1. Project-local .env
47    if let Some(root) = discovery::find_project_root() {
48        let project_env = root.join(".life").join("credentials").join(".env");
49        if project_env.exists()
50            && let Ok(vars) = env_loader::parse_env_file(&project_env)
51            && let Some(val) = vars.get(env_var_name)
52        {
53            return Some(ResolvedCredential {
54                value: val.clone(),
55                source: CredentialSource::ProjectEnv,
56            });
57        }
58    }
59
60    // 2. Keychain
61    if let Some(val) = keychain::read(keychain_account) {
62        return Some(ResolvedCredential {
63            value: val,
64            source: CredentialSource::Keychain,
65        });
66    }
67
68    // 3. Global .env
69    let global_env = discovery::global_life_dir()
70        .join("credentials")
71        .join(".env");
72    if global_env.exists()
73        && let Ok(vars) = env_loader::parse_env_file(&global_env)
74        && let Some(val) = vars.get(env_var_name)
75    {
76        return Some(ResolvedCredential {
77            value: val.clone(),
78            source: CredentialSource::GlobalEnv,
79        });
80    }
81
82    // 4. Environment variable
83    if let Ok(val) = std::env::var(env_var_name) {
84        return Some(ResolvedCredential {
85            value: val,
86            source: CredentialSource::EnvironmentVariable,
87        });
88    }
89
90    None
91}
92
93/// Store a credential: try keychain first, fall back to `~/.life/credentials/.env`.
94/// Returns the source where the credential was stored.
95pub fn store_credential(
96    env_var_name: &str,
97    keychain_account: &str,
98    value: &str,
99) -> CredentialSource {
100    // Try keychain first
101    if keychain::store(keychain_account, value) {
102        tracing::info!("stored credential {env_var_name} in keychain");
103        return CredentialSource::Keychain;
104    }
105
106    // Fallback: write to ~/.life/credentials/.env
107    let cred_dir = discovery::global_life_dir().join("credentials");
108    std::fs::create_dir_all(&cred_dir).ok();
109
110    let env_file = cred_dir.join(".env");
111    let mut content = std::fs::read_to_string(&env_file).unwrap_or_default();
112
113    // Replace existing line or append
114    let prefix = format!("{env_var_name}=");
115    let new_line = format!("{env_var_name}={value}");
116    let mut found = false;
117    let lines: Vec<String> = content
118        .lines()
119        .map(|line| {
120            if line.starts_with(&prefix) {
121                found = true;
122                new_line.clone()
123            } else {
124                line.to_string()
125            }
126        })
127        .collect();
128
129    if found {
130        content = lines.join("\n");
131    } else {
132        if !content.is_empty() && !content.ends_with('\n') {
133            content.push('\n');
134        }
135        content.push_str(&new_line);
136        content.push('\n');
137    }
138
139    std::fs::write(&env_file, &content).ok();
140
141    // Set file permissions to 0600 on Unix
142    #[cfg(unix)]
143    {
144        set_restricted_permissions(&env_file);
145    }
146
147    tracing::info!("stored credential {env_var_name} in {}", env_file.display());
148    CredentialSource::GlobalEnv
149}
150
151#[cfg(unix)]
152fn set_restricted_permissions(path: &PathBuf) {
153    use std::os::unix::fs::PermissionsExt;
154    let perms = std::fs::Permissions::from_mode(0o600);
155    std::fs::set_permissions(path, perms).ok();
156}
157
158/// Map a provider name to its (env_var_name, keychain_account) pair.
159pub fn provider_credential_names(provider: &str) -> (&'static str, &'static str) {
160    match provider {
161        "anthropic" => ("ANTHROPIC_API_KEY", "anthropic-api-key"),
162        "openai" => ("OPENAI_API_KEY", "openai-api-key"),
163        "google" | "gemini" => ("GOOGLE_API_KEY", "google-api-key"),
164        "mistral" => ("MISTRAL_API_KEY", "mistral-api-key"),
165        "cohere" => ("COHERE_API_KEY", "cohere-api-key"),
166        "groq" => ("GROQ_API_KEY", "groq-api-key"),
167        "deepseek" => ("DEEPSEEK_API_KEY", "deepseek-api-key"),
168        _ => ("LIFE_API_KEY", "life-api-key"),
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn resolve_from_env_var() {
178        let unique_key = "LIFE_PATHS_TEST_CRED_RESOLVE";
179        // SAFETY: test-only, single-threaded context
180        unsafe {
181            std::env::set_var(unique_key, "secret-from-env");
182        }
183        let result = resolve_credential(unique_key, "nonexistent-keychain-account");
184        assert!(result.is_some());
185        let cred = result.unwrap();
186        assert_eq!(cred.value, "secret-from-env");
187        assert_eq!(cred.source, CredentialSource::EnvironmentVariable);
188        unsafe {
189            std::env::remove_var(unique_key);
190        }
191    }
192
193    #[test]
194    fn missing_credential() {
195        let result = resolve_credential(
196            "LIFE_PATHS_ABSOLUTELY_NONEXISTENT_VAR_XYZ",
197            "nonexistent-keychain-account-xyz",
198        );
199        assert!(result.is_none());
200    }
201
202    #[test]
203    fn provider_names() {
204        let (env_var, kc) = provider_credential_names("anthropic");
205        assert_eq!(env_var, "ANTHROPIC_API_KEY");
206        assert_eq!(kc, "anthropic-api-key");
207
208        let (env_var, kc) = provider_credential_names("openai");
209        assert_eq!(env_var, "OPENAI_API_KEY");
210        assert_eq!(kc, "openai-api-key");
211
212        // Unknown provider falls back to generic
213        let (env_var, kc) = provider_credential_names("unknown");
214        assert_eq!(env_var, "LIFE_API_KEY");
215        assert_eq!(kc, "life-api-key");
216    }
217
218    #[test]
219    fn store_creates_env_file() {
220        let tmp = tempfile::TempDir::new().unwrap();
221        let fake_home = tmp.path().join("fakehome");
222        std::fs::create_dir_all(&fake_home).unwrap();
223
224        // We can't easily redirect global_life_dir in a test, so test the file-write
225        // logic directly.
226        let cred_dir = fake_home.join(".life").join("credentials");
227        std::fs::create_dir_all(&cred_dir).unwrap();
228        let env_file = cred_dir.join(".env");
229
230        let content = "MY_KEY=my_value\n";
231        std::fs::write(&env_file, content).unwrap();
232
233        // Verify the file was created with correct content
234        let read_back = std::fs::read_to_string(&env_file).unwrap();
235        assert!(read_back.contains("MY_KEY=my_value"));
236    }
237}