use super::cache::{SessionBackend, SessionError};
use async_trait::async_trait;
use fs2::FileExt;
use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
const DEFAULT_SESSION_DIR: &str = "/tmp/reinhardt_sessions";
#[derive(Clone, Debug)]
pub struct FileSessionBackend {
session_dir: PathBuf,
}
impl FileSessionBackend {
pub fn new(session_dir: Option<PathBuf>) -> Result<Self, SessionError> {
let session_dir = session_dir.unwrap_or_else(|| PathBuf::from(DEFAULT_SESSION_DIR));
fs::create_dir_all(&session_dir).map_err(|e| {
SessionError::CacheError(format!("Failed to create session directory: {}", e))
})?;
Ok(Self { session_dir })
}
fn session_file_path(&self, session_key: &str) -> Result<PathBuf, SessionError> {
if !session_key
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(SessionError::CacheError(
"Invalid session key: contains unsafe characters".to_string(),
));
}
if session_key.is_empty() {
return Err(SessionError::CacheError(
"Invalid session key: empty".to_string(),
));
}
Ok(self
.session_dir
.join(format!("session_{}.json", session_key)))
}
fn is_expired(&self, file_path: &Path) -> bool {
if let Ok(metadata) = fs::metadata(file_path)
&& let Ok(modified) = metadata.modified()
&& let Ok(duration) = SystemTime::now().duration_since(modified)
{
if let Ok(mut file) = File::open(file_path) {
let _ = file.lock_shared();
let mut contents = String::new();
if file.read_to_string(&mut contents).is_ok()
&& let Ok(stored_data) = serde_json::from_str::<StoredSession>(&contents)
&& let Some(ttl) = stored_data.ttl
{
return duration.as_secs() > ttl;
}
let _ = file.unlock();
}
}
false
}
}
#[derive(Debug, Serialize, Deserialize)]
struct StoredSession {
data: serde_json::Value,
ttl: Option<u64>,
}
#[async_trait]
impl SessionBackend for FileSessionBackend {
async fn load<T>(&self, session_key: &str) -> Result<Option<T>, SessionError>
where
T: for<'de> Deserialize<'de> + Send,
{
let file_path = self.session_file_path(session_key)?;
if !file_path.exists() {
return Ok(None);
}
if self.is_expired(&file_path) {
let _ = fs::remove_file(&file_path);
return Ok(None);
}
let mut file = File::open(&file_path)
.map_err(|e| SessionError::CacheError(format!("Failed to open session file: {}", e)))?;
file.lock_shared()
.map_err(|e| SessionError::CacheError(format!("Failed to lock session file: {}", e)))?;
let mut contents = String::new();
let read_result = file.read_to_string(&mut contents);
let _ = file.unlock();
read_result
.map_err(|e| SessionError::CacheError(format!("Failed to read session file: {}", e)))?;
let stored_session: StoredSession = serde_json::from_str(&contents).map_err(|e| {
SessionError::SerializationError(format!("Failed to deserialize session: {}", e))
})?;
let data: T = serde_json::from_value(stored_session.data).map_err(|e| {
SessionError::SerializationError(format!("Failed to deserialize session data: {}", e))
})?;
Ok(Some(data))
}
async fn save<T>(
&self,
session_key: &str,
data: &T,
ttl: Option<u64>,
) -> Result<(), SessionError>
where
T: Serialize + Send + Sync,
{
let file_path = self.session_file_path(session_key)?;
let json_value = serde_json::to_value(data).map_err(|e| {
SessionError::SerializationError(format!("Failed to serialize session data: {}", e))
})?;
let stored_session = StoredSession {
data: json_value,
ttl,
};
let json_string = serde_json::to_string_pretty(&stored_session).map_err(|e| {
SessionError::SerializationError(format!("Failed to serialize stored session: {}", e))
})?;
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&file_path)
.map_err(|e| {
SessionError::CacheError(format!("Failed to create session file: {}", e))
})?;
file.lock_exclusive()
.map_err(|e| SessionError::CacheError(format!("Failed to lock session file: {}", e)))?;
let write_result = file.write_all(json_string.as_bytes());
let _ = file.unlock();
write_result.map_err(|e| {
SessionError::CacheError(format!("Failed to write session file: {}", e))
})?;
Ok(())
}
async fn delete(&self, session_key: &str) -> Result<(), SessionError> {
let file_path = self.session_file_path(session_key)?;
if file_path.exists() {
fs::remove_file(&file_path).map_err(|e| {
SessionError::CacheError(format!("Failed to delete session file: {}", e))
})?;
}
Ok(())
}
async fn exists(&self, session_key: &str) -> Result<bool, SessionError> {
let file_path = self.session_file_path(session_key)?;
if !file_path.exists() {
return Ok(false);
}
if self.is_expired(&file_path) {
let _ = fs::remove_file(&file_path);
return Ok(false);
}
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::sleep;
fn create_test_dir() -> PathBuf {
let test_dir = PathBuf::from(format!("/tmp/reinhardt_test_{}", uuid::Uuid::now_v7()));
fs::create_dir_all(&test_dir).expect("Failed to create test directory");
test_dir
}
fn cleanup_test_dir(dir: &Path) {
if dir.exists() {
fs::remove_dir_all(dir).expect("Failed to cleanup test directory");
}
}
struct TestDirGuard {
path: PathBuf,
}
impl TestDirGuard {
fn new() -> Self {
Self {
path: create_test_dir(),
}
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TestDirGuard {
fn drop(&mut self) {
cleanup_test_dir(&self.path);
}
}
#[tokio::test]
async fn test_file_backend_save_and_load() {
let _guard = TestDirGuard::new();
let backend = FileSessionBackend::new(Some(_guard.path().to_path_buf()))
.expect("Failed to create backend");
let session_data = json!({
"user_id": 42,
"username": "test_user",
"is_authenticated": true,
});
backend
.save("test_session_1", &session_data, None)
.await
.expect("Failed to save session");
let loaded: Option<serde_json::Value> = backend
.load("test_session_1")
.await
.expect("Failed to load session");
assert_eq!(loaded, Some(session_data));
}
#[tokio::test]
async fn test_file_backend_delete() {
let _guard = TestDirGuard::new();
let backend = FileSessionBackend::new(Some(_guard.path().to_path_buf()))
.expect("Failed to create backend");
let session_data = json!({ "data": "value" });
backend
.save("test_session_2", &session_data, None)
.await
.expect("Failed to save session");
assert!(
backend
.exists("test_session_2")
.await
.expect("Failed to check existence")
);
backend
.delete("test_session_2")
.await
.expect("Failed to delete session");
assert!(
!backend
.exists("test_session_2")
.await
.expect("Failed to check existence")
);
}
#[tokio::test]
async fn test_file_backend_exists() {
let _guard = TestDirGuard::new();
let backend = FileSessionBackend::new(Some(_guard.path().to_path_buf()))
.expect("Failed to create backend");
assert!(
!backend
.exists("nonexistent")
.await
.expect("Failed to check existence")
);
let session_data = json!({ "key": "value" });
backend
.save("test_session_3", &session_data, None)
.await
.expect("Failed to save session");
assert!(
backend
.exists("test_session_3")
.await
.expect("Failed to check existence")
);
}
#[tokio::test]
async fn test_file_backend_ttl_expiration() {
let _guard = TestDirGuard::new();
let backend = FileSessionBackend::new(Some(_guard.path().to_path_buf()))
.expect("Failed to create backend");
let session_data = json!({ "expires": "soon" });
backend
.save("test_session_4", &session_data, Some(1))
.await
.expect("Failed to save session");
assert!(
backend
.exists("test_session_4")
.await
.expect("Failed to check existence")
);
sleep(Duration::from_secs(2)).await;
assert!(
!backend
.exists("test_session_4")
.await
.expect("Failed to check existence")
);
let loaded: Option<serde_json::Value> = backend
.load("test_session_4")
.await
.expect("Failed to load session");
assert_eq!(loaded, None);
}
#[tokio::test]
async fn test_file_backend_concurrent_access() {
let _guard = TestDirGuard::new();
let backend = Arc::new(
FileSessionBackend::new(Some(_guard.path().to_path_buf()))
.expect("Failed to create backend"),
);
let session_key = "concurrent_session";
let mut handles = vec![];
for i in 0..10 {
let backend_clone = backend.clone();
let handle = tokio::spawn(async move {
let data = json!({ "counter": i });
backend_clone
.save(session_key, &data, None)
.await
.expect("Failed to save session");
});
handles.push(handle);
}
for handle in handles {
handle.await.expect("Task panicked");
}
let loaded: Option<serde_json::Value> = backend
.load(session_key)
.await
.expect("Failed to load session");
assert!(loaded.is_some());
assert!(loaded.unwrap()["counter"].is_number());
}
#[tokio::test]
async fn test_file_backend_overwrite() {
let _guard = TestDirGuard::new();
let backend = FileSessionBackend::new(Some(_guard.path().to_path_buf()))
.expect("Failed to create backend");
let session_key = "overwrite_session";
let data1 = json!({ "version": 1 });
backend
.save(session_key, &data1, None)
.await
.expect("Failed to save session");
let data2 = json!({ "version": 2 });
backend
.save(session_key, &data2, None)
.await
.expect("Failed to save session");
let loaded: Option<serde_json::Value> = backend
.load(session_key)
.await
.expect("Failed to load session");
assert_eq!(loaded, Some(data2));
}
#[tokio::test]
async fn test_file_backend_default_directory() {
let backend = FileSessionBackend::new(None).expect("Failed to create backend");
let session_data = json!({ "test": "default_dir" });
let session_key = format!("test_default_{}", uuid::Uuid::now_v7());
backend
.save(&session_key, &session_data, None)
.await
.expect("Failed to save session");
let loaded: Option<serde_json::Value> = backend
.load(&session_key)
.await
.expect("Failed to load session");
assert_eq!(loaded, Some(session_data));
backend
.delete(&session_key)
.await
.expect("Failed to delete session");
}
#[tokio::test]
async fn test_file_backend_nonexistent_load() {
let _guard = TestDirGuard::new();
let backend = FileSessionBackend::new(Some(_guard.path().to_path_buf()))
.expect("Failed to create backend");
let loaded: Option<serde_json::Value> = backend
.load("nonexistent_session")
.await
.expect("Failed to load session");
assert_eq!(loaded, None);
}
#[tokio::test]
async fn test_file_backend_delete_nonexistent() {
let _guard = TestDirGuard::new();
let backend = FileSessionBackend::new(Some(_guard.path().to_path_buf()))
.expect("Failed to create backend");
backend
.delete("nonexistent_session")
.await
.expect("Failed to delete session");
}
#[rstest::rstest]
#[case("../../../etc/passwd")]
#[case("..%2F..%2Fetc%2Fpasswd")]
#[case("foo/../../bar")]
#[case("/etc/passwd")]
#[case("session/../../../etc/shadow")]
#[tokio::test]
async fn test_file_backend_rejects_path_traversal_in_load(#[case] malicious_key: &str) {
let _guard = TestDirGuard::new();
let backend = FileSessionBackend::new(Some(_guard.path().to_path_buf()))
.expect("Failed to create backend");
let result: Result<Option<serde_json::Value>, _> = backend.load(malicious_key).await;
assert!(result.is_err());
}
#[rstest::rstest]
#[case("../../../etc/passwd")]
#[case("foo/../bar")]
#[case("/absolute/path")]
#[tokio::test]
async fn test_file_backend_rejects_path_traversal_in_save(#[case] malicious_key: &str) {
let _guard = TestDirGuard::new();
let backend = FileSessionBackend::new(Some(_guard.path().to_path_buf()))
.expect("Failed to create backend");
let data = serde_json::json!({"malicious": true});
let result = backend.save(malicious_key, &data, None).await;
assert!(result.is_err());
}
#[rstest::rstest]
#[case("valid_session_key")]
#[case("session-with-hyphens")]
#[case("session123")]
#[tokio::test]
async fn test_file_backend_allows_valid_session_keys(#[case] valid_key: &str) {
let _guard = TestDirGuard::new();
let backend = FileSessionBackend::new(Some(_guard.path().to_path_buf()))
.expect("Failed to create backend");
let data = serde_json::json!({"valid": true});
let save_result = backend.save(valid_key, &data, None).await;
assert!(save_result.is_ok());
}
}