unifly_api/session/
session_cache.rs1use std::fs;
14use std::io::Write;
15use std::path::{Path, PathBuf};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use base64::Engine;
19use base64::engine::general_purpose::URL_SAFE_NO_PAD;
20use serde::{Deserialize, Serialize};
21use tracing::{debug, warn};
22
23#[derive(Debug, Serialize, Deserialize)]
25struct CachedSession {
26 cookie: String,
28 csrf_token: Option<String>,
30 expires_at: u64,
32}
33
34pub struct SessionCache {
36 path: PathBuf,
37}
38
39impl SessionCache {
40 pub fn new(profile_name: &str, controller_url: &str) -> Option<Self> {
44 let cache_dir = cache_dir()?;
45 let url_hash = simple_hash(controller_url);
47 let filename = format!("{profile_name}_{url_hash}.json");
48 Some(Self {
49 path: cache_dir.join(filename),
50 })
51 }
52
53 pub fn load(&self) -> Option<(String, Option<String>)> {
57 let data = fs::read_to_string(&self.path).ok()?;
58 let session: CachedSession = serde_json::from_str(&data).ok()?;
59
60 let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
61
62 if now >= session.expires_at {
63 debug!("cached session expired, removing");
64 self.clear();
65 return None;
66 }
67
68 debug!(
69 expires_in_secs = session.expires_at.saturating_sub(now),
70 "loaded cached session"
71 );
72 Some((session.cookie, session.csrf_token))
73 }
74
75 pub fn save(&self, cookie: &str, csrf_token: Option<&str>, expires_at: u64) {
77 let session = CachedSession {
78 cookie: cookie.to_owned(),
79 csrf_token: csrf_token.map(str::to_owned),
80 expires_at,
81 };
82
83 let Ok(json) = serde_json::to_string_pretty(&session) else {
84 warn!("failed to serialize session cache");
85 return;
86 };
87
88 if let Err(e) = atomic_write(&self.path, json.as_bytes()) {
89 warn!(error = %e, "failed to write session cache");
90 } else {
91 debug!("session cached to {}", self.path.display());
92 }
93 }
94
95 pub fn clear(&self) {
97 let _ = fs::remove_file(&self.path);
98 }
99}
100
101pub fn jwt_expiry(token: &str) -> Option<u64> {
106 let jwt = token.split(';').next()?.split('=').nth(1)?;
108
109 let parts: Vec<&str> = jwt.split('.').collect();
110 if parts.len() != 3 {
111 return None;
112 }
113
114 let payload = URL_SAFE_NO_PAD.decode(parts[1]).ok()?;
115 let claims: serde_json::Value = serde_json::from_slice(&payload).ok()?;
116 claims["exp"].as_u64()
117}
118
119pub fn fallback_expiry() -> u64 {
121 SystemTime::now()
122 .duration_since(UNIX_EPOCH)
123 .map_or(0, |d| d.as_secs() + 2 * 3600)
124}
125
126pub const EXPIRY_MARGIN_SECS: u64 = 60;
128
129fn cache_dir() -> Option<PathBuf> {
133 directories::ProjectDirs::from("", "", "unifly").map(|dirs| dirs.cache_dir().to_owned())
134}
135
136fn simple_hash(s: &str) -> String {
138 let mut hash: u64 = 5381;
139 for byte in s.bytes() {
140 hash = hash.wrapping_mul(33).wrapping_add(u64::from(byte));
141 }
142 format!("{hash:016x}")
143}
144
145fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
147 if let Some(parent) = path.parent() {
148 fs::create_dir_all(parent)?;
149 }
150
151 let tmp_path = path.with_extension("tmp");
152 let mut file = fs::File::create(&tmp_path)?;
153 file.write_all(data)?;
154 file.flush()?;
155
156 #[cfg(unix)]
158 {
159 use std::os::unix::fs::PermissionsExt;
160 file.set_permissions(fs::Permissions::from_mode(0o600))?;
161 }
162
163 drop(file);
164
165 #[cfg(windows)]
167 let _ = fs::remove_file(path);
168
169 fs::rename(&tmp_path, path)?;
170 Ok(())
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn jwt_expiry_parses_valid_token() {
179 let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256"}"#);
181 let payload = URL_SAFE_NO_PAD.encode(r#"{"exp":1700000000}"#);
182 let token = format!("TOKEN={header}.{payload}.sig");
183 assert_eq!(jwt_expiry(&token), Some(1_700_000_000));
184 }
185
186 #[test]
187 fn jwt_expiry_returns_none_for_garbage() {
188 assert_eq!(jwt_expiry("not-a-jwt"), None);
189 assert_eq!(jwt_expiry("TOKEN=a.b"), None);
190 }
191
192 #[test]
193 fn simple_hash_is_deterministic() {
194 let a = simple_hash("https://192.168.1.1");
195 let b = simple_hash("https://192.168.1.1");
196 assert_eq!(a, b);
197 assert_ne!(a, simple_hash("https://10.0.0.1"));
198 }
199
200 #[test]
201 fn session_cache_round_trips() {
202 let dir = tempfile::tempdir().expect("tmpdir");
203 let cache = SessionCache {
204 path: dir.path().join("test.json"),
205 };
206
207 cache.save("TOKEN=abc", Some("csrf123"), fallback_expiry());
208 let loaded = cache.load().expect("cache should load");
209 assert_eq!(loaded.0, "TOKEN=abc");
210 assert_eq!(loaded.1.as_deref(), Some("csrf123"));
211 }
212
213 #[test]
214 fn expired_session_returns_none() {
215 let dir = tempfile::tempdir().expect("tmpdir");
216 let cache = SessionCache {
217 path: dir.path().join("expired.json"),
218 };
219
220 cache.save("TOKEN=old", None, 0); assert!(cache.load().is_none());
222 }
223}