use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
#[derive(Debug, Serialize, Deserialize)]
struct CachedSession {
cookie: String,
csrf_token: Option<String>,
expires_at: u64,
}
pub struct SessionCache {
path: PathBuf,
}
impl SessionCache {
pub fn new(profile_name: &str, controller_url: &str) -> Option<Self> {
let cache_dir = cache_dir()?;
let url_hash = simple_hash(controller_url);
let filename = format!("{profile_name}_{url_hash}.json");
Some(Self {
path: cache_dir.join(filename),
})
}
pub fn load(&self) -> Option<(String, Option<String>)> {
let data = fs::read_to_string(&self.path).ok()?;
let session: CachedSession = serde_json::from_str(&data).ok()?;
let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
if now >= session.expires_at {
debug!("cached session expired, removing");
self.clear();
return None;
}
debug!(
expires_in_secs = session.expires_at.saturating_sub(now),
"loaded cached session"
);
Some((session.cookie, session.csrf_token))
}
pub fn save(&self, cookie: &str, csrf_token: Option<&str>, expires_at: u64) {
let session = CachedSession {
cookie: cookie.to_owned(),
csrf_token: csrf_token.map(str::to_owned),
expires_at,
};
let Ok(json) = serde_json::to_string_pretty(&session) else {
warn!("failed to serialize session cache");
return;
};
if let Err(e) = atomic_write(&self.path, json.as_bytes()) {
warn!(error = %e, "failed to write session cache");
} else {
debug!("session cached to {}", self.path.display());
}
}
pub fn clear(&self) {
let _ = fs::remove_file(&self.path);
}
}
pub fn jwt_expiry(token: &str) -> Option<u64> {
let jwt = token.split(';').next()?.split('=').nth(1)?;
let parts: Vec<&str> = jwt.split('.').collect();
if parts.len() != 3 {
return None;
}
let payload = URL_SAFE_NO_PAD.decode(parts[1]).ok()?;
let claims: serde_json::Value = serde_json::from_slice(&payload).ok()?;
claims["exp"].as_u64()
}
pub fn fallback_expiry() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() + 2 * 3600)
.unwrap_or(0)
}
pub const EXPIRY_MARGIN_SECS: u64 = 60;
fn cache_dir() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", "unifly").map(|dirs| dirs.cache_dir().to_owned())
}
fn simple_hash(s: &str) -> String {
let mut hash: u64 = 5381;
for byte in s.bytes() {
hash = hash.wrapping_mul(33).wrapping_add(u64::from(byte));
}
format!("{hash:016x}")
}
fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp_path = path.with_extension("tmp");
let mut file = fs::File::create(&tmp_path)?;
file.write_all(data)?;
file.flush()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
}
drop(file);
#[cfg(windows)]
let _ = fs::remove_file(path);
fs::rename(&tmp_path, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jwt_expiry_parses_valid_token() {
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256"}"#);
let payload = URL_SAFE_NO_PAD.encode(r#"{"exp":1700000000}"#);
let token = format!("TOKEN={header}.{payload}.sig");
assert_eq!(jwt_expiry(&token), Some(1_700_000_000));
}
#[test]
fn jwt_expiry_returns_none_for_garbage() {
assert_eq!(jwt_expiry("not-a-jwt"), None);
assert_eq!(jwt_expiry("TOKEN=a.b"), None);
}
#[test]
fn simple_hash_is_deterministic() {
let a = simple_hash("https://192.168.1.1");
let b = simple_hash("https://192.168.1.1");
assert_eq!(a, b);
assert_ne!(a, simple_hash("https://10.0.0.1"));
}
#[test]
fn session_cache_round_trips() {
let dir = tempfile::tempdir().expect("tmpdir");
let cache = SessionCache {
path: dir.path().join("test.json"),
};
cache.save("TOKEN=abc", Some("csrf123"), fallback_expiry());
let loaded = cache.load().expect("cache should load");
assert_eq!(loaded.0, "TOKEN=abc");
assert_eq!(loaded.1.as_deref(), Some("csrf123"));
}
#[test]
fn expired_session_returns_none() {
let dir = tempfile::tempdir().expect("tmpdir");
let cache = SessionCache {
path: dir.path().join("expired.json"),
};
cache.save("TOKEN=old", None, 0); assert!(cache.load().is_none());
}
}