I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

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, because one process settles its config dir once: a later call is
31 /// ignored, and logged, so a second host cannot relocate the session out from under the first.
32 pub fn use_config_dir(dir: PathBuf) {
33 if let Err(rejected) = CONFIG_DIR.set(dir) {
34 log::warn!(
35 "session config dir is already {:?}; ignoring {rejected:?}",
36 CONFIG_DIR.get().cloned().unwrap_or_default()
37 );
38 }
39 }
40
41 /// Config directory: the host's, else `$SIGIT_CONFIG_DIR`, else `~/.config/sigit`.
42 fn config_dir() -> Option<PathBuf> {
43 if let Some(dir) = CONFIG_DIR.get() {
44 return Some(dir.clone());
45 }
46 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") {
47 return Some(PathBuf::from(dir));
48 }
49 let home = std::env::var("HOME").ok()?;
50 Some(PathBuf::from(home).join(".config/sigit"))
51 }
52
53 fn credentials_path() -> Option<PathBuf> {
54 config_dir().map(|dir| dir.join("credentials.toml"))
55 }
56
57 /// Load stored credentials, or `None` if not logged in.
58 pub fn load() -> Option<Credentials> {
59 let path = credentials_path()?;
60 let contents = std::fs::read_to_string(&path).ok()?;
61 match toml::from_str::<Credentials>(&contents) {
62 Ok(credentials) if !credentials.access_token.trim().is_empty() => Some(credentials),
63 _ => None,
64 }
65 }
66
67 /// Convenience: the bearer token alone, if logged in.
68 pub fn load_token() -> Option<String> {
69 load().map(|credentials| credentials.access_token)
70 }
71
72 /// Persist credentials, creating the config dir and restricting permissions.
73 pub fn store(credentials: &Credentials) -> Result<(), String> {
74 let dir = config_dir().ok_or_else(|| "cannot resolve config directory".to_string())?;
75 std::fs::create_dir_all(&dir).map_err(|error| format!("create {dir:?}: {error}"))?;
76 let path = dir.join("credentials.toml");
77 let body =
78 toml::to_string(credentials).map_err(|error| format!("serialize credentials: {error}"))?;
79 std::fs::write(&path, body).map_err(|error| format!("write {path:?}: {error}"))?;
80 restrict_permissions(&path);
81 Ok(())
82 }
83
84 /// Remove stored credentials. Returns `true` if a file was deleted.
85 pub fn clear() -> bool {
86 match credentials_path() {
87 Some(path) if path.exists() => std::fs::remove_file(&path).is_ok(),
88 _ => false,
89 }
90 }
91
92 #[cfg(unix)]
93 fn restrict_permissions(path: &std::path::Path) {
94 use std::os::unix::fs::PermissionsExt;
95 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
96 }
97
98 #[cfg(not(unix))]
99 fn restrict_permissions(_path: &std::path::Path) {}
100
101 #[cfg(test)]
102 mod tests {
103 use super::*;
104
105 #[test]
106 fn round_trips_credentials_via_temp_dir() {
107 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
108 let _guard = LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
109 let dir = std::env::temp_dir().join(format!("sigit_creds_test_{}", std::process::id()));
110 let _ = std::fs::remove_dir_all(&dir);
111 // SAFETY: single-threaded test; restores below.
112 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
113
114 assert!(load().is_none());
115 store(&Credentials {
116 access_token: "tok_123".to_string(),
117 email: Some("dev@sigit.si".to_string()),
118 })
119 .unwrap();
120
121 let loaded = load().expect("credentials present");
122 assert_eq!(loaded.access_token, "tok_123");
123 assert_eq!(loaded.email.as_deref(), Some("dev@sigit.si"));
124 assert_eq!(load_token().as_deref(), Some("tok_123"));
125
126 assert!(clear());
127 assert!(load().is_none());
128
129 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
130 let _ = std::fs::remove_dir_all(&dir);
131 }
132 }