1use anyhow::{Context, Result};
8use keyring::Entry;
9use serde::{Deserialize, Serialize};
10use std::fs;
11use std::path::PathBuf;
12
13use super::{CloudError, KEYRING_API_KEY_USER, KEYRING_ENCRYPTION_KEY_USER, KEYRING_SERVICE};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Credentials {
18 pub api_key: String,
20
21 pub email: String,
23
24 pub plan: String,
26
27 #[serde(default = "default_cloud_url")]
29 pub cloud_url: String,
30}
31
32fn default_cloud_url() -> String {
33 super::DEFAULT_CLOUD_URL.to_string()
34}
35
36pub struct CredentialsStore {
42 use_keyring: bool,
44 base_dir: Option<PathBuf>,
46}
47
48impl CredentialsStore {
49 pub fn new() -> Self {
53 Self {
54 use_keyring: false,
55 base_dir: None,
56 }
57 }
58
59 pub fn with_keychain(use_keychain: bool) -> Self {
66 let use_keyring = if use_keychain {
67 Self::is_keyring_available()
68 } else {
69 false
70 };
71 Self {
72 use_keyring,
73 base_dir: None,
74 }
75 }
76
77 #[cfg(test)]
78 pub(crate) fn with_base_dir(base_dir: PathBuf, use_keychain: bool) -> Self {
79 let use_keyring = if use_keychain {
80 Self::is_keyring_available()
81 } else {
82 false
83 };
84 Self {
85 use_keyring,
86 base_dir: Some(base_dir),
87 }
88 }
89
90 pub fn is_keyring_available() -> bool {
95 match Entry::new(KEYRING_SERVICE, "test-availability") {
97 Ok(entry) => {
98 match entry.get_password() {
100 Ok(_) => true,
101 Err(keyring::Error::NoEntry) => true,
102 Err(_) => false,
103 }
104 }
105 Err(_) => false,
106 }
107 }
108
109 #[cfg(target_os = "linux")]
118 pub fn is_secret_service_available() -> bool {
119 if std::env::var("DBUS_SESSION_BUS_ADDRESS").is_err() {
122 return false;
123 }
124
125 Self::is_keyring_available()
127 }
128
129 #[cfg(not(target_os = "linux"))]
131 pub fn is_secret_service_available() -> bool {
132 true
133 }
134
135 pub fn store(&self, credentials: &Credentials) -> Result<(), CloudError> {
139 if self.use_keyring {
140 self.store_to_keyring(credentials)
141 } else {
142 self.store_to_file(credentials)
143 }
144 }
145
146 pub fn load(&self) -> Result<Option<Credentials>, CloudError> {
151 if self.use_keyring {
152 if let Some(creds) = self.load_from_keyring()? {
154 return Ok(Some(creds));
155 }
156 self.load_from_file()
157 } else {
158 if let Some(creds) = self.load_from_file()? {
160 return Ok(Some(creds));
161 }
162 if Self::is_keyring_available() {
164 self.load_from_keyring()
165 } else {
166 Ok(None)
167 }
168 }
169 }
170
171 pub fn delete(&self) -> Result<(), CloudError> {
176 self.delete_from_file()?;
178
179 if Self::is_keyring_available() {
181 self.delete_from_keyring()?;
182 }
183
184 Ok(())
185 }
186
187 pub fn store_encryption_key(&self, key_hex: &str) -> Result<(), CloudError> {
192 if self.use_keyring {
193 let entry = Entry::new(KEYRING_SERVICE, KEYRING_ENCRYPTION_KEY_USER)
194 .map_err(|e| CloudError::KeyringError(e.to_string()))?;
195 entry
196 .set_password(key_hex)
197 .map_err(|e| CloudError::KeyringError(e.to_string()))?;
198 } else {
199 let path = self.encryption_key_path()?;
201 if let Some(parent) = path.parent() {
202 fs::create_dir_all(parent)
203 .map_err(|e| CloudError::KeyringError(format!("Failed to create dir: {e}")))?;
204 }
205 fs::write(&path, key_hex)
206 .map_err(|e| CloudError::KeyringError(format!("Failed to write key: {e}")))?;
207
208 #[cfg(unix)]
210 {
211 use std::os::unix::fs::PermissionsExt;
212 let perms = fs::Permissions::from_mode(0o600);
213 fs::set_permissions(&path, perms).map_err(|e| {
214 CloudError::KeyringError(format!("Failed to set permissions: {e}"))
215 })?;
216 }
217 }
218 Ok(())
219 }
220
221 pub fn load_encryption_key(&self) -> Result<Option<String>, CloudError> {
225 if self.use_keyring {
226 let entry = Entry::new(KEYRING_SERVICE, KEYRING_ENCRYPTION_KEY_USER)
227 .map_err(|e| CloudError::KeyringError(e.to_string()))?;
228 match entry.get_password() {
229 Ok(key) => return Ok(Some(key)),
230 Err(keyring::Error::NoEntry) => {}
231 Err(e) => return Err(CloudError::KeyringError(e.to_string())),
232 }
233 }
234
235 let path = self.encryption_key_path()?;
237 if path.exists() {
238 let key = fs::read_to_string(&path)
239 .map_err(|e| CloudError::KeyringError(format!("Failed to read key: {e}")))?;
240 return Ok(Some(key.trim().to_string()));
241 }
242
243 if !self.use_keyring && Self::is_keyring_available() {
245 let entry = Entry::new(KEYRING_SERVICE, KEYRING_ENCRYPTION_KEY_USER)
246 .map_err(|e| CloudError::KeyringError(e.to_string()))?;
247 match entry.get_password() {
248 Ok(key) => return Ok(Some(key)),
249 Err(keyring::Error::NoEntry) => {}
250 Err(e) => return Err(CloudError::KeyringError(e.to_string())),
251 }
252 }
253
254 Ok(None)
255 }
256
257 pub fn delete_encryption_key(&self) -> Result<(), CloudError> {
261 let path = self.encryption_key_path()?;
263 if path.exists() {
264 fs::remove_file(&path)
265 .map_err(|e| CloudError::KeyringError(format!("Failed to delete key file: {e}")))?;
266 }
267
268 if Self::is_keyring_available() {
270 let entry = Entry::new(KEYRING_SERVICE, KEYRING_ENCRYPTION_KEY_USER)
271 .map_err(|e| CloudError::KeyringError(e.to_string()))?;
272 match entry.delete_credential() {
273 Ok(()) => {}
274 Err(keyring::Error::NoEntry) => {}
275 Err(e) => return Err(CloudError::KeyringError(e.to_string())),
276 }
277 }
278
279 Ok(())
280 }
281
282 fn store_to_keyring(&self, credentials: &Credentials) -> Result<(), CloudError> {
285 let entry = Entry::new(KEYRING_SERVICE, KEYRING_API_KEY_USER)
286 .map_err(|e| CloudError::KeyringError(e.to_string()))?;
287
288 let json = serde_json::to_string(credentials)
290 .map_err(|e| CloudError::KeyringError(format!("Serialization error: {e}")))?;
291
292 entry
293 .set_password(&json)
294 .map_err(|e| CloudError::KeyringError(e.to_string()))?;
295
296 Ok(())
297 }
298
299 fn load_from_keyring(&self) -> Result<Option<Credentials>, CloudError> {
300 let entry = Entry::new(KEYRING_SERVICE, KEYRING_API_KEY_USER)
301 .map_err(|e| CloudError::KeyringError(e.to_string()))?;
302
303 match entry.get_password() {
304 Ok(json) => {
305 let credentials: Credentials = serde_json::from_str(&json)
306 .map_err(|e| CloudError::KeyringError(format!("Deserialization error: {e}")))?;
307 Ok(Some(credentials))
308 }
309 Err(keyring::Error::NoEntry) => Ok(None),
310 Err(e) => Err(CloudError::KeyringError(e.to_string())),
311 }
312 }
313
314 fn delete_from_keyring(&self) -> Result<(), CloudError> {
315 let entry = Entry::new(KEYRING_SERVICE, KEYRING_API_KEY_USER)
316 .map_err(|e| CloudError::KeyringError(e.to_string()))?;
317
318 match entry.delete_credential() {
319 Ok(()) => Ok(()),
320 Err(keyring::Error::NoEntry) => Ok(()), Err(e) => Err(CloudError::KeyringError(e.to_string())),
322 }
323 }
324
325 fn credentials_path(&self) -> Result<PathBuf, CloudError> {
328 let config_dir = match &self.base_dir {
329 Some(base_dir) => base_dir.clone(),
330 None => dirs::home_dir()
331 .ok_or_else(|| {
332 CloudError::KeyringError("Could not find home directory".to_string())
333 })?
334 .join(".lore"),
335 };
336
337 Ok(config_dir.join("credentials.json"))
338 }
339
340 fn encryption_key_path(&self) -> Result<PathBuf, CloudError> {
341 let config_dir = match &self.base_dir {
342 Some(base_dir) => base_dir.clone(),
343 None => dirs::home_dir()
344 .ok_or_else(|| {
345 CloudError::KeyringError("Could not find home directory".to_string())
346 })?
347 .join(".lore"),
348 };
349
350 Ok(config_dir.join("encryption.key"))
351 }
352
353 fn store_to_file(&self, credentials: &Credentials) -> Result<(), CloudError> {
354 let path = self.credentials_path()?;
355
356 if let Some(parent) = path.parent() {
357 fs::create_dir_all(parent).map_err(|e| {
358 CloudError::KeyringError(format!("Failed to create config directory: {e}"))
359 })?;
360 }
361
362 let json = serde_json::to_string_pretty(credentials)
363 .map_err(|e| CloudError::KeyringError(format!("Serialization error: {e}")))?;
364
365 fs::write(&path, json).map_err(|e| {
366 CloudError::KeyringError(format!("Failed to write credentials file: {e}"))
367 })?;
368
369 #[cfg(unix)]
371 {
372 use std::os::unix::fs::PermissionsExt;
373 let perms = fs::Permissions::from_mode(0o600);
374 fs::set_permissions(&path, perms).map_err(|e| {
375 CloudError::KeyringError(format!("Failed to set file permissions: {e}"))
376 })?;
377 }
378
379 Ok(())
380 }
381
382 fn load_from_file(&self) -> Result<Option<Credentials>, CloudError> {
383 let path = self.credentials_path()?;
384
385 if !path.exists() {
386 return Ok(None);
387 }
388
389 let json = fs::read_to_string(&path).map_err(|e| {
390 CloudError::KeyringError(format!("Failed to read credentials file: {e}"))
391 })?;
392
393 let credentials: Credentials = serde_json::from_str(&json)
394 .map_err(|e| CloudError::KeyringError(format!("Invalid credentials file: {e}")))?;
395
396 Ok(Some(credentials))
397 }
398
399 fn delete_from_file(&self) -> Result<(), CloudError> {
400 let path = self.credentials_path()?;
401
402 if path.exists() {
403 fs::remove_file(&path).map_err(|e| {
404 CloudError::KeyringError(format!("Failed to delete credentials file: {e}"))
405 })?;
406 }
407
408 Ok(())
409 }
410}
411
412impl Default for CredentialsStore {
413 fn default() -> Self {
414 Self::new()
415 }
416}
417
418#[allow(dead_code)]
423pub fn is_logged_in() -> bool {
424 let use_keychain = crate::config::Config::load()
425 .map(|c| c.use_keychain)
426 .unwrap_or(false);
427 let store = CredentialsStore::with_keychain(use_keychain);
428 matches!(store.load(), Ok(Some(_)))
429}
430
431#[allow(dead_code)]
436pub fn get_credentials() -> Option<Credentials> {
437 let use_keychain = crate::config::Config::load()
438 .map(|c| c.use_keychain)
439 .unwrap_or(false);
440 let store = CredentialsStore::with_keychain(use_keychain);
441 get_credentials_with_store(&store)
442}
443
444pub fn require_login() -> Result<Credentials> {
449 let use_keychain = crate::config::Config::load()
450 .map(|c| c.use_keychain)
451 .unwrap_or(false);
452 let store = CredentialsStore::with_keychain(use_keychain);
453 require_login_with_store(&store)
454}
455
456fn get_credentials_with_store(store: &CredentialsStore) -> Option<Credentials> {
457 store.load().ok().flatten()
458}
459
460fn require_login_with_store(store: &CredentialsStore) -> Result<Credentials> {
461 store
462 .load()
463 .context("Failed to check login status")?
464 .ok_or_else(|| anyhow::anyhow!("Not logged in. Run 'lore login' first."))
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470
471 #[test]
472 fn test_credentials_default_cloud_url() {
473 let creds = Credentials {
474 api_key: "test".to_string(),
475 email: "test@example.com".to_string(),
476 plan: "free".to_string(),
477 cloud_url: default_cloud_url(),
478 };
479 assert_eq!(creds.cloud_url, super::super::DEFAULT_CLOUD_URL);
480 }
481
482 #[test]
483 fn test_credentials_serialization() {
484 let creds = Credentials {
485 api_key: "lore_test123".to_string(),
486 email: "user@example.com".to_string(),
487 plan: "pro".to_string(),
488 cloud_url: "https://custom.example.com".to_string(),
489 };
490
491 let json = serde_json::to_string(&creds).unwrap();
492 let parsed: Credentials = serde_json::from_str(&json).unwrap();
493
494 assert_eq!(parsed.api_key, creds.api_key);
495 assert_eq!(parsed.email, creds.email);
496 assert_eq!(parsed.plan, creds.plan);
497 assert_eq!(parsed.cloud_url, creds.cloud_url);
498 }
499
500 #[test]
501 fn test_credentials_deserialization_default_url() {
502 let json = r#"{"api_key":"test","email":"test@example.com","plan":"free"}"#;
504 let creds: Credentials = serde_json::from_str(json).unwrap();
505 assert_eq!(creds.cloud_url, super::super::DEFAULT_CLOUD_URL);
506 }
507
508 #[test]
509 fn test_is_logged_in_returns_bool() {
510 let _result: bool = is_logged_in();
514 }
515
516 #[test]
517 fn test_is_keyring_available_smoke() {
518 let _result: bool = CredentialsStore::is_keyring_available();
521 }
522
523 #[test]
524 fn test_is_secret_service_available_smoke() {
525 let _result: bool = CredentialsStore::is_secret_service_available();
529 }
530
531 #[test]
532 fn test_require_login_with_store_deterministic() {
533 let temp_dir = tempfile::TempDir::new().unwrap();
534 let store = CredentialsStore::with_base_dir(temp_dir.path().to_path_buf(), false);
535
536 let creds = Credentials {
537 api_key: "test_key".to_string(),
538 email: "user@example.com".to_string(),
539 plan: "pro".to_string(),
540 cloud_url: default_cloud_url(),
541 };
542
543 store.store(&creds).unwrap();
544 let loaded = require_login_with_store(&store).unwrap();
545 assert_eq!(loaded.email, creds.email);
546 assert_eq!(loaded.api_key, creds.api_key);
547 }
548
549 #[test]
550 fn test_get_credentials_with_store_deterministic() {
551 let temp_dir = tempfile::TempDir::new().unwrap();
552 let store = CredentialsStore::with_base_dir(temp_dir.path().to_path_buf(), false);
553
554 let creds = Credentials {
555 api_key: "test_key".to_string(),
556 email: "user@example.com".to_string(),
557 plan: "free".to_string(),
558 cloud_url: default_cloud_url(),
559 };
560
561 store.store(&creds).unwrap();
562 let loaded = get_credentials_with_store(&store).unwrap();
563 assert_eq!(loaded.email, creds.email);
564 assert_eq!(loaded.api_key, creds.api_key);
565 }
566}