1 //! Local credential store.
2 //!
3 //! Holds the session token from `sigit login`, used to authenticate siGit Code
4 //! Cloud requests. Stored as TOML at `$SIGIT_CONFIG_DIR/credentials.toml` or
5 //! `~/.config/sigit/credentials.toml`, with `0600` permissions on Unix.
6
7 use std::path::PathBuf;
8
9 use serde::{Deserialize, Serialize};
10
11 /// The persisted session, written on login and cleared on logout.
12 #[derive(Debug, Clone, Serialize, Deserialize)]
13 pub struct Credentials {
14 /// Bearer token issued by sigit.si.
15 pub access_token: String,
16 /// Account email, kept for `whoami` display.
17 #[serde(default)]
18 pub email: Option<String>,
19 }
20
21 /// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`.
22 fn config_dir() -> Option<PathBuf> {
23 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") {
24 return Some(PathBuf::from(dir));
25 }
26 let home = std::env::var("HOME").ok()?;
27 Some(PathBuf::from(home).join(".config/sigit"))
28 }
29
30 fn credentials_path() -> Option<PathBuf> {
31 config_dir().map(|dir| dir.join("credentials.toml"))
32 }
33
34 /// Load stored credentials, or `None` if not logged in.
35 pub fn load() -> Option<Credentials> {
36 let path = credentials_path()?;
37 let contents = std::fs::read_to_string(&path).ok()?;
38 match toml::from_str::<Credentials>(&contents) {
39 Ok(credentials) if !credentials.access_token.trim().is_empty() => Some(credentials),
40 _ => None,
41 }
42 }
43
44 /// Convenience: the bearer token alone, if logged in.
45 pub fn load_token() -> Option<String> {
46 load().map(|credentials| credentials.access_token)
47 }
48
49 /// Persist credentials, creating the config dir and restricting permissions.
50 pub fn store(credentials: &Credentials) -> Result<(), String> {
51 let dir = config_dir().ok_or_else(|| "cannot resolve config directory".to_string())?;
52 std::fs::create_dir_all(&dir).map_err(|error| format!("create {dir:?}: {error}"))?;
53 let path = dir.join("credentials.toml");
54 let body =
55 toml::to_string(credentials).map_err(|error| format!("serialize credentials: {error}"))?;
56 std::fs::write(&path, body).map_err(|error| format!("write {path:?}: {error}"))?;
57 restrict_permissions(&path);
58 Ok(())
59 }
60
61 /// Remove stored credentials. Returns `true` if a file was deleted.
62 pub fn clear() -> bool {
63 match credentials_path() {
64 Some(path) if path.exists() => std::fs::remove_file(&path).is_ok(),
65 _ => false,
66 }
67 }
68
69 #[cfg(unix)]
70 fn restrict_permissions(path: &std::path::Path) {
71 use std::os::unix::fs::PermissionsExt;
72 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
73 }
74
75 #[cfg(not(unix))]
76 fn restrict_permissions(_path: &std::path::Path) {}
77
78 #[cfg(test)]
79 mod tests {
80 use super::*;
81
82 #[test]
83 fn round_trips_credentials_via_temp_dir() {
84 let _guard = crate::ENV_TEST_LOCK
85 .lock()
86 .unwrap_or_else(|poisoned| poisoned.into_inner());
87 let dir = std::env::temp_dir().join(format!("sigit_creds_test_{}", std::process::id()));
88 let _ = std::fs::remove_dir_all(&dir);
89 // SAFETY: single-threaded test; restores below.
90 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
91
92 assert!(load().is_none());
93 store(&Credentials {
94 access_token: "tok_123".to_string(),
95 email: Some("dev@sigit.si".to_string()),
96 })
97 .unwrap();
98
99 let loaded = load().expect("credentials present");
100 assert_eq!(loaded.access_token, "tok_123");
101 assert_eq!(loaded.email.as_deref(), Some("dev@sigit.si"));
102 assert_eq!(load_token().as_deref(), Some("tok_123"));
103
104 assert!(clear());
105 assert!(load().is_none());
106
107 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
108 let _ = std::fs::remove_dir_all(&dir);
109 }
110 }