1use crate::session::{Session, is_valid_id};
10use rustlavel_core::{Json, Result};
11use std::collections::HashMap;
12use std::future::Future;
13use std::path::{Path, PathBuf};
14use std::pin::Pin;
15use std::sync::{Arc, Mutex};
16use std::time::Duration;
17
18pub type StoreFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T>> + Send + 'a>>;
25
26pub const DEFAULT_LIFETIME: Duration = Duration::from_secs(120 * 60);
28
29pub trait SessionStore: Send + Sync + 'static {
30 fn read<'a>(&'a self, id: &'a str) -> StoreFuture<'a, Option<Session>>;
32
33 fn write<'a>(&'a self, session: &'a Session) -> StoreFuture<'a, ()>;
35
36 fn destroy<'a>(&'a self, id: &'a str) -> StoreFuture<'a, ()>;
38
39 fn gc(&self) -> StoreFuture<'_, usize>;
45
46 fn lifetime(&self) -> Duration;
48}
49
50fn is_live(updated_at: u64, lifetime: Duration) -> bool {
52 crate::unix_now().saturating_sub(updated_at) < lifetime.as_secs()
53}
54
55pub struct MemoryStore {
61 sessions: Mutex<HashMap<String, Json>>,
62 lifetime: Duration,
63}
64
65impl MemoryStore {
66 pub fn new() -> Self {
67 MemoryStore::with_lifetime(DEFAULT_LIFETIME)
68 }
69
70 pub fn with_lifetime(lifetime: Duration) -> Self {
71 MemoryStore { sessions: Mutex::new(HashMap::new()), lifetime }
72 }
73
74 pub fn len(&self) -> usize {
76 self.sessions.lock().expect("session store lock poisoned").len()
77 }
78
79 pub fn is_empty(&self) -> bool {
80 self.len() == 0
81 }
82}
83
84impl Default for MemoryStore {
85 fn default() -> Self {
86 MemoryStore::new()
87 }
88}
89
90impl SessionStore for MemoryStore {
91 fn read<'a>(&'a self, id: &'a str) -> StoreFuture<'a, Option<Session>> {
92 Box::pin(async move {
93 let sessions = self.sessions.lock().expect("session store lock poisoned");
94 let Some(payload) = sessions.get(id) else { return Ok(None) };
95
96 let session = Session::from_json(id, payload);
97 Ok(is_live(session.updated_at(), self.lifetime).then_some(session))
98 })
99 }
100
101 fn write<'a>(&'a self, session: &'a Session) -> StoreFuture<'a, ()> {
102 Box::pin(async move {
103 let mut stamped = session.clone();
104 stamped.touch();
105
106 let mut sessions = self.sessions.lock().expect("session store lock poisoned");
107 sessions.insert(stamped.id().to_string(), stamped.to_json());
108 Ok(())
109 })
110 }
111
112 fn destroy<'a>(&'a self, id: &'a str) -> StoreFuture<'a, ()> {
113 Box::pin(async move {
114 self.sessions.lock().expect("session store lock poisoned").remove(id);
115 Ok(())
116 })
117 }
118
119 fn gc(&self) -> StoreFuture<'_, usize> {
120 Box::pin(async move {
121 let mut sessions = self.sessions.lock().expect("session store lock poisoned");
122 let before = sessions.len();
123 sessions.retain(|_, payload| {
124 let updated_at = payload.get("updated_at").and_then(Json::as_f64).unwrap_or(0.0);
125 is_live(updated_at as u64, self.lifetime)
126 });
127 Ok(before - sessions.len())
128 })
129 }
130
131 fn lifetime(&self) -> Duration {
132 self.lifetime
133 }
134}
135
136pub struct FileStore {
143 directory: PathBuf,
144 lifetime: Duration,
145}
146
147impl FileStore {
148 pub const DEFAULT_DIRECTORY: &'static str = "storage/sessions";
150
151 pub fn new(directory: impl Into<PathBuf>) -> Self {
152 FileStore::with_lifetime(directory, DEFAULT_LIFETIME)
153 }
154
155 pub fn with_lifetime(directory: impl Into<PathBuf>, lifetime: Duration) -> Self {
156 FileStore { directory: directory.into(), lifetime }
157 }
158
159 pub fn directory(&self) -> &Path {
160 &self.directory
161 }
162
163 fn path_for(&self, id: &str) -> Option<PathBuf> {
170 is_valid_id(id).then(|| self.directory.join(format!("{id}.json")))
171 }
172}
173
174impl SessionStore for FileStore {
175 fn read<'a>(&'a self, id: &'a str) -> StoreFuture<'a, Option<Session>> {
176 Box::pin(async move {
177 let Some(path) = self.path_for(id) else { return Ok(None) };
178 let Ok(source) = tokio::fs::read_to_string(&path).await else { return Ok(None) };
179
180 let Ok(payload) = Json::parse(&source) else {
183 let _ = tokio::fs::remove_file(&path).await;
184 return Ok(None);
185 };
186
187 let session = Session::from_json(id, &payload);
188 if !is_live(session.updated_at(), self.lifetime) {
189 let _ = tokio::fs::remove_file(&path).await;
190 return Ok(None);
191 }
192 Ok(Some(session))
193 })
194 }
195
196 fn write<'a>(&'a self, session: &'a Session) -> StoreFuture<'a, ()> {
197 Box::pin(async move {
198 let Some(path) = self.path_for(session.id()) else {
199 return Err(rustlavel_core::Error::msg(
200 "refusing to write a session whose id was not generated by this framework",
201 ));
202 };
203
204 tokio::fs::create_dir_all(&self.directory).await?;
205
206 let mut stamped = session.clone();
207 stamped.touch();
208 tokio::fs::write(&path, stamped.to_json().to_string()).await?;
209 Ok(())
210 })
211 }
212
213 fn destroy<'a>(&'a self, id: &'a str) -> StoreFuture<'a, ()> {
214 Box::pin(async move {
215 if let Some(path) = self.path_for(id) {
216 let _ = tokio::fs::remove_file(path).await;
217 }
218 Ok(())
219 })
220 }
221
222 fn gc(&self) -> StoreFuture<'_, usize> {
223 Box::pin(async move {
224 let Ok(mut entries) = tokio::fs::read_dir(&self.directory).await else {
225 return Ok(0);
226 };
227
228 let mut removed = 0;
229 while let Some(entry) = entries.next_entry().await? {
230 let path = entry.path();
231 if path.extension().is_none_or(|extension| extension != "json") {
232 continue;
233 }
234
235 let live = match tokio::fs::read_to_string(&path).await {
236 Ok(source) => Json::parse(&source)
237 .ok()
238 .and_then(|payload| payload.get("updated_at").and_then(Json::as_f64))
239 .is_some_and(|updated_at| is_live(updated_at as u64, self.lifetime)),
240 Err(_) => continue,
241 };
242
243 if !live && tokio::fs::remove_file(&path).await.is_ok() {
244 removed += 1;
245 }
246 }
247 Ok(removed)
248 })
249 }
250
251 fn lifetime(&self) -> Duration {
252 self.lifetime
253 }
254}
255
256pub type SharedStore = Arc<dyn SessionStore>;
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 fn scratch(label: &str) -> PathBuf {
266 let directory = std::env::temp_dir()
267 .join(format!("rustlavel-auth-{label}-{}", crate::random::hex(8)));
268 std::fs::create_dir_all(&directory).expect("could not create the scratch directory");
269 directory
270 }
271
272 fn saved_session() -> Session {
273 let mut session = Session::new();
274 session.put("user_id", 7);
275 session.flash("status", "saved");
276 session.age_flash();
277 session
278 }
279
280 #[tokio::test]
281 async fn the_memory_store_round_trips_a_session() {
282 let store = MemoryStore::new();
283 let session = saved_session();
284
285 store.write(&session).await.unwrap();
286 let loaded = store.read(session.id()).await.unwrap().expect("session should be found");
287
288 assert_eq!(loaded.id(), session.id());
289 assert_eq!(loaded.get("user_id").and_then(Json::as_i64), Some(7));
290 assert_eq!(loaded.get_string("status").as_deref(), Some("saved"));
291 }
292
293 #[tokio::test]
294 async fn the_memory_store_forgets_unknown_and_destroyed_sessions() {
295 let store = MemoryStore::new();
296 let session = saved_session();
297 store.write(&session).await.unwrap();
298
299 assert!(store.read(&Session::new_id()).await.unwrap().is_none());
300
301 store.destroy(session.id()).await.unwrap();
302 assert!(store.read(session.id()).await.unwrap().is_none());
303 assert!(store.is_empty());
304 }
305
306 #[tokio::test]
307 async fn the_memory_store_refuses_and_collects_expired_sessions() {
308 let store = MemoryStore::with_lifetime(Duration::ZERO);
309 let session = saved_session();
310 store.write(&session).await.unwrap();
311
312 assert!(store.read(session.id()).await.unwrap().is_none(), "a zero lifetime expires at once");
313 assert_eq!(store.len(), 1, "reading does not remove it from memory");
314 assert_eq!(store.gc().await.unwrap(), 1);
315 assert!(store.is_empty());
316 }
317
318 #[tokio::test]
319 async fn the_file_store_persists_a_session_across_stores() {
320 let directory = scratch("persists");
321 let session = saved_session();
322
323 FileStore::new(&directory).write(&session).await.unwrap();
324
325 let reopened = FileStore::new(&directory);
327 let loaded = reopened.read(session.id()).await.unwrap().expect("session should be on disk");
328
329 assert_eq!(loaded.get("user_id").and_then(Json::as_i64), Some(7));
330 assert_eq!(loaded.get_string("status").as_deref(), Some("saved"));
331 assert!(directory.join(format!("{}.json", session.id())).exists());
332
333 let _ = std::fs::remove_dir_all(&directory);
334 }
335
336 #[tokio::test]
337 async fn the_file_store_creates_its_directory_on_first_write() {
338 let directory = scratch("creates").join("nested").join("sessions");
339 assert!(!directory.exists());
340
341 let store = FileStore::new(&directory);
342 let session = saved_session();
343 store.write(&session).await.unwrap();
344
345 assert!(directory.is_dir());
346 assert!(store.read(session.id()).await.unwrap().is_some());
347
348 let _ = std::fs::remove_dir_all(directory.parent().unwrap().parent().unwrap());
349 }
350
351 #[tokio::test]
352 async fn the_file_store_destroys_and_expires_sessions() {
353 let directory = scratch("destroys");
354 let session = saved_session();
355
356 let store = FileStore::new(&directory);
357 store.write(&session).await.unwrap();
358 store.destroy(session.id()).await.unwrap();
359 assert!(store.read(session.id()).await.unwrap().is_none());
360
361 let expiring = FileStore::with_lifetime(&directory, Duration::ZERO);
362 expiring.write(&session).await.unwrap();
363 assert!(expiring.read(session.id()).await.unwrap().is_none());
364 assert!(
365 !directory.join(format!("{}.json", session.id())).exists(),
366 "reading an expired session should clean the file up"
367 );
368
369 let _ = std::fs::remove_dir_all(&directory);
370 }
371
372 #[tokio::test]
373 async fn the_file_store_collects_expired_files_and_leaves_live_ones() {
374 let directory = scratch("collects");
375 let store = FileStore::new(&directory);
376 let first = saved_session();
377 let second = saved_session();
378
379 store.write(&first).await.unwrap();
380 store.write(&second).await.unwrap();
381
382 assert_eq!(store.gc().await.unwrap(), 0);
384 assert!(store.read(first.id()).await.unwrap().is_some());
385
386 let expired = FileStore::with_lifetime(&directory, Duration::ZERO);
388 assert_eq!(expired.gc().await.unwrap(), 2);
389 assert!(store.read(first.id()).await.unwrap().is_none());
390
391 let _ = std::fs::remove_dir_all(&directory);
392 }
393
394 #[tokio::test]
395 async fn the_file_store_never_lets_an_id_escape_its_directory() {
396 let directory = scratch("traversal");
397 let store = FileStore::new(&directory);
398
399 assert!(store.read("../../../etc/passwd").await.unwrap().is_none());
400 assert!(store.read("").await.unwrap().is_none());
401
402 let hostile = Session::with_id("../escaped");
403 assert!(store.write(&hostile).await.is_err());
404 assert!(!directory.parent().unwrap().join("escaped.json").exists());
405
406 let _ = std::fs::remove_dir_all(&directory);
407 }
408
409 #[tokio::test]
410 async fn a_corrupt_session_file_logs_the_visitor_out_instead_of_failing() {
411 let directory = scratch("corrupt");
412 let id = Session::new_id();
413 std::fs::write(directory.join(format!("{id}.json")), "{not json").unwrap();
414
415 let store = FileStore::new(&directory);
416 assert!(store.read(&id).await.unwrap().is_none());
417 assert!(!directory.join(format!("{id}.json")).exists());
418
419 let _ = std::fs::remove_dir_all(&directory);
420 }
421
422 #[tokio::test]
423 async fn both_stores_satisfy_the_trait_object() {
424 let stores: Vec<SharedStore> =
425 vec![Arc::new(MemoryStore::new()), Arc::new(FileStore::new(scratch("dyn")))];
426
427 for store in stores {
428 let session = saved_session();
429 store.write(&session).await.unwrap();
430
431 assert!(store.read(session.id()).await.unwrap().is_some());
432 assert_eq!(store.lifetime(), DEFAULT_LIFETIME);
433 }
434 }
435}