use anyhow::Result;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
pub const MAX_SESSION_SECS: u64 = 6 * 60 * 60;
fn session_path(vault_dir: &Path) -> PathBuf {
vault_dir.join(".session")
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn secure_remove(path: &Path) -> std::io::Result<()> {
if path.exists() {
if let Ok(meta) = std::fs::metadata(path) {
let _ = std::fs::write(path, vec![0u8; meta.len() as usize]);
}
std::fs::remove_file(path)?;
}
Ok(())
}
pub fn write_session_key(path: &Path, key: &[u8; 32]) -> Result<()> {
let payload = format!("{}\n{}", now_secs(), hex::encode(key));
crate::core::secfile::write_owner_only(path, payload.as_bytes())?;
Ok(())
}
pub fn read_session_key(path: &Path) -> Option<[u8; 32]> {
let contents = std::fs::read_to_string(path).ok()?;
let (ts, hex_key) = contents.trim().split_once('\n')?;
let unlocked_at: u64 = ts.trim().parse().ok()?;
if now_secs().saturating_sub(unlocked_at) >= MAX_SESSION_SECS {
let _ = secure_remove(path);
return None;
}
hex::decode(hex_key.trim()).ok()?.try_into().ok()
}
pub fn unlock_with_key(vault_dir: &Path, key: &[u8; 32]) -> Result<()> {
write_session_key(&session_path(vault_dir), key)
}
pub fn lock(vault_dir: &Path) -> Result<()> {
secure_remove(&session_path(vault_dir))?;
Ok(())
}
pub fn is_unlocked(vault_dir: &Path) -> bool {
get_key(vault_dir).is_some()
}
pub fn get_key(vault_dir: &Path) -> Option<[u8; 32]> {
read_session_key(&session_path(vault_dir))
}
pub fn lock_all(svault_dir: &Path) -> Result<usize> {
let mut count = 0;
let Ok(entries) = std::fs::read_dir(svault_dir) else {
return Ok(0);
};
for entry in entries.flatten() {
let vault_dir = entry.path();
if vault_dir.is_dir() && session_path(&vault_dir).exists() {
lock(&vault_dir)?;
count += 1;
}
}
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn unlock_caches_key_then_lock_clears() {
let dir = TempDir::new().unwrap();
let vault_dir = dir.path().join("v");
std::fs::create_dir_all(&vault_dir).unwrap();
assert!(!is_unlocked(&vault_dir));
let key = [7u8; 32];
unlock_with_key(&vault_dir, &key).unwrap();
assert!(is_unlocked(&vault_dir));
assert_eq!(get_key(&vault_dir), Some(key));
lock(&vault_dir).unwrap();
assert!(!is_unlocked(&vault_dir));
assert_eq!(get_key(&vault_dir), None);
}
#[test]
fn session_never_contains_a_passphrase() {
let dir = TempDir::new().unwrap();
let vault_dir = dir.path().join("v");
std::fs::create_dir_all(&vault_dir).unwrap();
unlock_with_key(&vault_dir, &[0xABu8; 32]).unwrap();
let raw = std::fs::read_to_string(session_path(&vault_dir)).unwrap();
let (_ts, key) = raw.trim().split_once('\n').unwrap();
assert_eq!(key, "ab".repeat(32));
std::fs::write(session_path(&vault_dir), "hunter2").unwrap();
assert_eq!(get_key(&vault_dir), None);
}
#[test]
fn session_expires_past_the_hard_cap() {
let dir = TempDir::new().unwrap();
let vault_dir = dir.path().join("v");
std::fs::create_dir_all(&vault_dir).unwrap();
unlock_with_key(&vault_dir, &[5u8; 32]).unwrap();
assert!(is_unlocked(&vault_dir));
let path = session_path(&vault_dir);
let stale = now_secs().saturating_sub(MAX_SESSION_SECS + 1);
std::fs::write(&path, format!("{}\n{}", stale, "05".repeat(32))).unwrap();
assert_eq!(get_key(&vault_dir), None);
assert!(!is_unlocked(&vault_dir));
assert!(!path.exists());
}
#[test]
fn lock_all_locks_every_unlocked_vault() {
let svault = TempDir::new().unwrap();
let a = svault.path().join("a");
let b = svault.path().join("b");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
unlock_with_key(&a, &[1u8; 32]).unwrap();
unlock_with_key(&b, &[2u8; 32]).unwrap();
assert_eq!(lock_all(svault.path()).unwrap(), 2);
assert!(!is_unlocked(&a));
assert!(!is_unlocked(&b));
assert_eq!(lock_all(svault.path()).unwrap(), 0);
}
}