use super::metadata::SessionMetadataSummary;
use super::read::validate_session_id;
use super::store::{prepare_session_root, primary_path};
use std::path::{Path, PathBuf};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SessionInternalDiagnostic {
pub(crate) session_id: Option<String>,
pub(crate) message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SessionListReport {
pub(crate) summaries: Vec<SessionMetadataSummary>,
pub(crate) diagnostics: Vec<SessionInternalDiagnostic>,
}
#[derive(Debug, Clone)]
pub struct SessionManager {
pub(in crate::sessions) root: PathBuf,
}
impl SessionManager {
#[must_use]
pub fn new(root: PathBuf) -> Self {
Self { root }
}
pub fn create(&self) -> anyhow::Result<Session> {
prepare_session_root(&self.root)?;
let id = Uuid::new_v4().to_string();
let path = self.path_for_valid_id(&id)?;
Ok(Session { id, path })
}
pub fn open(&self, id: impl Into<String>) -> anyhow::Result<Session> {
let id = validate_session_id(id.into())?;
Ok(Session {
path: self.path_for_valid_id(&id)?,
id,
})
}
pub(crate) fn open_existing(&self, id: impl Into<String>) -> anyhow::Result<Session> {
let session = self.open(id)?;
super::store::open_existing_primary(&self.root, &session.id)?
.ok_or_else(|| anyhow::anyhow!("session JSONL is missing"))?;
Ok(session)
}
pub fn list(&self) -> anyhow::Result<Vec<Session>> {
Ok(self
.list_metadata_summaries()?
.into_iter()
.map(|summary| summary.session)
.collect())
}
pub fn most_recent(&self) -> anyhow::Result<Option<Session>> {
let report = self.list_metadata_report()?;
if !report.diagnostics.is_empty() {
anyhow::bail!("session discovery found unreadable or unsafe history");
}
Ok(report
.summaries
.into_iter()
.last()
.map(|summary| summary.session))
}
pub(crate) fn path_for_valid_id(&self, id: &str) -> anyhow::Result<PathBuf> {
primary_path(&self.root, id)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session {
pub(in crate::sessions) id: String,
pub(in crate::sessions) path: PathBuf,
}
impl Session {
pub fn id(&self) -> &str {
&self.id
}
pub fn path(&self) -> &Path {
&self.path
}
#[cfg(test)]
pub(crate) fn unchecked_for_test(id: String, path: PathBuf) -> Self {
Self { id, path }
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use std::path::Component;
use tempfile::TempDir;
fn valid_session_id_strategy() -> impl Strategy<Value = String> {
proptest::string::string_regex("[A-Za-z0-9_-]{1,64}").unwrap()
}
fn invalid_session_id_strategy() -> impl Strategy<Value = String> {
prop_oneof![
Just(String::new()),
Just(".".to_string()),
any::<String>().prop_map(|value| format!("{value}..")),
any::<String>().prop_map(|value| format!("{value}/{value}")),
any::<String>().prop_map(|value| format!("{value}\\{value}")),
any::<String>().prop_map(|value| format!("{value}.jsonl")),
any::<String>().prop_map(|value| format!("{value}é")),
]
}
fn lexical_normalize(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
normalized.push(component.as_os_str());
}
}
}
normalized
}
proptest! {
#[test]
fn path_for_valid_id_keeps_valid_ids_under_session_root(id in valid_session_id_strategy()) {
let temp = TempDir::new().unwrap();
let root = temp.path().join("sessions");
let manager = SessionManager::new(root.clone());
let path = manager.path_for_valid_id(&id).unwrap();
let normalized_root = lexical_normalize(&root);
let normalized_path = lexical_normalize(&path);
let expected_file_name = format!("{id}.jsonl");
prop_assert!(normalized_path.starts_with(&normalized_root));
prop_assert_eq!(normalized_path.parent(), Some(normalized_root.as_path()));
prop_assert_eq!(
normalized_path.file_name().and_then(|file_name| file_name.to_str()),
Some(expected_file_name.as_str())
);
let session = manager.open(id.clone()).unwrap();
prop_assert_eq!(session.id(), id);
prop_assert_eq!(session.path(), path.as_path());
}
#[test]
fn open_and_path_for_valid_id_reject_generated_unsafe_ids(id in invalid_session_id_strategy()) {
let temp = TempDir::new().unwrap();
let manager = SessionManager::new(temp.path().join("sessions"));
prop_assert!(manager.open(id.clone()).is_err());
prop_assert!(manager.path_for_valid_id(&id).is_err());
}
}
#[test]
fn session_open_rejects_unsafe_ids_before_joining_paths() {
let temp = TempDir::new().unwrap();
let manager = SessionManager::new(temp.path().join("sessions"));
for id in [
"",
"..",
"../escape",
"nested/id",
"nested\\id",
"/absolute",
"bad.jsonl",
] {
assert!(manager.open(id).is_err(), "accepted unsafe id {id:?}");
}
assert!(manager.open("safe_ID-123").is_ok());
}
}