life_paths/
credentials.rs1use std::fmt;
2use std::path::PathBuf;
3
4use crate::{discovery, env as env_loader, keychain};
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum CredentialSource {
9 ProjectEnv,
11 Keychain,
13 GlobalEnv,
15 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#[derive(Debug, Clone)]
32pub struct ResolvedCredential {
33 pub value: String,
34 pub source: CredentialSource,
35}
36
37pub fn resolve_credential(
43 env_var_name: &str,
44 keychain_account: &str,
45) -> Option<ResolvedCredential> {
46 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 if let Some(val) = keychain::read(keychain_account) {
62 return Some(ResolvedCredential {
63 value: val,
64 source: CredentialSource::Keychain,
65 });
66 }
67
68 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 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
93pub fn store_credential(
96 env_var_name: &str,
97 keychain_account: &str,
98 value: &str,
99) -> CredentialSource {
100 if keychain::store(keychain_account, value) {
102 tracing::info!("stored credential {env_var_name} in keychain");
103 return CredentialSource::Keychain;
104 }
105
106 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 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 #[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
158pub 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 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 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 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 let read_back = std::fs::read_to_string(&env_file).unwrap();
235 assert!(read_back.contains("MY_KEY=my_value"));
236 }
237}