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                    return Ok(());
424                }
425                Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
426                    record.id = Id::default();
427                }
428                Err(source) => return Err(io_error(source)),
429            }
430        }
431
432        Err(session_store::Error::Backend(format!(
433            "couldn't create a session after {MAX_ID_COLLISION_RETRIES} ID collisions in a row"
434        )))
435    }
436
437    async fn save(&self, record: &Record) -> session_store::Result<()> {
438        let path = self.path_for(&record.id);
439        let data = encode(record)?;
440        Self::write_atomically(&path, data.as_bytes())
441            .await
442            .map_err(io_error)
443    }
444
445    async fn load(&self, session_id: &Id) -> session_store::Result<Option<Record>> {
446        match tokio::fs::read_to_string(self.path_for(session_id)).await {
447            Ok(data) => {
448                let record = decode(&data)?;
449                // Matches `AnySessionStore::load`'s own `WHERE expiry_at >
450                // now` filter exactly: an expired session is treated as
451                // absent on read, but its file is left on disk for
452                // `delete_expired`'s periodic sweep to remove later, not
453                // deleted eagerly here.
454                if record.expiry_date.unix_timestamp() > now_unix_secs() {
455                    Ok(Some(record))
456                } else {
457                    Ok(None)
458                }
459            }
460            Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(None),
461            Err(source) => Err(io_error(source)),
462        }
463    }
464
465    async fn delete(&self, session_id: &Id) -> session_store::Result<()> {
466        match tokio::fs::remove_file(self.path_for(session_id)).await {
467            Ok(()) => Ok(()),
468            // Matches `AnySessionStore::delete`'s own idempotent shape - a
469            // `DELETE FROM sessions WHERE id = ?` that matched no row is
470            // just as much a "success" as one that did.
471            Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()),
472            Err(source) => Err(io_error(source)),
473        }
474    }
475}
476
477#[async_trait]
478impl ExpiredDeletion for FileSessionStore {
479    async fn delete_expired(&self) -> session_store::Result<()> {
480        let mut entries = match tokio::fs::read_dir(&self.dir).await {
481            Ok(entries) => entries,
482            // Nothing has ever been saved yet - nothing to clean up, not
483            // an error.
484            Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(()),
485            Err(source) => return Err(io_error(source)),
486        };
487
488        let now = now_unix_secs();
489        while let Some(entry) = entries.next_entry().await.map_err(io_error)? {
490            let path = entry.path();
491            if !is_session_file(&path) {
492                continue;
493            }
494            // Best-effort, matching `RotatingFile::write`'s own "never
495            // fail the caller over a housekeeping concern" precedent: a
496            // file that fails to read or decode (a concurrent `delete`
497            // already removed it between `read_dir` listing it and this
498            // loop reaching it, or a stray non-session file) is skipped
499            // rather than aborting the rest of the sweep.
500            let Ok(data) = tokio::fs::read_to_string(&path).await else {
501                continue;
502            };
503            let Ok(record) = decode(&data) else {
504                continue;
505            };
506            if record.expiry_date.unix_timestamp() <= now {
507                let _ = tokio::fs::remove_file(&path).await;
508            }
509        }
510        Ok(())
511    }
512}
513
514/// Wraps whichever of [`AnySessionStore`]/[`FileSessionStore`] `Config::
515/// session_driver` selected, so [`session_layer`] always returns the same
516/// concrete `SessionManagerLayer<SessionBackend>` type regardless of which
517/// one is active - `tower_sessions::SessionManagerLayer<S>` is generic over
518/// one concrete `S`, so the alternative to an enum here would be trait-
519/// object erasure (`Arc<dyn SessionStore>`), more machinery for no real
520/// benefit over a two-variant `match` in each trait method.
521#[derive(Clone, Debug)]
522pub enum SessionBackend {
523    Database(AnySessionStore),
524    File(FileSessionStore),
525}
526
527#[async_trait]
528impl SessionStore for SessionBackend {
529    async fn create(&self, record: &mut Record) -> session_store::Result<()> {
530        match self {
531            Self::Database(store) => store.create(record).await,
532            Self::File(store) => store.create(record).await,
533        }
534    }
535
536    async fn save(&self, record: &Record) -> session_store::Result<()> {
537        match self {
538            Self::Database(store) => store.save(record).await,
539            Self::File(store) => store.save(record).await,
540        }
541    }
542
543    async fn load(&self, session_id: &Id) -> session_store::Result<Option<Record>> {
544        match self {
545            Self::Database(store) => store.load(session_id).await,
546            Self::File(store) => store.load(session_id).await,
547        }
548    }
549
550    async fn delete(&self, session_id: &Id) -> session_store::Result<()> {
551        match self {
552            Self::Database(store) => store.delete(session_id).await,
553            Self::File(store) => store.delete(session_id).await,
554        }
555    }
556}
557
558#[async_trait]
559impl ExpiredDeletion for SessionBackend {
560    async fn delete_expired(&self) -> session_store::Result<()> {
561        match self {
562            Self::Database(store) => store.delete_expired().await,
563            Self::File(store) => store.delete_expired().await,
564        }
565    }
566}
567
568/// Resolves `Config::session_driver` into the matching, constructed (but
569/// not yet prepared - see [`session_layer`]'s own `ensure_dir`/`migrate`
570/// call for that) [`SessionBackend`] variant. Split out from `session_layer`
571/// itself specifically so `larust_testing::TestClient` can build a handle
572/// over the *same* backend a router's own session layer resolved to,
573/// without duplicating the driver-selection logic (and risking it drifting
574/// out of sync) - `TestClient::acting_as` forges a session by writing
575/// directly through this handle, bypassing a real `/login` round trip, so
576/// it has to agree with the router on where sessions actually live.
577/// Doesn't call `ensure_dir`/`migrate` itself: a `TestClient` is always
578/// built over a `router` whose own `.with_sessions(...)` call already ran
579/// them, the same "someone else already prepared this" assumption the
580/// database branch has always relied on (`TestClient::new` never called
581/// `AnySessionStore::migrate` either, even before `FileSessionStore`
582/// existed).
583///
584/// Reads `Config::session_driver`/`AppPaths` through `larust_core::
585/// try_config()`/`try_paths()`, not `config()`/`paths()` - the same
586/// non-panicking fallback [`cookie_name`] already uses, and for the
587/// identical reason: a handful of narrow test harnesses build a session-
588/// bearing router directly off a bare pool, with no `Application::new()`
589/// call anywhere in the test, so nothing has ever published either
590/// singleton. Both fall back to `"database"` behavior when unset - not
591/// `"file"` - so calling this the way every existing test in this codebase
592/// already does keeps behaving identically whether or not an `Application`
593/// happened to be constructed first.
594pub fn resolve_backend(pool: &AnyPool) -> SessionBackend {
595    let driver =
596        larust_core::try_config().map_or("database", |config| config.session_driver.as_str());
597
598    match driver {
599        "file" => {
600            let dir = larust_core::try_paths()
601                .map(|paths| paths.sessions())
602                .unwrap_or_else(|| PathBuf::from("storage/sessions"));
603            SessionBackend::File(FileSessionStore::new(dir))
604        }
605        // "database", or anything unrecognized - `Application::new()`'s
606        // own `warn_if_session_driver_is_unsupported` already warns about
607        // the latter case; degrading to the always-safe default here
608        // rather than treating a typo'd `SESSION_DRIVER` as fatal matches
609        // `logging::init`'s identical "unrecognized `LOG_CHANNEL` degrades
610        // to stdout" precedent.
611        _ => SessionBackend::Database(AnySessionStore::new(pool.clone())),
612    }
613}
614
615/// Builds the session layer for a Larust app, over `pool` (typically
616/// `larust_support::orm::pool()`) - or, when `Config::session_driver` is
617/// `"file"`, over a directory instead, `pool` going unused for this call
618/// entirely. Dispatches to whichever of [`AnySessionStore`]/
619/// [`FileSessionStore`] is active (see this module's own doc comment),
620/// preparing it before returning: the database store's idempotent
621/// `CREATE TABLE IF NOT EXISTS`, or the file store's `create_dir_all` -
622/// no separate migration file needed in any app's `database/migrations/`
623/// either way.
624///
625/// Reads `Config::session_driver`/`AppPaths` through `larust_core::
626/// try_config()`/`try_paths()`, not `config()`/`paths()` - the same
627/// non-panicking fallback [`cookie_name`] already uses, and for the
628/// identical reason: a handful of narrow test harnesses (see that
629/// function's own doc comment) build a session-bearing router directly off
630/// a bare pool, with no `Application::new()` call anywhere in the test, so
631/// nothing has ever published either singleton. Both fall back to
632/// `"database"` behavior when unset - not `"file"` - so calling this the
633/// way every existing test in this codebase already does (`session_layer(
634/// &pool, secure)`, database-backed) keeps behaving identically whether or
635/// not an `Application` happened to be constructed first.
636///
637/// `secure` controls the cookie's `Secure` attribute (`tower-sessions`
638/// defaults this to `true`). Browsers only treat loopback addresses and
639/// the literal name `localhost` as secure contexts over plain HTTP - a
640/// custom local dev hostname (e.g. a `.test` domain resolved via
641/// `/etc/hosts`, even one that points at 127.0.0.1) is not on that list,
642/// so a `Secure` cookie is silently dropped and sessions/CSRF stop working
643/// with no error surfaced anywhere. `Router::with_sessions(pool, secure)`
644/// is how callers set this - see `Config::session_secure_cookie` for the
645/// `SESSION_SECURE_COOKIE`-env-driven value apps are expected to pass.
646///
647/// Also spawns a background task that periodically deletes expired
648/// sessions (every `EXPIRED_SESSION_CLEANUP_INTERVAL`) - a persistent
649/// store means expired sessions actually accumulate (rows or files) over
650/// time, unlike an in-memory store where every session vanishes on its own
651/// at the next restart regardless. This doesn't affect *expiry* itself
652/// (`tower-sessions` already treats an expired session as logged-out the
653/// moment it's read, with or without this task) - only how long stale
654/// rows/files linger.
655pub async fn session_layer(
656    pool: &AnyPool,
657    secure: bool,
658) -> Result<SessionManagerLayer<SessionBackend>, AppError> {
659    let store = resolve_backend(pool);
660    match &store {
661        SessionBackend::File(file_store) => file_store
662            .ensure_dir()
663            .await
664            .map_err(|source| AppError::Internal(Box::new(source)))?,
665        SessionBackend::Database(db_store) => db_store
666            .migrate()
667            .await
668            .map_err(|source| AppError::Internal(Box::new(source)))?,
669    }
670
671    let cleanup_store = store.clone();
672    tokio::spawn(async move {
673        // Hand-rolled rather than `ExpiredDeletion::continuously_delete_expired`
674        // (the same loop that method runs) - same effect, one fewer trait
675        // import to reason about.
676        let mut interval = tokio::time::interval(EXPIRED_SESSION_CLEANUP_INTERVAL);
677        interval.tick().await; // first tick completes immediately; skip it
678        loop {
679            interval.tick().await;
680            if let Err(error) = cleanup_store.delete_expired().await {
681                tracing::error!(%error, "expired session cleanup task stopped unexpectedly");
682                break;
683            }
684        }
685    });
686
687    // `tower_sessions` defaults the cookie name to a bare `"id"`, with no
688    // domain/port scoping - and browsers scope cookies by host+path only,
689    // never by port (RFC 6265), so two *different* Larust apps both
690    // running on `localhost`/`127.0.0.1` (any ports) would silently share
691    // one browser-side cookie slot: logging into one overwrites the other
692    // app's session cookie out from under it, and since the CSRF token is
693    // itself stored in the session (see `csrf.rs`), that surfaces as a
694    // CSRF mismatch rather than a plain logout. Naming the cookie after
695    // `Config::app_name` (already the same value `channel_address` keys
696    // the `xr dev`/`xr restart` admin channel by, so it's already the
697    // thing that's supposed to distinguish one app from another on this
698    // machine) keeps every app's session cookie in its own slot.
699    Ok(SessionManagerLayer::new(store)
700        .with_secure(secure)
701        .with_name(cookie_name()))
702}
703
704/// This app's own session cookie name, derived from `Config::app_name` -
705/// public so `larust_testing::TestClient::acting_as` can adopt a session by
706/// hand-crafting the exact same `Cookie` header the router's own session
707/// layer (above) would issue, without needing a real `/login` round trip.
708/// Must stay the single source of truth for the name: any second place
709/// that re-derives it independently risks drifting out of sync with
710/// whatever `session_layer` actually configured.
711///
712/// Uses `larust_core::try_config()`, not `config()` - a handful of narrow
713/// router-building test helpers (see e.g.
714/// `examples/blog/tests/store_post_test.rs`) build a session-bearing
715/// router directly off a bare pool, with no `Application::new()` call
716/// anywhere in the test at all, since nothing else they exercise needs
717/// one. Falling back to a fixed name in that case (rather than panicking)
718/// keeps this a purely additive change - every real app (`main.rs` always
719/// calls `Application::new()` first) still gets a properly app-scoped
720/// cookie name; a test with no `Application` just gets a stable shared one
721/// instead, which is harmless since nothing about in-process `oneshot()`-
722/// driven tests risks the actual cross-app cookie collision this scoping
723/// exists to prevent in a real browser.
724pub fn cookie_name() -> String {
725    let app_name = larust_core::try_config()
726        .map(|config| config.app_name.as_str())
727        .unwrap_or("app");
728    session_cookie_name(app_name)
729}
730
731/// Same ASCII-alphanumeric-or-underscore sanitization as
732/// `larust_core::lifecycle::admin::channel_address` - reused by name/shape,
733/// not by call, since that helper lives in a different crate and produces a
734/// pipe/socket-address string, not a cookie-token-safe one; a cookie name
735/// has the same "alphanumeric plus a few symbols" constraint an admin
736/// channel address does, so the same replace-anything-unsafe approach fits.
737fn session_cookie_name(app_name: &str) -> String {
738    let safe: String = app_name
739        .chars()
740        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
741        .collect();
742    format!("larust_{safe}_session")
743}
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748
749    #[test]
750    fn cookie_name_is_derived_from_the_app_name() {
751        assert_eq!(session_cookie_name("blog"), "larust_blog_session");
752    }
753
754    #[test]
755    fn cookie_name_sanitizes_characters_a_cookie_token_can_t_contain() {
756        assert_eq!(
757            session_cookie_name("My App! 2.0"),
758            "larust_My_App__2_0_session"
759        );
760    }
761
762    #[test]
763    fn different_app_names_never_collide_on_the_same_cookie_name() {
764        assert_ne!(session_cookie_name("Larust"), session_cookie_name("blog"));
765    }
766
767    async fn connect_test_db() -> AnyPool {
768        let dir = tempfile::tempdir().unwrap().keep();
769        let database_url = format!("sqlite://{}/test.sqlite", dir.display());
770        larust_orm::connect(&database_url).await.unwrap();
771        larust_orm::pool().unwrap().clone()
772    }
773
774    /// All scenarios share one test function, not several:
775    /// `larust_orm::connect()` sets a process-wide pool exactly once (a
776    /// second call in the same test binary errors), the same
777    /// singleton-per-process constraint this codebase's other test suites
778    /// (`larust-notifications`, `larust-permissions`, `larust-queue`)
779    /// already document and work around.
780    #[tokio::test]
781    async fn any_session_store_behaves_correctly_across_every_scenario() {
782        let pool = connect_test_db().await;
783        let store = AnySessionStore::new(pool);
784        store.migrate().await.unwrap();
785
786        // A saved session round-trips through load.
787        let mut record = Record {
788            id: Id::default(),
789            data: Default::default(),
790            expiry_date: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
791        };
792        record
793            .data
794            .insert("greeting".to_string(), serde_json::json!("hi"));
795        store.save(&record).await.unwrap();
796        let loaded = store.load(&record.id).await.unwrap().unwrap();
797        assert_eq!(loaded.data["greeting"], serde_json::json!("hi"));
798
799        // An expired session is not loaded.
800        let expired = Record {
801            id: Id::default(),
802            data: Default::default(),
803            expiry_date: time::OffsetDateTime::now_utc() - time::Duration::hours(1),
804        };
805        store.save(&expired).await.unwrap();
806        assert!(store.load(&expired.id).await.unwrap().is_none());
807
808        // delete removes a saved session.
809        let doomed = Record {
810            id: Id::default(),
811            data: Default::default(),
812            expiry_date: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
813        };
814        store.save(&doomed).await.unwrap();
815        store.delete(&doomed.id).await.unwrap();
816        assert!(store.load(&doomed.id).await.unwrap().is_none());
817
818        // `create` on a genuinely fresh ID just inserts, same as `save`.
819        let mut fresh = Record {
820            id: Id::default(),
821            data: Default::default(),
822            expiry_date: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
823        };
824        fresh
825            .data
826            .insert("greeting".to_string(), serde_json::json!("fresh"));
827        let fresh_id = fresh.id;
828        store.create(&mut fresh).await.unwrap();
829        assert_eq!(
830            fresh.id, fresh_id,
831            "a non-colliding create must not change the id"
832        );
833        let loaded = store.load(&fresh_id).await.unwrap().unwrap();
834        assert_eq!(loaded.data["greeting"], serde_json::json!("fresh"));
835
836        // `create` on an *already-taken* id is the real regression guard
837        // for this method's whole reason to exist: unlike `save` (an
838        // upsert, which would silently overwrite the row above), `create`
839        // must detect the collision and regenerate the id rather than
840        // clobbering the existing session.
841        let mut colliding = Record {
842            id: fresh_id,
843            data: Default::default(),
844            expiry_date: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
845        };
846        colliding
847            .data
848            .insert("greeting".to_string(), serde_json::json!("colliding"));
849        store.create(&mut colliding).await.unwrap();
850        assert_ne!(
851            colliding.id, fresh_id,
852            "create must regenerate the id on a real collision, not overwrite the existing row"
853        );
854        // The original session survives untouched.
855        let original = store.load(&fresh_id).await.unwrap().unwrap();
856        assert_eq!(original.data["greeting"], serde_json::json!("fresh"));
857        // The regenerated session was actually created under its new id.
858        let new_one = store.load(&colliding.id).await.unwrap().unwrap();
859        assert_eq!(new_one.data["greeting"], serde_json::json!("colliding"));
860
861        // `take`'s actual regression guard: a bare `session.remove()` marks
862        // the session modified even when the key was never present
863        // (confirmed directly against `tower-sessions-core`'s own
864        // `remove_value`, not just assumed) - avoiding exactly that
865        // phantom-dirty write on a flash-message check with nothing
866        // actually flashed is `take`'s whole reason to exist.
867        let session = Session::new(None, std::sync::Arc::new(store.clone()), None);
868        let absent: Option<String> = take(&session, "success").await.unwrap();
869        assert_eq!(absent, None);
870        assert!(
871            !session.is_modified(),
872            "checking for an absent flash key must not mark the session dirty"
873        );
874
875        // The companion case: when the key *is* present, `take` must still
876        // actually remove it (a flash message read once, not re-shown on
877        // the next page) and report the session as modified, same as a
878        // bare `remove()` would.
879        session.insert("success", "saved!").await.unwrap();
880        let value: Option<String> = take(&session, "success").await.unwrap();
881        assert_eq!(value.as_deref(), Some("saved!"));
882        assert!(session.is_modified());
883        let gone: Option<String> = session.get("success").await.unwrap();
884        assert_eq!(gone, None);
885
886        // A basic sanity check for the production incident this area got
887        // hardened for (see `docs/GOTCHAS.md`): concurrent session writes
888        // against SQLite must succeed, not error with `(code: 5) database
889        // is locked`. This doesn't reproduce the original failure by
890        // itself - on fast local storage with a generous `busy_timeout`,
891        // even the old `max_connections(10)` absorbs a burst this size
892        // without erroring (confirmed directly: reverting `larust_orm::
893        // pool::connect`'s SQLite cap back to 10 and running this same
894        // scenario, even at much higher concurrency, still passed here).
895        // The original incident needed sustained real traffic, and very
896        // likely a slower disk and/or a zero-downtime restart handoff
897        // briefly running two processes' pools against the same file at
898        // once - none of which a single in-process test can cheaply
899        // reproduce. What this test *does* guard is a plain, real
900        // regression: that funneling every SQLite access through one
901        // connection (`max_connections(1)`, the actual fix) doesn't
902        // itself introduce a deadlock or a dropped write under concurrent
903        // callers - a legitimate risk worth a real test, even though the
904        // specific "thundering herd under load" failure mode itself has to
905        // stay a documented-but-unreproduced-in-CI scenario.
906        let concurrent_saves: Vec<_> = (0..32)
907            .map(|i| {
908                let store = store.clone();
909                tokio::spawn(async move {
910                    let mut record = Record {
911                        id: Id::default(),
912                        data: Default::default(),
913                        expiry_date: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
914                    };
915                    record.data.insert("i".to_string(), serde_json::json!(i));
916                    store.save(&record).await
917                })
918            })
919            .collect();
920        for task in concurrent_saves {
921            task.await
922                .expect("task panicked")
923                .expect("concurrent session save must not fail with a locked database");
924        }
925    }
926
927    /// The file-store equivalent of `any_session_store_behaves_correctly_
928    /// across_every_scenario` above - same scenarios, same assertions,
929    /// proving `FileSessionStore` honors the identical `SessionStore`
930    /// contract `AnySessionStore` does (round-trip, expiry filtering on
931    /// read, idempotent delete, and `create`'s real collision detection).
932    /// One function, not several, purely for symmetry with its database
933    /// counterpart - `FileSessionStore` has no shared-singleton
934    /// constraint forcing that shape the way `AnySessionStore`'s tests do,
935    /// but splitting it here while the sibling test stays merged would
936    /// make the two harder to compare side by side.
937    #[tokio::test]
938    async fn file_session_store_behaves_correctly_across_every_scenario() {
939        let dir = tempfile::tempdir().unwrap();
940        let store = FileSessionStore::new(dir.path());
941
942        // A saved session round-trips through load.
943        let mut record = Record {
944            id: Id::default(),
945            data: Default::default(),
946            expiry_date: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
947        };
948        record
949            .data
950            .insert("greeting".to_string(), serde_json::json!("hi"));
951        store.save(&record).await.unwrap();
952        let loaded = store.load(&record.id).await.unwrap().unwrap();
953        assert_eq!(loaded.data["greeting"], serde_json::json!("hi"));
954
955        // An expired session is not loaded, but its file is left on disk
956        // for `delete_expired` to find - not deleted eagerly on read,
957        // matching `AnySessionStore::load`'s identical behavior.
958        let expired = Record {
959            id: Id::default(),
960            data: Default::default(),
961            expiry_date: time::OffsetDateTime::now_utc() - time::Duration::hours(1),
962        };
963        store.save(&expired).await.unwrap();
964        assert!(store.load(&expired.id).await.unwrap().is_none());
965        assert!(
966            dir.path().join(expired.id.to_string()).exists(),
967            "an expired session's file must still exist until delete_expired sweeps it"
968        );
969
970        // delete removes a saved session, and is idempotent - deleting an
971        // already-absent session is still `Ok`, matching `AnySessionStore::
972        // delete`'s own `DELETE ... WHERE id = ?` (a no-op match is still a
973        // successful statement).
974        let doomed = Record {
975            id: Id::default(),
976            data: Default::default(),
977            expiry_date: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
978        };
979        store.save(&doomed).await.unwrap();
980        store.delete(&doomed.id).await.unwrap();
981        assert!(store.load(&doomed.id).await.unwrap().is_none());
982        store.delete(&doomed.id).await.unwrap();
983
984        // `create` on a genuinely fresh ID just inserts, same as `save`.
985        let mut fresh = Record {
986            id: Id::default(),
987            data: Default::default(),
988            expiry_date: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
989        };
990        fresh
991            .data
992            .insert("greeting".to_string(), serde_json::json!("fresh"));
993        let fresh_id = fresh.id;
994        store.create(&mut fresh).await.unwrap();
995        assert_eq!(
996            fresh.id, fresh_id,
997            "a non-colliding create must not change the id"
998        );
999        let loaded = store.load(&fresh_id).await.unwrap().unwrap();
1000        assert_eq!(loaded.data["greeting"], serde_json::json!("fresh"));
1001
1002        // `create` on an *already-taken* id is the real regression guard
1003        // for this method's whole reason to exist - unlike `save` (an
1004        // upsert, which would silently overwrite the file above), `create`
1005        // must detect the collision (`create_new(true)`'s `AlreadyExists`)
1006        // and regenerate the id rather than clobbering the existing
1007        // session.
1008        let mut colliding = Record {
1009            id: fresh_id,
1010            data: Default::default(),
1011            expiry_date: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
1012        };
1013        colliding
1014            .data
1015            .insert("greeting".to_string(), serde_json::json!("colliding"));
1016        store.create(&mut colliding).await.unwrap();
1017        assert_ne!(
1018            colliding.id, fresh_id,
1019            "create must regenerate the id on a real collision, not overwrite the existing file"
1020        );
1021        let original = store.load(&fresh_id).await.unwrap().unwrap();
1022        assert_eq!(original.data["greeting"], serde_json::json!("fresh"));
1023        let new_one = store.load(&colliding.id).await.unwrap().unwrap();
1024        assert_eq!(new_one.data["greeting"], serde_json::json!("colliding"));
1025
1026        // `delete_expired` removes only the expired file, leaving every
1027        // still-valid one (and any stray temp file - see below) alone.
1028        store.delete_expired().await.unwrap();
1029        assert!(!dir.path().join(expired.id.to_string()).exists());
1030        assert!(dir.path().join(fresh_id.to_string()).exists());
1031        assert!(dir.path().join(colliding.id.to_string()).exists());
1032
1033        // A leftover temp file (the shape a crash mid-`write_atomically`
1034        // would leave behind) must not crash `delete_expired`'s scan, and
1035        // must not be mistaken for a session to delete - `is_session_file`'s
1036        // whole reason to exist.
1037        tokio::fs::write(dir.path().join("orphaned.tmp.999.1"), b"not json")
1038            .await
1039            .unwrap();
1040        store.delete_expired().await.unwrap();
1041        assert!(dir.path().join("orphaned.tmp.999.1").exists());
1042
1043        // Concurrent creates (a burst of genuinely new sessions, e.g. many
1044        // simultaneous first-time visitors) must all succeed - proving the
1045        // real, final file's own atomic `create_new(true)` open doesn't
1046        // trip over the others, the file-store analogue of
1047        // `AnySessionStore`'s own concurrent-`save` regression guard above.
1048        let concurrent_creates: Vec<_> = (0..32)
1049            .map(|i| {
1050                let store = store.clone();
1051                tokio::spawn(async move {
1052                    let mut record = Record {
1053                        id: Id::default(),
1054                        data: Default::default(),
1055                        expiry_date: time::OffsetDateTime::now_utc() + time::Duration::hours(1),
1056                    };
1057                    record.data.insert("i".to_string(), serde_json::json!(i));
1058                    store.create(&mut record).await
1059                })
1060            })
1061            .collect();
1062        for task in concurrent_creates {
1063            task.await
1064                .expect("task panicked")
1065                .expect("concurrent session create must not fail");
1066        }
1067    }
1068}