Skip to main content

larust_http/
session.rs

1//! Cookie-based sessions over `tower-sessions` (a real, maintained session
2//! engine - the cookie carries only an opaque session ID, actual data
3//! lives server-side in a `Store`, which is the standard, more secure
4//! pattern rather than shipping session contents in the cookie itself).
5//!
6//! Two real backends, chosen by [`Config::session_driver`](larust_core::Config::session_driver)
7//! (`SESSION_DRIVER` - `"database"`, the default, or `"file"`), both
8//! wrapped in one [`SessionBackend`] enum so `session_layer` always
9//! returns the same concrete `SessionManagerLayer<SessionBackend>` type
10//! regardless of which one is active:
11//!
12//! - [`AnySessionStore`]: a small hand-written `tower_sessions::
13//!   SessionStore` implementation over `sqlx::AnyPool` - not a third-party
14//!   per-backend store crate (`tower-sessions-sqlx-store`, say). That
15//!   crate's `SqliteStore`/`MySqlStore` each need their own concretely-
16//!   typed `SqlitePool`/`MySqlPool`, but `larust_orm::pool()` hands out a
17//!   runtime-generic `AnyPool` (see `larust_orm::Backend`) with no way to
18//!   recover a concrete pool from it - so a store built directly against
19//!   `AnyPool`, branching its own SQL by [`larust_orm::backend`] the same
20//!   way every other framework crate with its own table does
21//!   (`larust-permissions`, `larust-queue`, ...), is both the only real
22//!   option and the one consistent with how the rest of this framework
23//!   already handles the two backends.
24//! - [`FileSessionStore`]: one file per session under `storage/sessions/` -
25//!   Laravel's own `SESSION_DRIVER=file` equivalent, for an app that would
26//!   rather not put session churn through its own database at all
27//!   (especially now that `docs/GOTCHAS.md` documents a real production
28//!   incident SQLite-backed sessions caused under write-heavy load). Only
29//!   works for a single server - every process sharing this store needs to
30//!   see the same directory, which a real multi-server deployment behind a
31//!   load balancer can't guarantee the way a shared database can.
32//!
33//! Neither is an in-memory store: session data needs to survive a process
34//! restart (a deploy, a crash, `xr dev`'s rebuild-and-restart cycle), not
35//! just live for the lifetime of one process. There's deliberately no
36//! in-memory option in this crate's public API at all: an in-memory store
37//! is a real, common trap (an app that "works" in every manual test, then
38//! silently logs everyone out on every deploy) - same shape as Laravel's
39//! own `array` session driver being the wrong thing to ship to production.
40//! `"file"` doesn't have that problem - real files on disk survive a
41//! restart/crash exactly the way a database row does - which is why it's
42//! offered even though `array` deliberately isn't.
43
44pub use tower_sessions::{Session, SessionManagerLayer};
45
46use async_trait::async_trait;
47use larust_core::AppError;
48use larust_orm::Backend;
49use sqlx::AnyPool;
50use std::io;
51use std::path::{Path, PathBuf};
52use tower_sessions::session::{Id, Record};
53use tower_sessions::session_store::{self, ExpiredDeletion};
54use tower_sessions::SessionStore;
55
56/// How often the background cleanup task sweeps expired session rows out of
57/// the sessions table. `tower-sessions` itself already treats an expired
58/// session as logged-out on read (expiry is enforced regardless of this
59/// task), so this only bounds how long stale rows sit in the table -
60/// hourly is frequent enough that the table never grows unboundedly, and
61/// infrequent enough not to matter for a single-app database.
62const EXPIRED_SESSION_CLEANUP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3600);
63
64// `Session::cycle_id()` (from `tower_sessions`) is called from
65// `larust_auth::guard` on successful login, rotating the session ID to
66// prevent session fixation - not this crate's concern, since login itself
67// lives in `larust-auth`, but worth knowing it's already wired up if
68// you're looking for it.
69
70/// Reads and clears a "flash" value - like `session.remove(key)`, but only
71/// actually calls it when `key` was present. Prefer this for a flash-
72/// message read that runs on every page view whether or not one was
73/// queued (the common Laravel-style "check for a flashed success/error
74/// message on every response" pattern - see `demo/app/Http/Controllers/
75/// post_controller.rs`'s `index` for a real example).
76///
77/// The difference matters: `tower_sessions::Session::remove` marks the
78/// session modified unconditionally - confirmed by reading
79/// `tower-sessions-core`'s own `remove_value`, which flips its internal
80/// `is_modified` flag *before* even checking whether the underlying map
81/// had the key at all. A flash-message check is exactly the case where
82/// the key is usually *absent* (most page views have nothing flashed), so
83/// a bare `.remove()` in a handler that runs on every request writes to
84/// the session store on every single request, not just the ones that
85/// actually redirected in with a message. Under enough traffic this is a
86/// real, reproduced production issue, not a theoretical one - see
87/// `docs/GOTCHAS.md`.
88pub async fn take<T: serde::de::DeserializeOwned>(
89    session: &Session,
90    key: &str,
91) -> Result<Option<T>, tower_sessions::session::Error> {
92    if session.get_value(key).await?.is_none() {
93        return Ok(None);
94    }
95    session.remove(key).await
96}
97
98/// A `tower_sessions::SessionStore` over `sqlx::AnyPool` - see this
99/// module's own doc comment for why this is hand-written rather than a
100/// third-party store crate. The session's own `Record` (id/data/expiry) is
101/// stored as one row: `data` as a JSON-serialized `TEXT` column (`serde_json`
102/// is already a workspace-wide dependency; no need for a binary encoding
103/// crate just for this), `expiry_date` as Unix-epoch seconds - the same
104/// "epoch seconds as `INTEGER`" convention every other framework-owned
105/// table in this codebase already uses (`larust-cache`'s `cache_items`,
106/// `larust-queue`'s `jobs`, `larust-notifications`'s `notifications`), not
107/// `tower-sessions-sqlx-store`'s own native-timestamp-column choice.
108#[derive(Clone, Debug)]
109pub struct AnySessionStore {
110    pool: AnyPool,
111}
112
113impl AnySessionStore {
114    /// Public so `larust_testing::TestClient::acting_as` can build one
115    /// directly over the same pool the router's own session layer uses
116    /// (via `session_layer`, above), bypassing a real `/login` round trip.
117    pub fn new(pool: AnyPool) -> Self {
118        Self { pool }
119    }
120
121    /// Idempotent `CREATE TABLE IF NOT EXISTS` - called once, from
122    /// [`session_layer`], before the layer is ever handed a request.
123    async fn migrate(&self) -> Result<(), sqlx::Error> {
124        let create_table = match larust_orm::backend() {
125            Backend::Sqlite => {
126                "CREATE TABLE IF NOT EXISTS sessions (\
127                    id TEXT PRIMARY KEY, \
128                    data TEXT NOT NULL, \
129                    expiry_at INTEGER NOT NULL\
130                 )"
131            }
132            // `id`: a `TEXT`/`BLOB` column needs an explicit key length to
133            // be usable as a MySQL key at all - `tower_sessions::session::Id`
134            // always renders as a fixed 22-character URL-safe base64
135            // string (see its own `Display` impl), so `VARCHAR(32)` is a
136            // safe, generous cap.
137            //
138            // `data`: `VARCHAR`, not MySQL's own `TEXT` - confirmed
139            // empirically (a real, live MySQL server, not just reading
140            // source) that `sqlx`'s `Any` driver maps *every* MySQL
141            // `TEXT`/`TINYTEXT`/`MEDIUMTEXT`/`LONGTEXT` column to its own
142            // generic `Blob` kind, unconditionally, regardless of the
143            // column's actual charset (`sqlx-mysql`'s `Any` adapter keys
144            // off the wire-protocol `ColumnType` alone, which doesn't
145            // distinguish TEXT from BLOB the way the column's real
146            // charset does) - and `Decode<Any> for String` only ever
147            // accepts `Text`-kind values, so decoding a MySQL `TEXT`
148            // column as `String` through `Any` fails outright ("Rust type
149            // `String` is not compatible with SQL type `BLOB`"). Only
150            // `CHAR`/`VARCHAR` map to `Any`'s `Text` kind. `VARCHAR(4000)`
151            // (the largest that comfortably fits one `utf8mb4` row
152            // alongside this table's other columns) is a practical,
153            // generous cap for session data specifically - nowhere near
154            // enough for arbitrary large content, but session payloads
155            // are small structured data (auth id, CSRF token, a handful
156            // of flash values), never user-uploaded content.
157            Backend::MySql => {
158                "CREATE TABLE IF NOT EXISTS sessions (\
159                    id VARCHAR(32) PRIMARY KEY, \
160                    data VARCHAR(4000) NOT NULL, \
161                    expiry_at INTEGER NOT NULL\
162                 )"
163            }
164            // Postgres has native, unbounded `TEXT` and no MySQL-style key-
165            // length requirement - same shape as SQLite's own arm.
166            Backend::Postgres => {
167                "CREATE TABLE IF NOT EXISTS sessions (\
168                    id TEXT PRIMARY KEY, \
169                    data TEXT NOT NULL, \
170                    expiry_at INTEGER NOT NULL\
171                 )"
172            }
173        };
174        sqlx::query(create_table).execute(&self.pool).await?;
175        Ok(())
176    }
177}
178
179fn now_unix_secs() -> i64 {
180    std::time::SystemTime::now()
181        .duration_since(std::time::UNIX_EPOCH)
182        .expect("system clock is before the Unix epoch")
183        .as_secs() as i64
184}
185
186fn backend_error(source: sqlx::Error) -> session_store::Error {
187    session_store::Error::Backend(source.to_string())
188}
189
190fn encode(record: &Record) -> session_store::Result<String> {
191    serde_json::to_string(record).map_err(|source| session_store::Error::Encode(source.to_string()))
192}
193
194fn decode(data: &str) -> session_store::Result<Record> {
195    serde_json::from_str(data).map_err(|source| session_store::Error::Decode(source.to_string()))
196}
197
198/// Bounds `create`'s collision-retry loop. `Id` is a cryptographically
199/// random 128-bit value - a real collision is astronomically unlikely, so
200/// this is purely a safety backstop against a pathological RNG/DB state,
201/// never expected to actually bite in practice.
202const MAX_ID_COLLISION_RETRIES: u8 = 5;
203
204#[async_trait]
205impl SessionStore for AnySessionStore {
206    /// A real `INSERT` (not `save`'s upsert) so a genuine `Id` collision is
207    /// detected and retried with a fresh ID, rather than silently
208    /// overwriting the other session's row - the gap the default
209    /// `create`-via-`save` implementation has (see its own doc comment).
210    async fn create(&self, record: &mut Record) -> session_store::Result<()> {
211        let insert_sql = match larust_orm::backend() {
212            Backend::Sqlite | Backend::MySql => {
213                "INSERT INTO sessions (id, data, expiry_at) VALUES (?, ?, ?)"
214            }
215            Backend::Postgres => "INSERT INTO sessions (id, data, expiry_at) VALUES ($1, $2, $3)",
216        };
217
218        for _ in 0..MAX_ID_COLLISION_RETRIES {
219            let data = encode(record)?;
220            let expiry_at = record.expiry_date.unix_timestamp();
221            match sqlx::query(insert_sql)
222                .bind(record.id.to_string())
223                .bind(data)
224                .bind(expiry_at)
225                .execute(&self.pool)
226                .await
227            {
228                Ok(_) => return Ok(()),
229                Err(source) => {
230                    let is_collision = source
231                        .as_database_error()
232                        .is_some_and(|e| e.is_unique_violation());
233                    if !is_collision {
234                        return Err(backend_error(source));
235                    }
236                    record.id = Id::default();
237                }
238            }
239        }
240
241        Err(session_store::Error::Backend(format!(
242            "couldn't create a session after {MAX_ID_COLLISION_RETRIES} ID collisions in a row"
243        )))
244    }
245
246    async fn save(&self, record: &Record) -> session_store::Result<()> {
247        let data = encode(record)?;
248        let expiry_at = record.expiry_date.unix_timestamp();
249        let upsert_sql = match larust_orm::backend() {
250            Backend::Sqlite => {
251                "INSERT INTO sessions (id, data, expiry_at) VALUES (?, ?, ?) \
252                 ON CONFLICT(id) DO UPDATE SET data = excluded.data, expiry_at = excluded.expiry_at"
253            }
254            Backend::MySql => {
255                "INSERT INTO sessions (id, data, expiry_at) VALUES (?, ?, ?) \
256                 ON DUPLICATE KEY UPDATE data = VALUES(data), expiry_at = VALUES(expiry_at)"
257            }
258            Backend::Postgres => {
259                "INSERT INTO sessions (id, data, expiry_at) VALUES ($1, $2, $3) \
260                 ON CONFLICT(id) DO UPDATE SET data = excluded.data, expiry_at = excluded.expiry_at"
261            }
262        };
263        sqlx::query(upsert_sql)
264            .bind(record.id.to_string())
265            .bind(data)
266            .bind(expiry_at)
267            .execute(&self.pool)
268            .await
269            .map_err(backend_error)?;
270        Ok(())
271    }
272
273    async fn load(&self, session_id: &Id) -> session_store::Result<Option<Record>> {
274        let backend = larust_orm::backend();
275        let sql = format!(
276            "SELECT data FROM sessions WHERE id = {} AND expiry_at > {}",
277            larust_orm::placeholder(backend, 1),
278            larust_orm::placeholder(backend, 2),
279        );
280        let row: Option<(String,)> = sqlx::query_as(&sql)
281            .bind(session_id.to_string())
282            .bind(now_unix_secs())
283            .fetch_optional(&self.pool)
284            .await
285            .map_err(backend_error)?;
286
287        row.map(|(data,)| decode(&data)).transpose()
288    }
289
290    async fn delete(&self, session_id: &Id) -> session_store::Result<()> {
291        let sql = format!(
292            "DELETE FROM sessions WHERE id = {}",
293            larust_orm::placeholder(larust_orm::backend(), 1)
294        );
295        sqlx::query(&sql)
296            .bind(session_id.to_string())
297            .execute(&self.pool)
298            .await
299            .map_err(backend_error)?;
300        Ok(())
301    }
302}
303
304#[async_trait]
305impl ExpiredDeletion for AnySessionStore {
306    async fn delete_expired(&self) -> session_store::Result<()> {
307        let sql = format!(
308            "DELETE FROM sessions WHERE expiry_at <= {}",
309            larust_orm::placeholder(larust_orm::backend(), 1)
310        );
311        sqlx::query(&sql)
312            .bind(now_unix_secs())
313            .execute(&self.pool)
314            .await
315            .map_err(backend_error)?;
316        Ok(())
317    }
318}
319
320fn io_error(source: io::Error) -> session_store::Error {
321    session_store::Error::Backend(source.to_string())
322}
323
324/// A `tower_sessions::SessionStore` backed by plain files on disk, one per
325/// session - see this module's own doc comment for when this is used
326/// instead of [`AnySessionStore`]. Each session's `Record` is JSON-encoded
327/// (the same [`encode`]/[`decode`] helpers `AnySessionStore` itself uses)
328/// into a file named after [`Id`]'s own rendering.
329#[derive(Clone, Debug)]
330pub struct FileSessionStore {
331    dir: PathBuf,
332}
333
334impl FileSessionStore {
335    /// Public so `larust_testing::TestClient::acting_as` can build one
336    /// directly over the same directory the router's own session layer
337    /// uses, matching `AnySessionStore::new`'s identical role for the
338    /// database-backed store.
339    pub fn new(dir: impl Into<PathBuf>) -> Self {
340        Self { dir: dir.into() }
341    }
342
343    /// Called once from [`session_layer`], before the layer is ever
344    /// handed a request - the file-store equivalent of `AnySessionStore::
345    /// migrate`'s idempotent `CREATE TABLE IF NOT EXISTS`.
346    async fn ensure_dir(&self) -> io::Result<()> {
347        tokio::fs::create_dir_all(&self.dir).await
348    }
349
350    /// `Id`'s own `Display` always renders a fixed 22-character URL-safe-
351    /// base64 string (`A-Za-z0-9-_` only, confirmed by reading `tower-
352    /// sessions-core`'s own `Id` impl - the identical fact `AnySessionStore`'s
353    /// SQL binding already relies on) - safe as a bare filename on every
354    /// platform this framework targets, with no path-traversal risk, since
355    /// it's never derived from anything a client actually controls. Never
356    /// has a file extension, deliberately - see [`is_session_file`] below.
357    fn path_for(&self, id: &Id) -> PathBuf {
358        self.dir.join(id.to_string())
359    }
360
361    /// Writes `data` to `path` without ever letting a concurrent reader see
362    /// a half-written file: write to a sibling temp file first, then
363    /// `rename` it into place. Atomic and overwrite-safe on every platform
364    /// this framework targets - confirmed directly against the real
365    /// standard library source, not assumed: `std::fs::rename` on Windows
366    /// calls `MoveFileExW(..., MOVEFILE_REPLACE_EXISTING)`
367    /// (`library/std/src/sys/fs/windows.rs`), and a plain `rename(2)` on
368    /// Unix is already atomic and overwrite-safe by POSIX definition - so
369    /// any concurrent reader always sees either the complete old content or
370    /// the complete new content, never a torn write.
371    ///
372    /// The temp filename includes this process's own PID plus a monotonic
373    /// per-process counter, not a fixed suffix - two callers racing to save
374    /// the *same* session concurrently (a double-submitted form, or two
375    /// Larust generations briefly coexisting during a zero-downtime restart
376    /// handoff, both sharing this same directory on disk) must never share
377    /// one temp path, or one's write could land in the middle of the
378    /// other's in-flight rename.
379    async fn write_atomically(path: &Path, data: &[u8]) -> io::Result<()> {
380        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
381        let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
382        let tmp_path = path.with_extension(format!("tmp.{}.{n}", std::process::id()));
383        tokio::fs::write(&tmp_path, data).await?;
384        tokio::fs::rename(&tmp_path, path).await
385    }
386}
387
388/// A real session file, named after `Id`'s own rendering, never has a file
389/// extension - `write_atomically`'s own temp files always do
390/// (`<id>.tmp.<pid>.<n>`). `delete_expired`'s directory scan uses this to
391/// skip anything left behind by a process that crashed mid-write, rather
392/// than trying (and failing) to decode it as a session record.
393fn is_session_file(path: &Path) -> bool {
394    path.extension().is_none()
395}
396
397#[async_trait]
398impl SessionStore for FileSessionStore {
399    /// A real "fail if it already exists" create (not `save`'s upsert), the
400    /// file-store equivalent of `AnySessionStore::create`'s real `INSERT` -
401    /// see that method's own doc comment for why detecting a genuine `Id`
402    /// collision, rather than silently overwriting the other session, is
403    /// the whole reason `create` exists separately from `save` at all.
404    /// `create_new(true)` atomically fails with `AlreadyExists` if the
405    /// path is already taken, with no separate existence check to race
406    /// against. No temp-file dance needed here, unlike `save`/
407    /// `write_atomically`: a brand-new file has no previous version a
408    /// concurrent reader could see a torn mix of.
409    async fn create(&self, record: &mut Record) -> session_store::Result<()> {
410        use tokio::io::AsyncWriteExt;
411
412        for _ in 0..MAX_ID_COLLISION_RETRIES {
413            let path = self.path_for(&record.id);
414            let data = encode(record)?;
415            match tokio::fs::OpenOptions::new()
416                .write(true)
417                .create_new(true)
418                .open(&path)
419                .await
420            {
421                Ok(mut file) => {
422                    file.write_all(data.as_bytes()).await.map_err(io_error)?;
423                    // `tokio::fs::File` makes no guarantee the write is
424                    // actually complete and visible on `Drop` alone - its
425                    // own docs are explicit that close/flush errors (and,
426                    // by implication, full completion) are only surfaced
427                    // via an explicit call, not the implicit drop at the
428                    // end of this block. `write_atomically` (used by
429                    // `save`, below) doesn't need this: `tokio::fs::write`
430                    // is a single blocking-task call that already performs
431                    // the entire write synchronously before returning, not
432                    // the open-then-write-then-implicit-drop shape this
433                    // function uses.
434                    file.flush().await.map_err(io_error)?;
435                    return Ok(());
436                }
437                Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
438                    record.id = Id::default();
439                }
440                Err(source) => return Err(io_error(source)),
441            }
442        }
443
444        Err(session_store::Error::Backend(format!(
445            "couldn't create a session after {MAX_ID_COLLISION_RETRIES} ID collisions in a row"
446        )))
447    }
448
449    async fn save(&self, record: &Record) -> session_store::Result<()> {
450        let path = self.path_for(&record.id);
451        let data = encode(record)?;
452        Self::write_atomically(&path, data.as_bytes())
453            .await
454            .map_err(io_error)
455    }
456
457    async fn load(&self, session_id: &Id) -> session_store::Result<Option<Record>> {
458        match tokio::fs::read_to_string(self.path_for(session_id)).await {
459            Ok(data) => {
460                let record = decode(&data)?;
461                // Matches `AnySessionStore::load`'s own `WHERE expiry_at >
462                // now` filter exactly: an expired session is treated as
463                // absent on read, but its file is left on disk for
464                // `delete_expired`'s periodic sweep to remove later, not
465                // deleted eagerly here.
466                if record.expiry_date.unix_timestamp() > now_unix_secs() {
467                    Ok(Some(record))
468                } else {
469                    Ok(None)
470                }
471            }
472            Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(None),
473            Err(source) => Err(io_error(source)),
474        }
475    }
476
477    async fn delete(&self, session_id: &Id) -> session_store::Result<()> {
478        match tokio::fs::remove_file(self.path_for(session_id)).await {
479            Ok(()) => Ok(()),
480            // Matches `AnySessionStore::delete`'s own idempotent shape - a
481            // `DELETE FROM sessions WHERE id = ?` that matched no row is
482            // just as much a "success" as one that did.
483            Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()),
484            Err(source) => Err(io_error(source)),
485        }
486    }
487}
488
489#[async_trait]
490impl ExpiredDeletion for FileSessionStore {
491    async fn delete_expired(&self) -> session_store::Result<()> {
492        let mut entries = match tokio::fs::read_dir(&self.dir).await {
493            Ok(entries) => entries,
494            // Nothing has ever been saved yet - nothing to clean up, not
495            // an error.
496            Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(()),
497            Err(source) => return Err(io_error(source)),
498        };
499
500        let now = now_unix_secs();
501        while let Some(entry) = entries.next_entry().await.map_err(io_error)? {
502            let path = entry.path();
503            if !is_session_file(&path) {
504                continue;
505            }
506            // Best-effort, matching `RotatingFile::write`'s own "never
507            // fail the caller over a housekeeping concern" precedent: a
508            // file that fails to read or decode (a concurrent `delete`
509            // already removed it between `read_dir` listing it and this
510            // loop reaching it, or a stray non-session file) is skipped
511            // rather than aborting the rest of the sweep.
512            let Ok(data) = tokio::fs::read_to_string(&path).await else {
513                continue;
514            };
515            let Ok(record) = decode(&data) else {
516                continue;
517            };
518            if record.expiry_date.unix_timestamp() <= now {
519                let _ = tokio::fs::remove_file(&path).await;
520            }
521        }
522        Ok(())
523    }
524}
525
526/// Wraps whichever of [`AnySessionStore`]/[`FileSessionStore`] `Config::
527/// session_driver` selected, so [`session_layer`] always returns the same
528/// concrete `SessionManagerLayer<SessionBackend>` type regardless of which
529/// one is active - `tower_sessions::SessionManagerLayer<S>` is generic over
530/// one concrete `S`, so the alternative to an enum here would be trait-
531/// object erasure (`Arc<dyn SessionStore>`), more machinery for no real
532/// benefit over a two-variant `match` in each trait method.
533#[derive(Clone, Debug)]
534pub enum SessionBackend {
535    Database(AnySessionStore),
536    File(FileSessionStore),
537}
538
539#[async_trait]
540impl SessionStore for SessionBackend {
541    async fn create(&self, record: &mut Record) -> session_store::Result<()> {
542        match self {
543            Self::Database(store) => store.create(record).await,
544            Self::File(store) => store.create(record).await,
545        }
546    }
547
548    async fn save(&self, record: &Record) -> session_store::Result<()> {
549        match self {
550            Self::Database(store) => store.save(record).await,
551            Self::File(store) => store.save(record).await,
552        }
553    }
554
555    async fn load(&self, session_id: &Id) -> session_store::Result<Option<Record>> {
556        match self {
557            Self::Database(store) => store.load(session_id).await,
558            Self::File(store) => store.load(session_id).await,
559        }
560    }
561
562    async fn delete(&self, session_id: &Id) -> session_store::Result<()> {
563        match self {
564            Self::Database(store) => store.delete(session_id).await,
565            Self::File(store) => store.delete(session_id).await,
566        }
567    }
568}
569
570#[async_trait]
571impl ExpiredDeletion for SessionBackend {
572    async fn delete_expired(&self) -> session_store::Result<()> {
573        match self {
574            Self::Database(store) => store.delete_expired().await,
575            Self::File(store) => store.delete_expired().await,
576        }
577    }
578}
579
580/// Resolves `Config::session_driver` into the matching, constructed (but
581/// not yet prepared - see [`session_layer`]'s own `ensure_dir`/`migrate`
582/// call for that) [`SessionBackend`] variant. Split out from `session_layer`
583/// itself specifically so `larust_testing::TestClient` can build a handle
584/// over the *same* backend a router's own session layer resolved to,
585/// without duplicating the driver-selection logic (and risking it drifting
586/// out of sync) - `TestClient::acting_as` forges a session by writing
587/// directly through this handle, bypassing a real `/login` round trip, so
588/// it has to agree with the router on where sessions actually live.
589/// Doesn't call `ensure_dir`/`migrate` itself: a `TestClient` is always
590/// built over a `router` whose own `.with_sessions(...)` call already ran
591/// them, the same "someone else already prepared this" assumption the
592/// database branch has always relied on (`TestClient::new` never called
593/// `AnySessionStore::migrate` either, even before `FileSessionStore`
594/// existed).
595///
596/// Reads `Config::session_driver`/`AppPaths` through `larust_core::
597/// try_config()`/`try_paths()`, not `config()`/`paths()` - the same
598/// non-panicking fallback [`cookie_name`] already uses, and for the
599/// identical reason: a handful of narrow test harnesses build a session-
600/// bearing router directly off a bare pool, with no `Application::new()`
601/// call anywhere in the test, so nothing has ever published either
602/// singleton. Both fall back to `"database"` behavior when unset - not
603/// `"file"` - so calling this the way every existing test in this codebase
604/// already does keeps behaving identically whether or not an `Application`
605/// happened to be constructed first.
606pub fn resolve_backend(pool: &AnyPool) -> SessionBackend {
607    let driver =
608        larust_core::try_config().map_or("database", |config| config.session_driver.as_str());
609
610    match driver {
611        "file" => {
612            let dir = larust_core::try_paths()
613                .map(|paths| paths.sessions())
614                .unwrap_or_else(|| PathBuf::from("storage/sessions"));
615            SessionBackend::File(FileSessionStore::new(dir))
616        }
617        // "database", or anything unrecognized - `Application::new()`'s
618        // own `warn_if_session_driver_is_unsupported` already warns about
619        // the latter case; degrading to the always-safe default here
620        // rather than treating a typo'd `SESSION_DRIVER` as fatal matches
621        // `logging::init`'s identical "unrecognized `LOG_CHANNEL` degrades
622        // to stdout" precedent.
623        _ => SessionBackend::Database(AnySessionStore::new(pool.clone())),
624    }
625}
626
627/// Builds the session layer for a Larust app, over `pool` (typically
628/// `larust_support::orm::pool()`) - or, when `Config::session_driver` is
629/// `"file"`, over a directory instead, `pool` going unused for this call
630/// entirely. Dispatches to whichever of [`AnySessionStore`]/
631/// [`FileSessionStore`] is active (see this module's own doc comment),
632/// preparing it before returning: the database store's idempotent
633/// `CREATE TABLE IF NOT EXISTS`, or the file store's `create_dir_all` -
634/// no separate migration file needed in any app's `database/migrations/`
635/// either way.
636///
637/// Reads `Config::session_driver`/`AppPaths` through `larust_core::
638/// try_config()`/`try_paths()`, not `config()`/`paths()` - the same
639/// non-panicking fallback [`cookie_name`] already uses, and for the
640/// identical reason: a handful of narrow test harnesses (see that
641/// function's own doc comment) build a session-bearing router directly off
642/// a bare pool, with no `Application::new()` call anywhere in the test, so
643/// nothing has ever published either singleton. Both fall back to
644/// `"database"` behavior when unset - not `"file"` - so calling this the
645/// way every existing test in this codebase already does (`session_layer(
646/// &pool, secure)`, database-backed) keeps behaving identically whether or
647/// not an `Application` happened to be constructed first.
648///
649/// `secure` controls the cookie's `Secure` attribute (`tower-sessions`
650/// defaults this to `true`). Browsers only treat loopback addresses and
651/// the literal name `localhost` as secure contexts over plain HTTP - a
652/// custom local dev hostname (e.g. a `.test` domain resolved via
653/// `/etc/hosts`, even one that points at 127.0.0.1) is not on that list,
654/// so a `Secure` cookie is silently dropped and sessions/CSRF stop working
655/// with no error surfaced anywhere. `Router::with_sessions(pool, secure)`
656/// is how callers set this - see `Config::session_secure_cookie` for the
657/// `SESSION_SECURE_COOKIE`-env-driven value apps are expected to pass.
658///
659/// Also spawns a background task that periodically deletes expired
660/// sessions (every `EXPIRED_SESSION_CLEANUP_INTERVAL`) - a persistent
661/// store means expired sessions actually accumulate (rows or files) over
662/// time, unlike an in-memory store where every session vanishes on its own
663/// at the next restart regardless. This doesn't affect *expiry* itself
664/// (`tower-sessions` already treats an expired session as logged-out the
665/// moment it's read, with or without this task) - only how long stale
666/// rows/files linger.
667pub async fn session_layer(
668    pool: &AnyPool,
669    secure: bool,
670) -> Result<SessionManagerLayer<SessionBackend>, AppError> {
671    let store = resolve_backend(pool);
672    match &store {
673        SessionBackend::File(file_store) => file_store
674            .ensure_dir()
675            .await
676            .map_err(|source| AppError::Internal(Box::new(source)))?,
677        SessionBackend::Database(db_store) => db_store
678            .migrate()
679            .await
680            .map_err(|source| AppError::Internal(Box::new(source)))?,
681    }
682
683    let cleanup_store = store.clone();
684    tokio::spawn(async move {
685        // Hand-rolled rather than `ExpiredDeletion::continuously_delete_expired`
686        // (the same loop that method runs) - same effect, one fewer trait
687        // import to reason about.
688        let mut interval = tokio::time::interval(EXPIRED_SESSION_CLEANUP_INTERVAL);
689        interval.tick().await; // first tick completes immediately; skip it
690        loop {
691            interval.tick().await;
692            if let Err(error) = cleanup_store.delete_expired().await {
693                tracing::error!(%error, "expired session cleanup task stopped unexpectedly");
694                break;
695            }
696        }
697    });
698
699    // `tower_sessions` defaults the cookie name to a bare `"id"`, with no
700    // domain/port scoping - and browsers scope cookies by host+path only,
701    // never by port (RFC 6265), so two *different* Larust apps both
702    // running on `localhost`/`127.0.0.1` (any ports) would silently share
703    // one browser-side cookie slot: logging into one overwrites the other
704    // app's session cookie out from under it, and since the CSRF token is
705    // itself stored in the session (see `csrf.rs`), that surfaces as a
706    // CSRF mismatch rather than a plain logout. Naming the cookie after
707    // `Config::app_name` (already the same value `channel_address` keys
708    // the `xr dev`/`xr restart` admin channel by, so it's already the
709    // thing that's supposed to distinguish one app from another on this
710    // machine) keeps every app's session cookie in its own slot.
711    Ok(SessionManagerLayer::new(store)
712        .with_secure(secure)
713        .with_name(cookie_name()))
714}
715
716/// This app's own session cookie name, derived from `Config::app_name` -
717/// public so `larust_testing::TestClient::acting_as` can adopt a session by
718/// hand-crafting the exact same `Cookie` header the router's own session
719/// layer (above) would issue, without needing a real `/login` round trip.
720/// Must stay the single source of truth for the name: any second place
721/// that re-derives it independently risks drifting out of sync with
722/// whatever `session_layer` actually configured.
723///
724/// Uses `larust_core::try_config()`, not `config()` - a handful of narrow
725/// router-building test helpers (see e.g.
726/// `examples/blog/tests/store_post_test.rs`) build a session-bearing
727/// router directly off a bare pool, with no `Application::new()` call
728/// anywhere in the test at all, since nothing else they exercise needs
729/// one. Falling back to a fixed name in that case (rather than panicking)
730/// keeps this a purely additive change - every real app (`main.rs` always
731/// calls `Application::new()` first) still gets a properly app-scoped
732/// cookie name; a test with no `Application` just gets a stable shared one
733/// instead, which is harmless since nothing about in-process `oneshot()`-
734/// driven tests risks the actual cross-app cookie collision this scoping
735/// exists to prevent in a real browser.
736pub fn cookie_name() -> String {
737    let app_name = larust_core::try_config()
738        .map(|config| config.app_name.as_str())
739        .unwrap_or("app");
740    session_cookie_name(app_name)
741}
742
743/// Same ASCII-alphanumeric-or-underscore sanitization as
744/// `larust_core::lifecycle::admin::channel_address` - reused by name/shape,
745/// not by call, since that helper lives in a different crate and produces a
746/// pipe/socket-address string, not a cookie-token-safe one; a cookie name
747/// has the same "alphanumeric plus a few symbols" constraint an admin
748/// channel address does, so the same replace-anything-unsafe approach fits.
749fn session_cookie_name(app_name: &str) -> String {
750    let safe: String = app_name
751        .chars()
752        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
753        .collect();
754    format!("larust_{safe}_session")
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760
761    #[test]
762    fn cookie_name_is_derived_from_the_app_name() {
763        assert_eq!(session_cookie_name("blog"), "larust_blog_session");
764    }
765
766    #[test]
767    fn cookie_name_sanitizes_characters_a_cookie_token_can_t_contain() {
768        assert_eq!(
769            session_cookie_name("My App! 2.0"),
770            "larust_My_App__2_0_session"
771        );
772    }
773
774    #[test]
775    fn different_app_names_never_collide_on_the_same_cookie_name() {
776        assert_ne!(session_cookie_name("Larust"), session_cookie_name("blog"));
777    }
778
779    async fn connect_test_db() -> AnyPool {
780        let dir = tempfile::tempdir().unwrap().keep();
781        let database_url = format!("sqlite://{}/test.sqlite", dir.display());
782        larust_orm::connect(&database_url).await.unwrap();
783        larust_orm::pool().unwrap().clone()
784    }
785
786    /// All scenarios share one test function, not several:
787    /// `larust_orm::connect()` sets a process-wide pool exactly once (a
788    /// second call in the same test binary errors), the same
789    /// singleton-per-process constraint this codebase's other test suites
790    /// (`larust-notifications`, `larust-permissions`, `larust-queue`)
791    /// already document and work around.
792    #[tokio::test]
793    async fn any_session_store_behaves_correctly_across_every_scenario() {
794        let pool = connect_test_db().await;
795        let store = AnySessionStore::new(pool);
796        store.migrate().await.unwrap();
797
798        // A saved session round-trips through load.
799        let mut record = Record {
800            id: Id::default(),
801            data: Default::default(),
802            expiry_date: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
803        };
804        record
805            .data
806            .insert("greeting".to_string(), serde_json::json!("hi"));
807        store.save(&record).await.unwrap();
808        let loaded = store.load(&record.id).await.unwrap().unwrap();
809        assert_eq!(loaded.data["greeting"], serde_json::json!("hi"));
810
811        // An expired session is not loaded.
812        let expired = Record {
813            id: Id::default(),
814            data: Default::default(),
815            expiry_date: time::OffsetDateTime::now_utc() - time::Duration::hours(1),
816        };
817        store.save(&expired).await.unwrap();
818        assert!(store.load(&expired.id).await.unwrap().is_none());
819
820        // delete removes a saved session.
821        let doomed = Record {
822            id: Id::default(),
823            data: Default::default(),
824            expiry_date: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
825        };
826        store.save(&doomed).await.unwrap();
827        store.delete(&doomed.id).await.unwrap();
828        assert!(store.load(&doomed.id).await.unwrap().is_none());
829
830        // `create` on a genuinely fresh ID just inserts, same as `save`.
831        let mut fresh = Record {
832            id: Id::default(),
833            data: Default::default(),
834            expiry_date: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
835        };
836        fresh
837            .data
838            .insert("greeting".to_string(), serde_json::json!("fresh"));
839        let fresh_id = fresh.id;
840        store.create(&mut fresh).await.unwrap();
841        assert_eq!(
842            fresh.id, fresh_id,
843            "a non-colliding create must not change the id"
844        );
845        let loaded = store.load(&fresh_id).await.unwrap().unwrap();
846        assert_eq!(loaded.data["greeting"], serde_json::json!("fresh"));
847
848        // `create` on an *already-taken* id is the real regression guard
849        // for this method's whole reason to exist: unlike `save` (an
850        // upsert, which would silently overwrite the row above), `create`
851        // must detect the collision and regenerate the id rather than
852        // clobbering the existing session.
853        let mut colliding = Record {
854            id: fresh_id,
855            data: Default::default(),
856            expiry_date: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
857        };
858        colliding
859            .data
860            .insert("greeting".to_string(), serde_json::json!("colliding"));
861        store.create(&mut colliding).await.unwrap();
862        assert_ne!(
863            colliding.id, fresh_id,
864            "create must regenerate the id on a real collision, not overwrite the existing row"
865        );
866        // The original session survives untouched.
867        let original = store.load(&fresh_id).await.unwrap().unwrap();
868        assert_eq!(original.data["greeting"], serde_json::json!("fresh"));
869        // The regenerated session was actually created under its new id.
870        let new_one = store.load(&colliding.id).await.unwrap().unwrap();
871        assert_eq!(new_one.data["greeting"], serde_json::json!("colliding"));
872
873        // `take`'s actual regression guard: a bare `session.remove()` marks
874        // the session modified even when the key was never present
875        // (confirmed directly against `tower-sessions-core`'s own
876        // `remove_value`, not just assumed) - avoiding exactly that
877        // phantom-dirty write on a flash-message check with nothing
878        // actually flashed is `take`'s whole reason to exist.
879        let session = Session::new(None, std::sync::Arc::new(store.clone()), None);
880        let absent: Option<String> = take(&session, "success").await.unwrap();
881        assert_eq!(absent, None);
882        assert!(
883            !session.is_modified(),
884            "checking for an absent flash key must not mark the session dirty"
885        );
886
887        // The companion case: when the key *is* present, `take` must still
888        // actually remove it (a flash message read once, not re-shown on
889        // the next page) and report the session as modified, same as a
890        // bare `remove()` would.
891        session.insert("success", "saved!").await.unwrap();
892        let value: Option<String> = take(&session, "success").await.unwrap();
893        assert_eq!(value.as_deref(), Some("saved!"));
894        assert!(session.is_modified());
895        let gone: Option<String> = session.get("success").await.unwrap();
896        assert_eq!(gone, None);
897
898        // A basic sanity check for the production incident this area got
899        // hardened for (see `docs/GOTCHAS.md`): concurrent session writes
900        // against SQLite must succeed, not error with `(code: 5) database
901        // is locked`. This doesn't reproduce the original failure by
902        // itself - on fast local storage with a generous `busy_timeout`,
903        // even the old `max_connections(10)` absorbs a burst this size
904        // without erroring (confirmed directly: reverting `larust_orm::
905        // pool::connect`'s SQLite cap back to 10 and running this same
906        // scenario, even at much higher concurrency, still passed here).
907        // The original incident needed sustained real traffic, and very
908        // likely a slower disk and/or a zero-downtime restart handoff
909        // briefly running two processes' pools against the same file at
910        // once - none of which a single in-process test can cheaply
911        // reproduce. What this test *does* guard is a plain, real
912        // regression: that funneling every SQLite access through one
913        // connection (`max_connections(1)`, the actual fix) doesn't
914        // itself introduce a deadlock or a dropped write under concurrent
915        // callers - a legitimate risk worth a real test, even though the
916        // specific "thundering herd under load" failure mode itself has to
917        // stay a documented-but-unreproduced-in-CI scenario.
918        let concurrent_saves: Vec<_> = (0..32)
919            .map(|i| {
920                let store = store.clone();
921                tokio::spawn(async move {
922                    let mut record = Record {
923                        id: Id::default(),
924                        data: Default::default(),
925                        expiry_date: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
926                    };
927                    record.data.insert("i".to_string(), serde_json::json!(i));
928                    store.save(&record).await
929                })
930            })
931            .collect();
932        for task in concurrent_saves {
933            task.await
934                .expect("task panicked")
935                .expect("concurrent session save must not fail with a locked database");
936        }
937    }
938
939    /// The file-store equivalent of `any_session_store_behaves_correctly_
940    /// across_every_scenario` above - same scenarios, same assertions,
941    /// proving `FileSessionStore` honors the identical `SessionStore`
942    /// contract `AnySessionStore` does (round-trip, expiry filtering on
943    /// read, idempotent delete, and `create`'s real collision detection).
944    /// One function, not several, purely for symmetry with its database
945    /// counterpart - `FileSessionStore` has no shared-singleton
946    /// constraint forcing that shape the way `AnySessionStore`'s tests do,
947    /// but splitting it here while the sibling test stays merged would
948    /// make the two harder to compare side by side.
949    #[tokio::test]
950    async fn file_session_store_behaves_correctly_across_every_scenario() {
951        let dir = tempfile::tempdir().unwrap();
952        let store = FileSessionStore::new(dir.path());
953
954        // A saved session round-trips through load.
955        let mut record = Record {
956            id: Id::default(),
957            data: Default::default(),
958            expiry_date: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
959        };
960        record
961            .data
962            .insert("greeting".to_string(), serde_json::json!("hi"));
963        store.save(&record).await.unwrap();
964        let loaded = store.load(&record.id).await.unwrap().unwrap();
965        assert_eq!(loaded.data["greeting"], serde_json::json!("hi"));
966
967        // An expired session is not loaded, but its file is left on disk
968        // for `delete_expired` to find - not deleted eagerly on read,
969        // matching `AnySessionStore::load`'s identical behavior.
970        let expired = Record {
971            id: Id::default(),
972            data: Default::default(),
973            expiry_date: time::OffsetDateTime::now_utc() - time::Duration::hours(1),
974        };
975        store.save(&expired).await.unwrap();
976        assert!(store.load(&expired.id).await.unwrap().is_none());
977        assert!(
978            dir.path().join(expired.id.to_string()).exists(),
979            "an expired session's file must still exist until delete_expired sweeps it"
980        );
981
982        // delete removes a saved session, and is idempotent - deleting an
983        // already-absent session is still `Ok`, matching `AnySessionStore::
984        // delete`'s own `DELETE ... WHERE id = ?` (a no-op match is still a
985        // successful statement).
986        let doomed = Record {
987            id: Id::default(),
988            data: Default::default(),
989            expiry_date: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
990        };
991        store.save(&doomed).await.unwrap();
992        store.delete(&doomed.id).await.unwrap();
993        assert!(store.load(&doomed.id).await.unwrap().is_none());
994        store.delete(&doomed.id).await.unwrap();
995
996        // `create` on a genuinely fresh ID just inserts, same as `save`.
997        let mut fresh = Record {
998            id: Id::default(),
999            data: Default::default(),
1000            expiry_date: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
1001        };
1002        fresh
1003            .data
1004            .insert("greeting".to_string(), serde_json::json!("fresh"));
1005        let fresh_id = fresh.id;
1006        store.create(&mut fresh).await.unwrap();
1007        assert_eq!(
1008            fresh.id, fresh_id,
1009            "a non-colliding create must not change the id"
1010        );
1011        let loaded = store.load(&fresh_id).await.unwrap().unwrap();
1012        assert_eq!(loaded.data["greeting"], serde_json::json!("fresh"));
1013
1014        // `create` on an *already-taken* id is the real regression guard
1015        // for this method's whole reason to exist - unlike `save` (an
1016        // upsert, which would silently overwrite the file above), `create`
1017        // must detect the collision (`create_new(true)`'s `AlreadyExists`)
1018        // and regenerate the id rather than clobbering the existing
1019        // session.
1020        let mut colliding = Record {
1021            id: fresh_id,
1022            data: Default::default(),
1023            expiry_date: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
1024        };
1025        colliding
1026            .data
1027            .insert("greeting".to_string(), serde_json::json!("colliding"));
1028        store.create(&mut colliding).await.unwrap();
1029        assert_ne!(
1030            colliding.id, fresh_id,
1031            "create must regenerate the id on a real collision, not overwrite the existing file"
1032        );
1033        let original = store.load(&fresh_id).await.unwrap().unwrap();
1034        assert_eq!(original.data["greeting"], serde_json::json!("fresh"));
1035        let new_one = store.load(&colliding.id).await.unwrap().unwrap();
1036        assert_eq!(new_one.data["greeting"], serde_json::json!("colliding"));
1037
1038        // `delete_expired` removes only the expired file, leaving every
1039        // still-valid one (and any stray temp file - see below) alone.
1040        store.delete_expired().await.unwrap();
1041        assert!(!dir.path().join(expired.id.to_string()).exists());
1042        assert!(dir.path().join(fresh_id.to_string()).exists());
1043        assert!(dir.path().join(colliding.id.to_string()).exists());
1044
1045        // A leftover temp file (the shape a crash mid-`write_atomically`
1046        // would leave behind) must not crash `delete_expired`'s scan, and
1047        // must not be mistaken for a session to delete - `is_session_file`'s
1048        // whole reason to exist.
1049        tokio::fs::write(dir.path().join("orphaned.tmp.999.1"), b"not json")
1050            .await
1051            .unwrap();
1052        store.delete_expired().await.unwrap();
1053        assert!(dir.path().join("orphaned.tmp.999.1").exists());
1054
1055        // Concurrent creates (a burst of genuinely new sessions, e.g. many
1056        // simultaneous first-time visitors) must all succeed - proving the
1057        // real, final file's own atomic `create_new(true)` open doesn't
1058        // trip over the others, the file-store analogue of
1059        // `AnySessionStore`'s own concurrent-`save` regression guard above.
1060        let concurrent_creates: Vec<_> = (0..32)
1061            .map(|i| {
1062                let store = store.clone();
1063                tokio::spawn(async move {
1064                    let mut record = Record {
1065                        id: Id::default(),
1066                        data: Default::default(),
1067                        expiry_date: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
1068                    };
1069                    record.data.insert("i".to_string(), serde_json::json!(i));
1070                    store.create(&mut record).await
1071                })
1072            })
1073            .collect();
1074        for task in concurrent_creates {
1075            task.await
1076                .expect("task panicked")
1077                .expect("concurrent session create must not fail");
1078        }
1079    }
1080}