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