use chrono::{DateTime, Utc};
use fs4::fs_std::FileExt;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::OpenOptions;
use std::io::{Read, Write};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionEntry {
pub uuid: String,
pub last_used: DateTime<Utc>,
}
type SessionMap = HashMap<String, HashMap<String, SessionEntry>>;
#[derive(Debug, Clone)]
pub struct SessionStore {
path: PathBuf,
}
impl SessionStore {
pub fn new() -> Self {
if let Ok(explicit) = std::env::var("NSED_SESSION_DIR")
&& !explicit.is_empty()
{
return Self {
path: PathBuf::from(explicit).join("sessions.json"),
};
}
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
return Self {
path: PathBuf::from(home).join(".nsed").join("sessions.json"),
};
}
let uid_suffix = Self::user_suffix();
let dir = std::env::temp_dir().join(format!("nsed-sessions-{uid_suffix}"));
eprintln!(
"WARN session_store: HOME/USERPROFILE not set, falling back to {}",
dir.display()
);
Self {
path: dir.join("sessions.json"),
}
}
fn user_suffix() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".to_string())
}
fn create_dir_secure(path: &std::path::Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(path)?;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
Ok(())
}
#[cfg(not(unix))]
{
std::fs::create_dir_all(path)
}
}
#[cfg(test)]
pub fn with_path(path: PathBuf) -> Self {
Self { path }
}
pub fn record(&self, room_id: &str, agent_name: &str, uuid: &str) -> anyhow::Result<()> {
self.with_locked_map(|map| {
let room = map.entry(room_id.to_string()).or_default();
room.insert(
agent_name.to_string(),
SessionEntry {
uuid: uuid.to_string(),
last_used: Utc::now(),
},
);
})
}
#[allow(dead_code)] pub fn get(&self, room_id: &str, agent_name: &str) -> Option<String> {
let map = self.load_with_shared_lock().ok()?;
map.get(room_id)?.get(agent_name).map(|e| e.uuid.clone())
}
#[allow(dead_code)] pub fn cleanup_stale(&self, max_age: chrono::Duration) -> anyhow::Result<usize> {
let cutoff = Utc::now() - max_age;
let mut removed = 0;
self.with_locked_map(|map| {
map.retain(|_room, agents| {
agents.retain(|_name, entry| {
let keep = entry.last_used >= cutoff;
if !keep {
removed += 1;
}
keep
});
!agents.is_empty()
});
})?;
Ok(removed)
}
fn with_locked_map(&self, f: impl FnOnce(&mut SessionMap)) -> anyhow::Result<()> {
use std::io::Seek;
if let Some(parent) = self.path.parent() {
Self::create_dir_secure(parent)?;
}
let mut opts = OpenOptions::new();
opts.read(true).write(true).create(true).truncate(false);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts.open(&self.path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) =
std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(0o600))
{
eprintln!(
"WARN session_store: could not tighten permissions on sessions.json: {e}"
);
}
}
file.lock_exclusive()?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let mut map: SessionMap = if contents.trim().is_empty() {
HashMap::new()
} else {
match serde_json::from_str(&contents) {
Ok(m) => m,
Err(e) => {
eprintln!("WARN session_store: corrupt sessions.json, resetting: {e}");
HashMap::new()
}
}
};
f(&mut map);
let serialized = serde_json::to_string_pretty(&map)?;
file.seek(std::io::SeekFrom::Start(0))?;
file.set_len(0)?;
file.write_all(serialized.as_bytes())?;
file.flush()?;
Ok(())
}
#[allow(clippy::incompatible_msrv)] #[allow(dead_code)] fn load_with_shared_lock(&self) -> anyhow::Result<SessionMap> {
if !self.path.exists() {
return Ok(HashMap::new());
}
let mut file = OpenOptions::new().read(true).open(&self.path)?;
file.lock_shared()?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
if contents.trim().is_empty() {
return Ok(HashMap::new());
}
Ok(serde_json::from_str(&contents)?)
}
#[cfg(test)]
fn save_map(&self, map: &SessionMap) -> anyhow::Result<()> {
if let Some(parent) = self.path.parent() {
Self::create_dir_secure(parent)?;
}
let tmp = self.path.with_extension("json.tmp");
std::fs::write(&tmp, serde_json::to_string_pretty(map)?)?;
std::fs::rename(&tmp, &self.path)?;
Ok(())
}
}
impl Default for SessionStore {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_store() -> (SessionStore, TempDir) {
let dir = TempDir::new().unwrap();
let store = SessionStore::with_path(dir.path().join("sessions.json"));
(store, dir)
}
#[test]
fn record_and_get_roundtrip() {
let (store, _dir) = test_store();
store.record("room-1", "agent-a", "uuid-aaa").unwrap();
assert_eq!(store.get("room-1", "agent-a"), Some("uuid-aaa".to_string()));
}
#[test]
fn get_missing_returns_none() {
let (store, _dir) = test_store();
assert_eq!(store.get("no-room", "no-agent"), None);
}
#[test]
fn multiple_agents_per_room() {
let (store, _dir) = test_store();
store.record("room-1", "agent-a", "uuid-a").unwrap();
store.record("room-1", "agent-b", "uuid-b").unwrap();
assert_eq!(store.get("room-1", "agent-a"), Some("uuid-a".to_string()));
assert_eq!(store.get("room-1", "agent-b"), Some("uuid-b".to_string()));
}
#[test]
fn multiple_rooms_isolated() {
let (store, _dir) = test_store();
store.record("room-1", "agent-a", "uuid-1a").unwrap();
store.record("room-2", "agent-a", "uuid-2a").unwrap();
assert_eq!(store.get("room-1", "agent-a"), Some("uuid-1a".to_string()));
assert_eq!(store.get("room-2", "agent-a"), Some("uuid-2a".to_string()));
}
#[test]
fn record_updates_existing() {
let (store, _dir) = test_store();
store.record("room-1", "agent-a", "uuid-old").unwrap();
store.record("room-1", "agent-a", "uuid-new").unwrap();
assert_eq!(store.get("room-1", "agent-a"), Some("uuid-new".to_string()));
}
#[test]
fn cleanup_stale_removes_old_entries() {
let (store, _dir) = test_store();
let mut map: SessionMap = HashMap::new();
map.entry("old-room".to_string()).or_default().insert(
"agent-a".to_string(),
SessionEntry {
uuid: "old-uuid".to_string(),
last_used: Utc::now() - chrono::Duration::days(30),
},
);
map.entry("new-room".to_string()).or_default().insert(
"agent-b".to_string(),
SessionEntry {
uuid: "new-uuid".to_string(),
last_used: Utc::now(),
},
);
store.save_map(&map).unwrap();
let removed = store.cleanup_stale(chrono::Duration::days(7)).unwrap();
assert_eq!(removed, 1);
assert_eq!(store.get("old-room", "agent-a"), None);
assert_eq!(
store.get("new-room", "agent-b"),
Some("new-uuid".to_string())
);
}
#[test]
fn cleanup_removes_empty_rooms() {
let (store, _dir) = test_store();
let mut map: SessionMap = HashMap::new();
map.entry("stale-room".to_string()).or_default().insert(
"agent-a".to_string(),
SessionEntry {
uuid: "gone".to_string(),
last_used: Utc::now() - chrono::Duration::days(100),
},
);
store.save_map(&map).unwrap();
let removed = store.cleanup_stale(chrono::Duration::days(7)).unwrap();
assert_eq!(removed, 1);
let reloaded = store.load_with_shared_lock().unwrap();
assert!(reloaded.is_empty());
}
#[test]
fn empty_file_loads_ok() {
let (store, _dir) = test_store();
std::fs::create_dir_all(store.path.parent().unwrap()).unwrap();
std::fs::write(&store.path, "").unwrap();
assert_eq!(store.get("any", "any"), None);
}
#[test]
fn persistence_across_instances() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("sessions.json");
let store1 = SessionStore::with_path(path.clone());
store1.record("room-1", "agent-a", "uuid-abc").unwrap();
drop(store1);
let store2 = SessionStore::with_path(path);
assert_eq!(
store2.get("room-1", "agent-a"),
Some("uuid-abc".to_string())
);
}
#[test]
fn corrupt_json_self_heals() {
let (store, _dir) = test_store();
std::fs::create_dir_all(store.path.parent().unwrap()).unwrap();
std::fs::write(&store.path, "{ not valid json !!!").unwrap();
store.record("room", "agent", "uuid").unwrap();
assert_eq!(store.get("room", "agent"), Some("uuid".to_string()));
}
#[test]
fn record_creates_parent_directories() {
let dir = TempDir::new().unwrap();
let store =
SessionStore::with_path(dir.path().join("deep").join("nested").join("sessions.json"));
store.record("room-1", "agent-a", "uuid-deep").unwrap();
assert_eq!(
store.get("room-1", "agent-a"),
Some("uuid-deep".to_string())
);
}
#[test]
fn concurrent_records_no_data_loss() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("sessions.json");
let num_threads = 8;
let handles: Vec<_> = (0..num_threads)
.map(|i| {
let p = path.clone();
std::thread::spawn(move || {
let store = SessionStore::with_path(p);
store
.record("room-shared", &format!("agent-{i}"), &format!("uuid-{i}"))
.unwrap();
})
})
.collect();
for h in handles {
h.join().unwrap();
}
let store = SessionStore::with_path(path);
for i in 0..num_threads {
assert_eq!(
store.get("room-shared", &format!("agent-{i}")),
Some(format!("uuid-{i}")),
"agent-{i} should be present after concurrent writes"
);
}
}
#[test]
fn cleanup_on_nonexistent_file_is_noop() {
let (store, _dir) = test_store();
let removed = store.cleanup_stale(chrono::Duration::days(7)).unwrap();
assert_eq!(removed, 0);
}
#[test]
fn record_with_empty_strings() {
let (store, _dir) = test_store();
store.record("", "", "uuid-empty").unwrap();
assert_eq!(store.get("", ""), Some("uuid-empty".to_string()));
}
#[test]
fn record_with_special_characters() {
let (store, _dir) = test_store();
store
.record("oc-abc123-def456", "Sir_Wunderwaffel/v2", "uuid-special")
.unwrap();
assert_eq!(
store.get("oc-abc123-def456", "Sir_Wunderwaffel/v2"),
Some("uuid-special".to_string())
);
}
#[test]
fn last_used_updated_on_re_record() {
let (store, _dir) = test_store();
store.record("room", "agent", "uuid-1").unwrap();
let first_map = store.load_with_shared_lock().unwrap();
let first_ts = first_map["room"]["agent"].last_used;
std::thread::sleep(std::time::Duration::from_millis(10));
store.record("room", "agent", "uuid-2").unwrap();
let second_map = store.load_with_shared_lock().unwrap();
let second_ts = second_map["room"]["agent"].last_used;
assert!(second_ts >= first_ts, "last_used should be updated");
assert_eq!(second_map["room"]["agent"].uuid, "uuid-2");
}
}