//! Cached session token stored next to the state file. //! //! File format (no password): two lines — username and hex-encoded session token. //! File format (with password): QPCE-encrypted version of the above. //! The token has a server-side 24h TTL; no client-side expiry tracking. use std::path::{Path, PathBuf}; use anyhow::Context; use super::state::{decrypt_state, encrypt_state, is_encrypted_state}; pub struct CachedSession { pub username: String, pub token_hex: String, } /// Derive the session cache path: `{state_path}.session`. fn session_cache_path(state_path: &Path) -> PathBuf { state_path.with_extension("session") } /// Parse the two-line format (username + token_hex) from plaintext bytes. fn parse_session_lines(text: &str) -> Option { let mut lines = text.lines(); let username = lines.next()?.trim().to_string(); let token_hex = lines.next()?.trim().to_string(); if username.is_empty() || token_hex.is_empty() { return None; } if hex::decode(&token_hex).is_err() { return None; } Some(CachedSession { username, token_hex }) } /// Load a cached session token. Returns None if file is missing or malformed. /// Decrypts if the file is QPCE-encrypted (requires `password`). pub fn load_cached_session(state_path: &Path, password: Option<&str>) -> Option { let path = session_cache_path(state_path); let raw = std::fs::read(&path).ok()?; if is_encrypted_state(&raw) { let pw = password?; let plaintext = decrypt_state(pw, &raw).ok()?; let text = String::from_utf8(plaintext).ok()?; parse_session_lines(&text) } else { let text = String::from_utf8(raw).ok()?; parse_session_lines(&text) } } /// Save a session token to the cache file (mode 0o600 on Unix). /// Encrypts with QPCE if `password` is provided. pub fn save_cached_session( state_path: &Path, username: &str, token_hex: &str, password: Option<&str>, ) -> anyhow::Result<()> { let path = session_cache_path(state_path); let contents = format!("{username}\n{token_hex}\n"); let bytes = match password { Some(pw) => encrypt_state(pw, contents.as_bytes())?, None => { #[cfg(not(unix))] tracing::warn!( "storing session token as plaintext (no password set); \ file permissions cannot be restricted on this platform" ); contents.into_bytes() } }; std::fs::write(&path, bytes).with_context(|| format!("write session cache {path:?}"))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); std::fs::set_permissions(&path, perms).ok(); } Ok(()) } /// Remove the cached session file. pub fn clear_cached_session(state_path: &Path) { let path = session_cache_path(state_path); std::fs::remove_file(&path).ok(); } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; #[test] fn plaintext_round_trip() { let dir = tempfile::tempdir().unwrap(); let state_path = dir.path().join("state.bin"); let token = hex::encode(b"session-token-bytes"); save_cached_session(&state_path, "alice", &token, None).unwrap(); let loaded = load_cached_session(&state_path, None).unwrap(); assert_eq!(loaded.username, "alice"); assert_eq!(loaded.token_hex, token); } #[test] fn encrypted_round_trip() { let dir = tempfile::tempdir().unwrap(); let state_path = dir.path().join("state.bin"); let password = "strong-password"; let token = hex::encode(b"encrypted-token"); save_cached_session(&state_path, "bob", &token, Some(password)).unwrap(); // Encrypted file should start with QPCE magic let raw = std::fs::read(session_cache_path(&state_path)).unwrap(); assert_eq!(&raw[..4], b"QPCE"); let loaded = load_cached_session(&state_path, Some(password)).unwrap(); assert_eq!(loaded.username, "bob"); assert_eq!(loaded.token_hex, token); } #[test] fn wrong_password_returns_none() { let dir = tempfile::tempdir().unwrap(); let state_path = dir.path().join("state.bin"); let token = hex::encode(b"secret-token"); save_cached_session(&state_path, "carol", &token, Some("correct")).unwrap(); let result = load_cached_session(&state_path, Some("wrong")); assert!(result.is_none()); } #[test] fn missing_file_returns_none() { let dir = tempfile::tempdir().unwrap(); let state_path = dir.path().join("nonexistent.bin"); assert!(load_cached_session(&state_path, None).is_none()); } #[test] fn clear_removes_file() { let dir = tempfile::tempdir().unwrap(); let state_path = dir.path().join("state.bin"); let token = hex::encode(b"to-be-deleted"); save_cached_session(&state_path, "dave", &token, None).unwrap(); assert!(session_cache_path(&state_path).exists()); clear_cached_session(&state_path); assert!(!session_cache_path(&state_path).exists()); } #[test] fn malformed_content_returns_none() { let dir = tempfile::tempdir().unwrap(); let state_path = dir.path().join("state.bin"); let cache_path = session_cache_path(&state_path); // Not valid hex on second line std::fs::write(&cache_path, "alice\nnot-hex-data\n").unwrap(); assert!(load_cached_session(&state_path, None).is_none()); // Only one line std::fs::write(&cache_path, "alice\n").unwrap(); assert!(load_cached_session(&state_path, None).is_none()); // Empty file std::fs::write(&cache_path, "").unwrap(); assert!(load_cached_session(&state_path, None).is_none()); } }