Skip to main content

rust_web_server/session/
mod.rs

1//! Server-side session management.
2//!
3//! Three session store implementations are provided:
4//!
5//! * [`SessionStore`] — in-process `HashMap`; fast, zero-config, but sessions
6//!   are lost on restart. Good for single-instance deployments.
7//! * [`DbSessionStore`] — backed by the model-layer [`DbPool`]; sessions
8//!   survive restarts and are shared across multiple processes that use the
9//!   same database. Requires a `model-sqlite`, `model-postgres`, or
10//!   `model-mysql` feature.
11//! * [`RedisSessionStore`] — backed by a Redis server via a hand-rolled RESP
12//!   client; scales horizontally with no shared database needed. Requires a
13//!   running Redis server.
14//!
15//! All three expose the same public API: `create`, `create_with_id`, `load`,
16//! `save`, `destroy`, `purge_expired`, `len`, `is_empty`.
17//!
18//! [`Session`] holds the key/value data for one session. Retrieve it with
19//! the store's `load`, mutate it, then persist changes with `save`.
20//!
21//! Helper functions [`session_id_from_request`], [`session_cookie`], and
22//! [`destroy_cookie`] translate between the HTTP cookie layer and the store.
23//!
24//! # Security note
25//!
26//! Session IDs are generated from a non-cryptographic hash of the system
27//! clock and an atomic counter. Sufficient for most internal applications.
28//! For public-facing services requiring unpredictable IDs, supply your own
29//! CSPRNG via [`SessionStore::create_with_id`].
30//!
31//! # Example
32//!
33//! ```rust,no_run
34//! use rust_web_server::app::App;
35//! use rust_web_server::core::New;
36//! use rust_web_server::session::{self, SessionStore};
37//! use rust_web_server::header::Header;
38//! use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
39//!
40//! struct State { sessions: SessionStore }
41//!
42//! let app = App::with_state(State { sessions: SessionStore::new(3600) })
43//!     .post("/login", |req, _params, _conn, state| {
44//!         // verify credentials …
45//!         let mut sess = state.sessions.create();
46//!         sess.set("user_id", "42");
47//!         state.sessions.save(&sess);
48//!
49//!         let mut r = Response::new();
50//!         r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
51//!         r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
52//!         r.headers.push(Header {
53//!             name: "Set-Cookie".to_string(),
54//!             value: session::session_cookie(&sess.id, "sid", 3600),
55//!         });
56//!         r
57//!     })
58//!     .get("/profile", |req, _params, _conn, state| {
59//!         let mut r = Response::new();
60//!         let sid = match session::session_id_from_request(&req, "sid") {
61//!             Some(id) => id,
62//!             None => {
63//!                 r.status_code = *STATUS_CODE_REASON_PHRASE.n401_unauthorized.status_code;
64//!                 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n401_unauthorized.reason_phrase.to_string();
65//!                 return r;
66//!             }
67//!         };
68//!         let sess = match state.sessions.load(&sid) {
69//!             Some(s) => s,
70//!             None => {
71//!                 r.status_code = *STATUS_CODE_REASON_PHRASE.n401_unauthorized.status_code;
72//!                 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n401_unauthorized.reason_phrase.to_string();
73//!                 return r;
74//!             }
75//!         };
76//!         let user_id = sess.get("user_id").unwrap_or("guest");
77//!         r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
78//!         r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
79//!         r
80//!     });
81//! ```
82
83#[cfg(test)]
84mod tests;
85
86use std::collections::HashMap;
87use std::sync::atomic::{AtomicU64, Ordering};
88use std::sync::{Arc, Mutex};
89use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
90
91use crate::cookie::{CookieJar, SetCookie};
92use crate::request::Request;
93
94// ── ID generation ─────────────────────────────────────────────────────────────
95
96static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
97
98fn generate_id() -> String {
99    let nanos = SystemTime::now()
100        .duration_since(UNIX_EPOCH)
101        .map(|d| d.as_nanos() as u64)
102        .unwrap_or(0);
103    let count = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
104    // splitmix64 finalizer applied to two independent seeds
105    let mut x = nanos ^ count.wrapping_mul(0x9e3779b97f4a7c15);
106    x ^= x >> 30;
107    x = x.wrapping_mul(0xbf58476d1ce4e5b9);
108    x ^= x >> 27;
109    x = x.wrapping_mul(0x94d049bb133111eb);
110    x ^= x >> 31;
111    let mut y = count ^ nanos.wrapping_mul(0x517cc1b727220a95);
112    y ^= y >> 30;
113    y = y.wrapping_mul(0xbf58476d1ce4e5b9);
114    y ^= y >> 27;
115    y = y.wrapping_mul(0x94d049bb133111eb);
116    y ^= y >> 31;
117    format!("{:016x}{:016x}", x, y)
118}
119
120// ── Session ───────────────────────────────────────────────────────────────────
121
122/// Data for a single session, keyed by [`Session::id`].
123///
124/// After mutating the session, call [`SessionStore::save`] to persist the
125/// changes back to the store.
126pub struct Session {
127    /// Opaque session identifier — store this in the client cookie.
128    pub id: String,
129    pub(crate) data: HashMap<String, String>,
130}
131
132impl Session {
133    /// Return the value for `key`, or `None` if absent.
134    pub fn get(&self, key: &str) -> Option<&str> {
135        self.data.get(key).map(String::as_str)
136    }
137
138    /// Insert or update `key` with `value`.
139    pub fn set(&mut self, key: &str, value: impl Into<String>) {
140        self.data.insert(key.to_string(), value.into());
141    }
142
143    /// Remove `key`. No-op if absent.
144    pub fn remove(&mut self, key: &str) {
145        self.data.remove(key);
146    }
147
148    /// Return `true` if `key` is present.
149    pub fn contains(&self, key: &str) -> bool {
150        self.data.contains_key(key)
151    }
152}
153
154// ── SessionStore ──────────────────────────────────────────────────────────────
155
156struct Entry {
157    data: HashMap<String, String>,
158    expires_at: Instant,
159}
160
161struct Inner {
162    sessions: HashMap<String, Entry>,
163}
164
165/// Thread-safe in-memory session store with TTL-based expiry.
166///
167/// Cloning is cheap — all clones share the same backing map via `Arc`.
168/// Place one instance in your application state and share it across handlers.
169pub struct SessionStore {
170    inner: Arc<Mutex<Inner>>,
171    ttl: Duration,
172}
173
174impl Clone for SessionStore {
175    fn clone(&self) -> Self {
176        SessionStore { inner: Arc::clone(&self.inner), ttl: self.ttl }
177    }
178}
179
180impl SessionStore {
181    /// Create a new store where sessions expire `ttl_secs` seconds after
182    /// creation.
183    pub fn new(ttl_secs: u64) -> Self {
184        SessionStore {
185            inner: Arc::new(Mutex::new(Inner { sessions: HashMap::new() })),
186            ttl: Duration::from_secs(ttl_secs),
187        }
188    }
189
190    /// Create a new empty session with a generated ID, insert it into the
191    /// store, and return it. Mutate the session then call [`save`][Self::save].
192    pub fn create(&self) -> Session {
193        self.create_with_id(generate_id())
194    }
195
196    /// Create a new empty session using `id` (caller supplies the value,
197    /// e.g. from a CSPRNG). Inserts the session and returns it.
198    pub fn create_with_id(&self, id: String) -> Session {
199        let entry = Entry {
200            data: HashMap::new(),
201            expires_at: Instant::now() + self.ttl,
202        };
203        self.inner.lock().unwrap().sessions.insert(id.clone(), entry);
204        Session { id, data: HashMap::new() }
205    }
206
207    /// Load a session by ID. Returns `None` if unknown or expired.
208    pub fn load(&self, id: &str) -> Option<Session> {
209        let inner = self.inner.lock().unwrap();
210        let entry = inner.sessions.get(id)?;
211        if Instant::now() > entry.expires_at {
212            return None;
213        }
214        Some(Session { id: id.to_string(), data: entry.data.clone() })
215    }
216
217    /// Persist a session's data back to the store. No-op if the session ID
218    /// is no longer present (e.g. already destroyed or expired and purged).
219    pub fn save(&self, session: &Session) {
220        let mut inner = self.inner.lock().unwrap();
221        if let Some(entry) = inner.sessions.get_mut(&session.id) {
222            entry.data = session.data.clone();
223        }
224    }
225
226    /// Delete a session immediately. Also clear the client cookie using
227    /// [`destroy_cookie`].
228    pub fn destroy(&self, id: &str) {
229        self.inner.lock().unwrap().sessions.remove(id);
230    }
231
232    /// Remove all sessions whose TTL has elapsed. Call periodically to
233    /// reclaim memory (e.g. once per minute from a background thread).
234    pub fn purge_expired(&self) {
235        let now = Instant::now();
236        self.inner.lock().unwrap().sessions.retain(|_, e| e.expires_at > now);
237    }
238
239    /// Number of sessions in the store, including expired but not yet purged.
240    pub fn len(&self) -> usize {
241        self.inner.lock().unwrap().sessions.len()
242    }
243
244    /// `true` if the store contains no sessions.
245    pub fn is_empty(&self) -> bool {
246        self.len() == 0
247    }
248}
249
250// ── Cookie helpers ────────────────────────────────────────────────────────────
251
252/// Extract the session ID from the named cookie in a request's `Cookie`
253/// header. Returns `None` if the header is absent or the cookie is missing.
254pub fn session_id_from_request(request: &Request, cookie_name: &str) -> Option<String> {
255    let header = request.get_header("Cookie".to_string())?;
256    let jar = CookieJar::parse(&header.value);
257    jar.get(cookie_name).map(|c| c.value.clone())
258}
259
260/// Build a `Set-Cookie` header value that stores `session_id` in
261/// `cookie_name` with `HttpOnly`, `SameSite=Lax`, `Path=/`, and `Max-Age`.
262///
263/// # Example
264///
265/// ```rust,no_run
266/// use rust_web_server::{session, header::Header};
267///
268/// let value = session::session_cookie("abc123", "sid", 3600);
269/// // response.headers.push(Header { name: "Set-Cookie".to_string(), value });
270/// ```
271pub fn session_cookie(session_id: &str, cookie_name: &str, ttl_secs: u64) -> String {
272    SetCookie::new(cookie_name, session_id)
273        .path("/")
274        .http_only()
275        .same_site("Lax")
276        .max_age(ttl_secs as i64)
277        .build()
278}
279
280/// Build a `Set-Cookie` header value that clears `cookie_name` in the
281/// browser (`Max-Age=0`). Use after calling [`SessionStore::destroy`].
282pub fn destroy_cookie(cookie_name: &str) -> String {
283    SetCookie::new(cookie_name, "").path("/").max_age(0).build()
284}
285
286// ── DbSessionStore ────────────────────────────────────────────────────────────
287
288/// Session store backed by a relational database via the model-layer
289/// [`DbPool`][crate::model::DbPool].
290///
291/// Sessions survive process restarts and are visible to every process that
292/// connects to the same database, making this suitable for multi-instance
293/// deployments and zero-downtime restarts.
294///
295/// The first call to [`DbSessionStore::new`] creates the `rws_sessions` table
296/// if it does not already exist:
297///
298/// ```sql
299/// CREATE TABLE IF NOT EXISTS rws_sessions (
300///     id         TEXT    PRIMARY KEY,
301///     data       TEXT    NOT NULL DEFAULT '',
302///     expires_at INTEGER NOT NULL
303/// )
304/// ```
305///
306/// Session data is serialized as a URL-encoded string
307/// (`key1=val1&key2=val2`). Expired sessions are **not** removed
308/// automatically — call [`purge_expired`][DbSessionStore::purge_expired]
309/// periodically.
310///
311/// All methods are `async fn` — `model-*` features imply the `http2` feature
312/// which provides a tokio runtime.
313///
314/// # Example
315///
316/// ```rust,no_run
317/// # #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
318/// # async fn example() -> Result<(), rust_web_server::model::DbError> {
319/// use rust_web_server::model::DbPool;
320/// use rust_web_server::session::DbSessionStore;
321///
322/// let pool = DbPool::memory().await?;
323/// let store = DbSessionStore::new(pool, 3600).await?;
324///
325/// let mut sess = store.create().await?;
326/// sess.set("user_id", "42");
327/// store.save(&sess).await?;
328///
329/// let loaded = store.load(&sess.id).await?.unwrap();
330/// assert_eq!(Some("42"), loaded.get("user_id"));
331/// # Ok(())
332/// # }
333/// ```
334#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
335pub struct DbSessionStore {
336    pool: Arc<crate::model::DbPool>,
337    ttl: Duration,
338}
339
340#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
341impl Clone for DbSessionStore {
342    fn clone(&self) -> Self {
343        DbSessionStore { pool: Arc::clone(&self.pool), ttl: self.ttl }
344    }
345}
346
347#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
348impl DbSessionStore {
349    /// Open (or reuse) a `DbSessionStore` backed by `pool`.
350    ///
351    /// Creates the `rws_sessions` table on the first call if it is absent.
352    /// Returns `Err` if the DDL fails.
353    pub async fn new(pool: crate::model::DbPool, ttl_secs: u64) -> Result<Self, crate::model::DbError> {
354        let store = DbSessionStore {
355            pool: Arc::new(pool),
356            ttl: Duration::from_secs(ttl_secs),
357        };
358        store.ensure_table().await?;
359        Ok(store)
360    }
361
362    async fn ensure_table(&self) -> Result<(), crate::model::DbError> {
363        self.pool.execute(
364            "CREATE TABLE IF NOT EXISTS rws_sessions \
365             (id TEXT PRIMARY KEY, data TEXT NOT NULL DEFAULT '', expires_at INTEGER NOT NULL)",
366            &[],
367        ).await?;
368        Ok(())
369    }
370
371    fn now_epoch() -> i64 {
372        SystemTime::now()
373            .duration_since(UNIX_EPOCH)
374            .map(|d| d.as_secs() as i64)
375            .unwrap_or(0)
376    }
377
378    fn serialize(data: &HashMap<String, String>) -> String {
379        crate::url::URL::build_query(data.clone())
380    }
381
382    fn deserialize(s: &str) -> HashMap<String, String> {
383        crate::url::URL::parse_query(s)
384    }
385
386    /// Create a new empty session and persist it immediately.
387    pub async fn create(&self) -> Result<Session, crate::model::DbError> {
388        self.create_with_id(generate_id()).await
389    }
390
391    /// Create a new empty session with a caller-supplied ID and persist it.
392    pub async fn create_with_id(&self, id: String) -> Result<Session, crate::model::DbError> {
393        let expires_at = Self::now_epoch() + self.ttl.as_secs() as i64;
394        self.pool.execute(
395            "INSERT INTO rws_sessions (id, data, expires_at) VALUES (?, ?, ?)",
396            &[
397                crate::model::Value::Text(id.clone()),
398                crate::model::Value::Text(String::new()),
399                crate::model::Value::Int(expires_at),
400            ],
401        ).await?;
402        Ok(Session { id, data: HashMap::new() })
403    }
404
405    /// Load a session by ID. Returns `None` if unknown or expired.
406    pub async fn load(&self, id: &str) -> Result<Option<Session>, crate::model::DbError> {
407        let now = Self::now_epoch();
408        let rows = self.pool.query_rows(
409            "SELECT data FROM rws_sessions WHERE id = ? AND expires_at > ?",
410            &[
411                crate::model::Value::Text(id.to_string()),
412                crate::model::Value::Int(now),
413            ],
414        ).await?;
415        if rows.is_empty() {
416            return Ok(None);
417        }
418        let raw: String = rows[0].get("data")?;
419        Ok(Some(Session { id: id.to_string(), data: Self::deserialize(&raw) }))
420    }
421
422    /// Persist a session's data back to the store.
423    pub async fn save(&self, session: &Session) -> Result<(), crate::model::DbError> {
424        self.pool.execute(
425            "UPDATE rws_sessions SET data = ? WHERE id = ?",
426            &[
427                crate::model::Value::Text(Self::serialize(&session.data)),
428                crate::model::Value::Text(session.id.clone()),
429            ],
430        ).await?;
431        Ok(())
432    }
433
434    /// Delete a session immediately.
435    pub async fn destroy(&self, id: &str) -> Result<(), crate::model::DbError> {
436        self.pool.execute(
437            "DELETE FROM rws_sessions WHERE id = ?",
438            &[crate::model::Value::Text(id.to_string())],
439        ).await?;
440        Ok(())
441    }
442
443    /// Delete all sessions whose TTL has elapsed.
444    pub async fn purge_expired(&self) -> Result<(), crate::model::DbError> {
445        let now = Self::now_epoch();
446        self.pool.execute(
447            "DELETE FROM rws_sessions WHERE expires_at <= ?",
448            &[crate::model::Value::Int(now)],
449        ).await?;
450        Ok(())
451    }
452
453    /// Total number of sessions in the store, including expired ones not yet purged.
454    pub async fn len(&self) -> Result<usize, crate::model::DbError> {
455        let rows = self.pool.query_rows("SELECT COUNT(*) AS n FROM rws_sessions", &[]).await?;
456        if rows.is_empty() {
457            return Ok(0);
458        }
459        let n: i64 = rows[0].get("n")?;
460        Ok(n as usize)
461    }
462
463    /// `true` if the store contains no sessions.
464    pub async fn is_empty(&self) -> Result<bool, crate::model::DbError> {
465        Ok(self.len().await? == 0)
466    }
467}
468
469// ── RedisSessionStore ─────────────────────────────────────────────────────────
470
471/// A minimal RESP v2 client for issuing Redis commands.
472///
473/// Reconnects automatically when the connection is dropped.
474pub struct RespConn {
475    addr: String,
476    password: Option<String>,
477    stream: Mutex<Option<std::net::TcpStream>>,
478}
479
480impl RespConn {
481    fn new(addr: impl Into<String>, password: Option<String>) -> Self {
482        RespConn { addr: addr.into(), password, stream: Mutex::new(None) }
483    }
484
485    fn connect(&self) -> std::io::Result<std::net::TcpStream> {
486        let stream = std::net::TcpStream::connect(&self.addr)?;
487        stream.set_read_timeout(Some(Duration::from_secs(5)))?;
488        stream.set_write_timeout(Some(Duration::from_secs(5)))?;
489        Ok(stream)
490    }
491
492    /// Send a Redis command (array of byte slices) and return the raw reply.
493    fn cmd(&self, args: &[&[u8]]) -> std::io::Result<RespReply> {
494        use std::io::Write;
495        let mut guard = self.stream.lock().unwrap();
496        // Lazy connect / reconnect
497        if guard.is_none() {
498            let mut s = self.connect()?;
499            if let Some(ref pw) = self.password {
500                let auth_frame = resp_array(&[b"AUTH", pw.as_bytes()]);
501                s.write_all(&auth_frame)?;
502                read_reply(&mut s)?; // consume +OK
503            }
504            *guard = Some(s);
505        }
506        let frame = resp_array(args);
507        let stream = guard.as_mut().unwrap();
508        if stream.write_all(&frame).is_err() {
509            // Connection broke — drop and retry once
510            *guard = None;
511            drop(guard);
512            return self.cmd(args);
513        }
514        read_reply(stream)
515    }
516}
517
518/// A decoded RESP reply.
519enum RespReply {
520    Ok,
521    Int(i64),
522    Bulk(Option<Vec<u8>>),
523    Error(String),
524}
525
526fn resp_array(args: &[&[u8]]) -> Vec<u8> {
527    let mut out = format!("*{}\r\n", args.len()).into_bytes();
528    for arg in args {
529        out.extend_from_slice(format!("${}\r\n", arg.len()).as_bytes());
530        out.extend_from_slice(arg);
531        out.extend_from_slice(b"\r\n");
532    }
533    out
534}
535
536fn read_reply(stream: &mut std::net::TcpStream) -> std::io::Result<RespReply> {
537    use std::io::{BufRead, BufReader, Read};
538    let mut reader = BufReader::new(stream);
539    let mut line = String::new();
540    reader.read_line(&mut line)?;
541    let line = line.trim_end_matches("\r\n");
542    match line.chars().next() {
543        Some('+') => Ok(RespReply::Ok),
544        Some(':') => {
545            let n = line[1..].parse::<i64>().unwrap_or(0);
546            Ok(RespReply::Int(n))
547        }
548        Some('-') => Ok(RespReply::Error(line[1..].to_string())),
549        Some('$') => {
550            let len = line[1..].parse::<i64>().unwrap_or(-1);
551            if len < 0 {
552                return Ok(RespReply::Bulk(None));
553            }
554            let mut buf = vec![0u8; len as usize + 2]; // +2 for \r\n
555            reader.read_exact(&mut buf)?;
556            buf.truncate(len as usize);
557            Ok(RespReply::Bulk(Some(buf)))
558        }
559        _ => Ok(RespReply::Ok), // ignore arrays etc.
560    }
561}
562
563/// Session store backed by a Redis server.
564///
565/// Sessions are stored as Redis strings keyed by `rws:sess:{id}` and given
566/// a Redis TTL via `SET … EX`. Expired sessions are removed automatically by
567/// Redis — no `purge_expired` sweep is needed.
568///
569/// Cloning is cheap — all clones share the same underlying TCP connection
570/// (one persistent connection per `RedisSessionStore` instance).
571///
572/// # Connection
573///
574/// Specify the server address as `host:port` (e.g. `"127.0.0.1:6379"`).
575/// Pass `Some("password")` for Redis servers that require AUTH.
576/// Use [`RedisSessionStore::from_env`] to read connection details from
577/// `RWS_REDIS_HOST`, `RWS_REDIS_PORT`, and `RWS_REDIS_PASSWORD`.
578///
579/// # Example
580///
581/// ```rust,no_run
582/// use rust_web_server::session::RedisSessionStore;
583///
584/// let store = RedisSessionStore::new("127.0.0.1:6379", None, 3600);
585///
586/// let mut sess = store.create().expect("create session");
587/// sess.set("user_id", "42");
588/// store.save(&sess).expect("save session");
589///
590/// let loaded = store.load(&sess.id).expect("load session").unwrap();
591/// assert_eq!(Some("42"), loaded.get("user_id"));
592/// ```
593pub struct RedisSessionStore {
594    conn: Arc<RespConn>,
595    ttl: u64,
596}
597
598impl Clone for RedisSessionStore {
599    fn clone(&self) -> Self {
600        RedisSessionStore { conn: Arc::clone(&self.conn), ttl: self.ttl }
601    }
602}
603
604impl RedisSessionStore {
605    /// Create a store that connects to `addr` (e.g. `"127.0.0.1:6379"`).
606    /// `password` is passed to Redis `AUTH` if `Some`.
607    pub fn new(addr: impl Into<String>, password: Option<String>, ttl_secs: u64) -> Self {
608        RedisSessionStore {
609            conn: Arc::new(RespConn::new(addr, password)),
610            ttl: ttl_secs,
611        }
612    }
613
614    /// Build a store from environment variables:
615    /// - `RWS_REDIS_HOST` (default `127.0.0.1`)
616    /// - `RWS_REDIS_PORT` (default `6379`)
617    /// - `RWS_REDIS_PASSWORD` (optional)
618    /// - `RWS_REDIS_TTL_SECS` (default `3600`)
619    pub fn from_env() -> Self {
620        let host = std::env::var("RWS_REDIS_HOST").unwrap_or_else(|_| "127.0.0.1".into());
621        let port = std::env::var("RWS_REDIS_PORT").unwrap_or_else(|_| "6379".into());
622        let addr = format!("{}:{}", host, port);
623        let password = std::env::var("RWS_REDIS_PASSWORD").ok();
624        let ttl = std::env::var("RWS_REDIS_TTL_SECS")
625            .ok()
626            .and_then(|v| v.parse().ok())
627            .unwrap_or(3600u64);
628        Self::new(addr, password, ttl)
629    }
630
631    fn key(id: &str) -> Vec<u8> {
632        format!("rws:sess:{}", id).into_bytes()
633    }
634
635    fn serialize(data: &HashMap<String, String>) -> Vec<u8> {
636        crate::url::URL::build_query(data.clone()).into_bytes()
637    }
638
639    fn deserialize(bytes: Vec<u8>) -> HashMap<String, String> {
640        let s = String::from_utf8(bytes).unwrap_or_default();
641        crate::url::URL::parse_query(&s)
642    }
643
644    /// Create a new empty session and persist it to Redis.
645    pub fn create(&self) -> std::io::Result<Session> {
646        self.create_with_id(generate_id())
647    }
648
649    /// Create a new empty session with a caller-supplied ID and persist it.
650    pub fn create_with_id(&self, id: String) -> std::io::Result<Session> {
651        let ttl_str = self.ttl.to_string();
652        self.conn.cmd(&[
653            b"SET",
654            &Self::key(&id),
655            b"",
656            b"EX",
657            ttl_str.as_bytes(),
658        ])?;
659        Ok(Session { id, data: HashMap::new() })
660    }
661
662    /// Load a session by ID. Returns `None` if unknown or expired.
663    pub fn load(&self, id: &str) -> std::io::Result<Option<Session>> {
664        match self.conn.cmd(&[b"GET", &Self::key(id)])? {
665            RespReply::Bulk(Some(bytes)) => {
666                Ok(Some(Session { id: id.to_string(), data: Self::deserialize(bytes) }))
667            }
668            _ => Ok(None),
669        }
670    }
671
672    /// Persist a session's data back to Redis, resetting the TTL.
673    pub fn save(&self, session: &Session) -> std::io::Result<()> {
674        let ttl_str = self.ttl.to_string();
675        let data = Self::serialize(&session.data);
676        self.conn.cmd(&[
677            b"SET",
678            &Self::key(&session.id),
679            &data,
680            b"EX",
681            ttl_str.as_bytes(),
682        ])?;
683        Ok(())
684    }
685
686    /// Delete a session immediately.
687    pub fn destroy(&self, id: &str) -> std::io::Result<()> {
688        self.conn.cmd(&[b"DEL", &Self::key(id)])?;
689        Ok(())
690    }
691
692    /// No-op — Redis expiry removes sessions automatically.
693    pub fn purge_expired(&self) {}
694
695    /// Total number of keys in the Redis database.
696    ///
697    /// Uses `DBSIZE`, which counts *all* keys, not just session keys.
698    /// Useful as a rough indicator; not exact for mixed-use Redis instances.
699    pub fn len(&self) -> std::io::Result<usize> {
700        match self.conn.cmd(&[b"DBSIZE"])? {
701            RespReply::Int(n) => Ok(n as usize),
702            _ => Ok(0),
703        }
704    }
705
706    /// `true` if `DBSIZE` returns 0.
707    pub fn is_empty(&self) -> std::io::Result<bool> {
708        Ok(self.len()? == 0)
709    }
710}