Skip to main content

rustlavel_auth/
store.rs

1//! Where sessions live between requests.
2//!
3//! Two drivers ship: [`MemoryStore`], which is the right answer for tests and
4//! for a single-process development server, and [`FileStore`], which is the
5//! right answer for a single production node and matches Laravel's default. A
6//! Redis or database driver is another implementation of [`SessionStore`] and
7//! needs no change here.
8
9use 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
18/// What a store's operations return.
19///
20/// Spelled out as a boxed future rather than an `async fn` because
21/// [`SessionStore`] has to be usable as `dyn SessionStore` — the middleware
22/// holds whichever driver the application picked, and cannot be generic over it
23/// without infecting every route with a type parameter.
24pub type StoreFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T>> + Send + 'a>>;
25
26/// The default session lifetime: two hours, as in Laravel.
27pub const DEFAULT_LIFETIME: Duration = Duration::from_secs(120 * 60);
28
29pub trait SessionStore: Send + Sync + 'static {
30    /// Load a session, or `None` if it is unknown or has expired.
31    fn read<'a>(&'a self, id: &'a str) -> StoreFuture<'a, Option<Session>>;
32
33    /// Persist a session under its current id.
34    fn write<'a>(&'a self, session: &'a Session) -> StoreFuture<'a, ()>;
35
36    /// Remove a session. Called on logout and after an id rotation.
37    fn destroy<'a>(&'a self, id: &'a str) -> StoreFuture<'a, ()>;
38
39    /// Drop everything past its lifetime, returning how many were removed.
40    ///
41    /// Expired sessions are already refused by `read`, so this is housekeeping
42    /// rather than security: without it a busy site accumulates one dead file
43    /// per visitor, forever.
44    fn gc(&self) -> StoreFuture<'_, usize>;
45
46    /// How long a session may sit untouched before it is considered gone.
47    fn lifetime(&self) -> Duration;
48}
49
50/// Whether a session written at `updated_at` is still within `lifetime`.
51fn is_live(updated_at: u64, lifetime: Duration) -> bool {
52    crate::unix_now().saturating_sub(updated_at) < lifetime.as_secs()
53}
54
55/// Sessions held in this process's memory.
56///
57/// Everything is lost on restart and nothing is shared between processes, which
58/// makes it perfect for tests and wrong for anything running more than one
59/// worker.
60pub 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    /// How many sessions are held, expired ones included. For tests.
75    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
136/// One JSON file per session, under `storage/sessions` by default.
137///
138/// Survives a restart, needs no service to be running, and is what a single
139/// application server should use. It does not work across machines: two nodes
140/// behind a load balancer each keep their own directory, and a visitor bounced
141/// between them looks logged out on alternate requests.
142pub struct FileStore {
143    directory: PathBuf,
144    lifetime: Duration,
145}
146
147impl FileStore {
148    /// Laravel's path, relative to the application root.
149    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    /// The file a session id maps to, or `None` if the id is not one of ours.
164    ///
165    /// The id is checked against [`is_valid_id`] before it is joined onto the
166    /// directory, so a cookie containing `../../../etc/passwd` never reaches
167    /// the filesystem at all. Rejecting by shape is safer than sanitising: an
168    /// id we did not mint has nothing to say to us anyway.
169    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            // A half-written or hand-edited file logs the visitor out; it does
181            // not fail the request.
182            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
256/// A store shared between the middleware and anything else that needs it.
257pub type SharedStore = Arc<dyn SessionStore>;
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    /// A directory of this test's own. Tests run concurrently, so sharing one
264    /// fixture directory would make them delete each other's session files.
265    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        // A second store, as a restarted process would build.
326        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        // Both were written a moment ago, so a real lifetime collects nothing.
383        assert_eq!(store.gc().await.unwrap(), 0);
384        assert!(store.read(first.id()).await.unwrap().is_some());
385
386        // A store whose lifetime has already elapsed collects both.
387        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}