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}