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 use std::sync::OnceLock;
9
10 use serde::{Deserialize, Serialize};
11
12 /// The persisted session, written on login and cleared on logout.
13 #[derive(Debug, Clone, Serialize, Deserialize)]
14 pub struct Credentials {
15 /// Bearer token issued by sigit.si.
16 pub access_token: String,
17 /// Account email, kept for `whoami` display.
18 #[serde(default)]
19 pub email: Option<String>,
20 }
21
22 /// Where an embedding host keeps the session, set once at startup by
23 /// [`use_config_dir`]. Unset for the command-line agent.
24 static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
25
26 /// Keep the session in `dir` instead of `$SIGIT_CONFIG_DIR` or `~/.config/sigit`.
27 ///
28 /// An app that embeds the agent calls this with its own data directory, which is inside the
29 /// container in a sandboxed app, so the app and the CLI never share or overwrite a session.
30 /// The first call wins; later calls are ignored.
31 pub fn use_config_dir(dir: PathBuf) {
32 let _ = CONFIG_DIR.set(dir);
33 }
34
35 /// Config directory: the host's, else `$SIGIT_CONFIG_DIR`, else `~/.config/sigit`.
36 fn config_dir() -> Option<PathBuf> {
37 if let Some(dir) = CONFIG_DIR.get() {
38 return Some(dir.clone());
39 }
40 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") {
41 return Some(PathBuf::from(dir));
42 }
43 let home = std::env::var("HOME").ok()?;
44 Some(PathBuf::from(home).join(".config/sigit"))
45 }
46
47 fn credentials_path() -> Option<PathBuf> {
48 config_dir().map(|dir| dir.join("credentials.toml"))
49 }
50
51 /// Load stored credentials, or `None` if not logged in.
52 pub fn load() -> Option<Credentials> {
53 let path = credentials_path()?;
54 let contents = std::fs::read_to_string(&path).ok()?;
55 match toml::from_str::<Credentials>(&contents) {
56 Ok(credentials) if !credentials.access_token.trim().is_empty() => Some(credentials),
57 _ => None,
58 }
59 }
60
61 /// Convenience: the bearer token alone, if logged in.
62 pub fn load_token() -> Option<String> {
63 load().map(|credentials| credentials.access_token)
64 }
65
66 /// Persist credentials, creating the config dir and restricting permissions.
67 pub fn store(credentials: &Credentials) -> Result<(), String> {
68 let dir = config_dir().ok_or_else(|| "cannot resolve config directory".to_string())?;
69 std::fs::create_dir_all(&dir).map_err(|error| format!("create {dir:?}: {error}"))?;
70 let path = dir.join("credentials.toml");
71 let body =
72 toml::to_string(credentials).map_err(|error| format!("serialize credentials: {error}"))?;
73 std::fs::write(&path, body).map_err(|error| format!("write {path:?}: {error}"))?;
74 restrict_permissions(&path);
75 Ok(())
76 }
77
78 /// Remove stored credentials. Returns `true` if a file was deleted.
79 pub fn clear() -> bool {
80 match credentials_path() {
81 Some(path) if path.exists() => std::fs::remove_file(&path).is_ok(),
82 _ => false,
83 }
84 }
85
86 #[cfg(unix)]
87 fn restrict_permissions(path: &std::path::Path) {
88 use std::os::unix::fs::PermissionsExt;
89 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
90 }
91
92 #[cfg(not(unix))]
93 fn restrict_permissions(_path: &std::path::Path) {}
94
95 #[cfg(test)]
96 mod tests {
97 use super::*;
98
99 #[test]
100 fn round_trips_credentials_via_temp_dir() {
101 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
102 let _guard = LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
103 let dir = std::env::temp_dir().join(format!("sigit_creds_test_{}", std::process::id()));
104 let _ = std::fs::remove_dir_all(&dir);
105 // SAFETY: single-threaded test; restores below.
106 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
107
108 assert!(load().is_none());
109 store(&Credentials {
110 access_token: "tok_123".to_string(),
111 email: Some("dev@sigit.si".to_string()),
112 })
113 .unwrap();
114
115 let loaded = load().expect("credentials present");
116 assert_eq!(loaded.access_token, "tok_123");
117 assert_eq!(loaded.email.as_deref(), Some("dev@sigit.si"));
118 assert_eq!(load_token().as_deref(), Some("tok_123"));
119
120 assert!(clear());
121 assert!(load().is_none());
122
123 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
124 let _ = std::fs::remove_dir_all(&dir);
125 }
126 }