seshcookie 0.1.0

Stateless, encrypted, type-safe session cookies for Rust web applications.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
//! Axum extractor for the typed session handle.
//!
//! pattern: Imperative Shell
//!
//! This module is the boundary between the synchronous request-extraction
//! machinery (`http::request::Parts::extensions`) and the typed session API
//! exposed to handlers. It performs no business logic of its own: requests
//! land here only after [`crate::SessionLayer`]'s `SessionService` has populated
//! the request extensions with an `Arc<Mutex<SessionState<T>>>`. The extractor's
//! job is to retrieve that handle, hand it to the handler, and let the handler
//! call the async methods that mutate the inner state.
//!
//! Handlers declaring `Session<T>` get [`crate::SessionRejection::NotMounted`]
//! (HTTP 500) when the corresponding [`crate::SessionLayer<T>`] is missing.
//! Handlers declaring `Option<Session<T>>` get `None` instead, and decide
//! locally how to react.
//!
//! # Usage
//!
//! In an Axum handler, declare `session: Session<T>` (required — HTTP 500 if
//! the layer is missing) or `session: Option<Session<T>>` (optional — `None`
//! if the layer is missing). Both forms work with the same `SessionLayer<T>`
//! mounted via `Router::layer`.
//!
//! ```ignore
//! use axum::routing::{get, post};
//! use axum::Router;
//! use seshcookie::{Session, SessionConfig, SessionKeys, SessionLayer};
//!
//! #[derive(Clone, serde::Serialize, serde::Deserialize)]
//! struct User { id: u64, name: String }
//!
//! async fn whoami(session: Option<Session<User>>) -> String {
//!     match session {
//!         Some(s) => match s.get().await {
//!             Some(u) => format!("hello, {}", u.name),
//!             None => "hello, stranger".into(),
//!         },
//!         None => "hello, anon".into(),
//!     }
//! }
//! ```

use std::convert::Infallible;
use std::sync::Arc;

use axum_core::extract::{FromRequestParts, OptionalFromRequestParts};
use http::request::Parts;
use tokio::sync::Mutex;

use crate::error::SessionRejection;
use crate::state::SessionState;

/// Typed session handle.
///
/// A `Session<T>` is a cheap reference-counted handle into per-request
/// session state produced by [`crate::SessionLayer<T>`]. All methods are
/// `async` — they briefly take an internal [`tokio::sync::Mutex`] guard so
/// concurrent reads from the same request never block the runtime thread.
///
/// Cloning a `Session<T>` produces a second handle over the same underlying
/// state (Arc-clone semantics); mutations through one clone are visible
/// through the other within the same request.
///
/// # Notes on `T`
///
/// The response path suppresses no-op cookie rewrites by SHA-256-comparing
/// the candidate serialized payload against the incoming one. For this to
/// work reliably, `serde_json` must produce byte-identical output for equal
/// `T` values on every serialization. For standard `T` (structs, `Vec`,
/// `Option`, nested enums) this is true by construction. **For `T`
/// containing a `HashMap<K, V>` or `HashSet<V>`, field-iteration order is
/// non-deterministic across process restarts**, which can cause the
/// hash-compare to miss and produce spurious `Set-Cookie` emissions on
/// read-only handlers. Prefer `BTreeMap` / `BTreeSet` in session payloads,
/// or implement `Serialize` with a deterministic-order strategy.
pub struct Session<T>(Arc<Mutex<SessionState<T>>>);

impl<T> Clone for Session<T> {
    fn clone(&self) -> Self {
        Self(Arc::clone(&self.0))
    }
}

impl<T> std::fmt::Debug for Session<T> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Report the Arc's pointer for traceability without dereferencing
        // the Mutex. Avoiding the lock here means `Debug` is non-blocking
        // and cannot leak payload bytes into trace logs.
        f.debug_struct("Session")
            .field("state", &Arc::as_ptr(&self.0))
            .finish()
    }
}

impl<T> Session<T> {
    /// Return a clone of the current payload, or `None` if no session is
    /// active (handler didn't call `insert`, or `clear` was invoked, or the
    /// incoming cookie was absent / malformed / expired).
    ///
    /// ```
    /// # use seshcookie::Session;
    /// # async fn example<T: Clone + Send + 'static>(session: Session<T>) -> Option<T> {
    /// session.get().await
    /// # }
    /// ```
    pub async fn get(&self) -> Option<T>
    where
        T: Clone,
    {
        self.0.lock().await.payload.clone()
    }

    /// Replace the session payload. Marks the state as mutated. If the new
    /// value serializes to the same JSON bytes as the incoming cookie's
    /// payload, no `Set-Cookie` is emitted (hash-compare suppression in the
    /// response path).
    ///
    /// ```
    /// # use seshcookie::Session;
    /// # async fn example<T: Send + 'static>(session: Session<T>, value: T) {
    /// session.insert(value).await
    /// # }
    /// ```
    pub async fn insert(&self, value: T) {
        let mut guard = self.0.lock().await;
        guard.payload = Some(value);
        guard.mutated = true;
    }

    /// Remove the current payload, leaving `None`, and return the prior
    /// value if any. If a cookie was present on the request, the response
    /// emits a cookie-delete.
    ///
    /// ```
    /// # use seshcookie::Session;
    /// # async fn example<T: Send + 'static>(session: Session<T>) -> Option<T> {
    /// session.take().await
    /// # }
    /// ```
    pub async fn take(&self) -> Option<T> {
        let mut guard = self.0.lock().await;
        guard.mutated = true;
        guard.payload.take()
    }

    /// Clear the session. Equivalent to [`Session::take`] but discards the
    /// return value.
    ///
    /// ```
    /// # use seshcookie::Session;
    /// # async fn example<T: Send + 'static>(session: Session<T>) {
    /// session.clear().await
    /// # }
    /// ```
    pub async fn clear(&self) {
        let mut guard = self.0.lock().await;
        guard.mutated = true;
        guard.payload = None;
    }

    /// Run `f` with mutable access to the payload. The closure can return a
    /// value of any type; this method returns whatever `f` returned. The
    /// state is always marked as mutated — the hash-compare suppression in
    /// the response path catches no-op closures without the caller needing
    /// to track them.
    ///
    /// ```
    /// # use seshcookie::Session;
    /// # #[derive(Default)] struct Counter(u32);
    /// # async fn example(session: Session<Counter>) -> u32 {
    /// session
    ///     .modify(|p| {
    ///         let c = p.get_or_insert_with(Counter::default);
    ///         c.0 += 1;
    ///         c.0
    ///     })
    ///     .await
    /// # }
    /// ```
    pub async fn modify<R>(&self, f: impl FnOnce(&mut Option<T>) -> R) -> R {
        let mut guard = self.0.lock().await;
        let r = f(&mut guard.payload);
        guard.mutated = true;
        r
    }
}

/// Required-extraction impl. Handlers declaring `session: Session<T>` get
/// this. When the corresponding [`crate::SessionLayer<T>`] has not been
/// mounted on the request, the rejection maps to HTTP 500 via
/// [`SessionRejection::NotMounted`].
impl<S, T> FromRequestParts<S> for Session<T>
where
    S: Send + Sync,
    T: Send + 'static,
{
    type Rejection = SessionRejection;

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        parts
            .extensions
            .get::<Arc<Mutex<SessionState<T>>>>()
            .cloned()
            .map(Session)
            .ok_or(SessionRejection::NotMounted)
    }
}

/// Optional-extraction impl. Handlers declaring `session: Option<Session<T>>`
/// get this. Axum's blanket `FromRequestParts` for `Option<T>` (in
/// `axum-core`) delegates to this trait; `Infallible` as the rejection means
/// the wrapping `Option` is always produced and the handler decides how to
/// react to a missing layer.
impl<S, T> OptionalFromRequestParts<S> for Session<T>
where
    S: Send + Sync,
    T: Send + 'static,
{
    type Rejection = Infallible;

    async fn from_request_parts(
        parts: &mut Parts,
        _state: &S,
    ) -> Result<Option<Self>, Self::Rejection> {
        Ok(parts
            .extensions
            .get::<Arc<Mutex<SessionState<T>>>>()
            .cloned()
            .map(Session))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    use std::time::SystemTime;

    use http::Request;

    /// Test payload mirroring the shape used in `state.rs` tests so the
    /// extractor tests run against a concrete `T` rather than a phantom one.
    #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
    struct UserPayload {
        id: u64,
        name: String,
    }

    fn sample_payload() -> UserPayload {
        UserPayload {
            id: 42,
            name: "alice".into(),
        }
    }

    /// Build a `Session<UserPayload>` directly from a fresh `SessionState`.
    /// The extractor tests don't drive the layer; they construct the
    /// per-request handle the same way `SessionService` does (Arc-wrap,
    /// Mutex-wrap, hand to the extractor).
    fn empty_session_with_payload(payload: Option<UserPayload>) -> Session<UserPayload> {
        let now = SystemTime::UNIX_EPOCH;
        let mut state = SessionState::new_empty(now);
        state.payload = payload;
        Session(Arc::new(Mutex::new(state)))
    }

    /// Insert a state Arc into a fresh `Parts` so the extractor can pick it
    /// up. Returns the Parts together with the Arc — sharing the Arc lets
    /// tests snapshot the inner state after the extractor returns.
    fn parts_with_state(
        payload: Option<UserPayload>,
    ) -> (Parts, Arc<Mutex<SessionState<UserPayload>>>) {
        let now = SystemTime::UNIX_EPOCH;
        let mut state = SessionState::new_empty(now);
        state.payload = payload;
        let arc = Arc::new(Mutex::new(state));
        let req = Request::builder().body(()).unwrap();
        let (mut parts, ()) = req.into_parts();
        parts.extensions.insert(Arc::clone(&arc));
        (parts, arc)
    }

    // --- AC9.1: every handle method is `async` -----------------------------------

    /// seshcookie-rs.AC9.1: `Session::get` is `async`. The `.await` here is
    /// the compile-time proof; the runtime assertion validates the value.
    #[tokio::test]
    async fn session_get_is_async() {
        let payload = sample_payload();
        let session = empty_session_with_payload(Some(payload.clone()));
        assert_eq!(session.get().await, Some(payload));
    }

    /// seshcookie-rs.AC9.1: `Session::insert` is `async`. After inserting,
    /// the value is observable through a second `get().await`.
    #[tokio::test]
    async fn session_insert_is_async() {
        let session = empty_session_with_payload(None);
        let payload = sample_payload();

        session.insert(payload.clone()).await;

        assert_eq!(session.get().await, Some(payload));
        assert!(session.0.lock().await.mutated, "insert must set mutated");
    }

    /// seshcookie-rs.AC9.1: `Session::take` is `async`. The pre-existing
    /// payload is returned, and a subsequent `get().await` yields `None`.
    #[tokio::test]
    async fn session_take_is_async() {
        let payload = sample_payload();
        let session = empty_session_with_payload(Some(payload.clone()));

        assert_eq!(session.take().await, Some(payload));
        assert_eq!(session.get().await, None);
        assert!(session.0.lock().await.mutated, "take must set mutated");
    }

    /// seshcookie-rs.AC9.1: `Session::clear` is `async`. The payload is
    /// dropped without surfacing a return value.
    #[tokio::test]
    async fn session_clear_is_async() {
        let session = empty_session_with_payload(Some(sample_payload()));

        session.clear().await;

        assert_eq!(session.get().await, None);
        assert!(session.0.lock().await.mutated, "clear must set mutated");
    }

    /// seshcookie-rs.AC9.1: `Session::modify` is `async`. The closure
    /// receives `&mut Option<T>` and its return value bubbles out.
    #[tokio::test]
    async fn session_modify_is_async() {
        let payload = sample_payload();
        let session = empty_session_with_payload(Some(payload.clone()));

        let observed_id: u64 = session
            .modify(|p| {
                let user = p.as_mut().expect("modify sees the prior payload");
                user.id += 1;
                user.id
            })
            .await;

        assert_eq!(observed_id, payload.id + 1);
        assert_eq!(
            session.get().await.map(|u| u.id),
            Some(payload.id + 1),
            "modify must persist its mutation"
        );
        assert!(session.0.lock().await.mutated, "modify must set mutated");
    }

    // --- AC5.1: required-extraction success --------------------------------------

    /// seshcookie-rs.AC5.1: a request whose extensions carry the
    /// `Arc<Mutex<SessionState<T>>>` resolves the `FromRequestParts` impl to
    /// a working handle whose `get().await` returns the inserted payload.
    #[tokio::test]
    async fn from_request_parts_returns_session_when_extension_present_seshcookie_rs_ac5_1() {
        let payload = sample_payload();
        let (mut parts, _arc) = parts_with_state(Some(payload.clone()));

        let session =
            <Session<UserPayload> as FromRequestParts<()>>::from_request_parts(&mut parts, &())
                .await
                .expect("extension is present, extraction must succeed");

        assert_eq!(session.get().await, Some(payload));
    }

    /// seshcookie-rs.AC5.1 (failure side): the same `FromRequestParts` impl
    /// returns `SessionRejection::NotMounted` when no extension is present.
    /// AC5.4 covers the response shape; this test pins the error variant.
    #[tokio::test]
    async fn from_request_parts_returns_not_mounted_when_extension_missing_seshcookie_rs_ac5_1() {
        let req = Request::builder().body(()).unwrap();
        let (mut parts, ()) = req.into_parts();

        let result =
            <Session<UserPayload> as FromRequestParts<()>>::from_request_parts(&mut parts, &())
                .await;

        assert_eq!(result.unwrap_err(), SessionRejection::NotMounted);
    }

    // --- AC5.3: optional-extraction returns None on missing layer ----------------

    /// seshcookie-rs.AC5.3: `Option<Session<T>>` resolves to `None` when no
    /// `SessionLayer<T>` is mounted (no matching extension). The rejection
    /// type is `Infallible`, so `Result::Err` is statically impossible.
    #[tokio::test]
    async fn optional_from_request_parts_returns_none_when_extension_missing_seshcookie_rs_ac5_3() {
        let req = Request::builder().body(()).unwrap();
        let (mut parts, ()) = req.into_parts();

        let result = <Session<UserPayload> as OptionalFromRequestParts<()>>::from_request_parts(
            &mut parts,
            &(),
        )
        .await;

        match result {
            Ok(None) => {}
            Ok(Some(_)) => panic!("missing extension must yield None"),
            Err(infallible) => match infallible {},
        }
    }

    /// seshcookie-rs.AC5.3 (companion): `Option<Session<T>>` resolves to
    /// `Some(_)` when the extension *is* present, so handlers can downgrade
    /// from the required form to the optional form without losing access.
    #[tokio::test]
    async fn optional_from_request_parts_returns_some_when_extension_present_seshcookie_rs_ac5_3() {
        let payload = sample_payload();
        let (mut parts, _arc) = parts_with_state(Some(payload.clone()));

        let result = <Session<UserPayload> as OptionalFromRequestParts<()>>::from_request_parts(
            &mut parts,
            &(),
        )
        .await
        .expect("Infallible cannot be constructed");

        let session = result.expect("extension was inserted, must yield Some");
        assert_eq!(session.get().await, Some(payload));
    }

    // --- AC5.5: two extractions in one handler share state -----------------------

    /// seshcookie-rs.AC5.5: extracting `Session<T>` twice in the same
    /// handler yields two handles that share underlying state. A mutation
    /// through one is visible through the other.
    #[tokio::test]
    async fn two_extractions_share_underlying_state_seshcookie_rs_ac5_5() {
        let (mut parts, _arc) = parts_with_state(None);

        let s1 =
            <Session<UserPayload> as FromRequestParts<()>>::from_request_parts(&mut parts, &())
                .await
                .expect("first extraction must succeed");
        let s2 =
            <Session<UserPayload> as FromRequestParts<()>>::from_request_parts(&mut parts, &())
                .await
                .expect("second extraction must succeed");

        let payload = sample_payload();
        s1.insert(payload.clone()).await;
        assert_eq!(
            s2.get().await,
            Some(payload),
            "second handle must see s1's insert"
        );

        s2.take().await;
        assert_eq!(s1.get().await, None, "first handle must see s2's take");
    }

    /// seshcookie-rs.AC5.5: explicit `Clone` on `Session<T>` produces a
    /// shared-state handle. Same Arc-clone semantics as two extractions.
    #[tokio::test]
    async fn clone_produces_shared_state_handle_seshcookie_rs_ac5_5() {
        let s1 = empty_session_with_payload(None);
        let s2 = s1.clone();

        let payload = sample_payload();
        s1.insert(payload.clone()).await;

        assert_eq!(s2.get().await, Some(payload));
    }

    // --- Debug impl never locks the mutex ----------------------------------------

    /// `Session::fmt` must not lock the inner mutex — otherwise a session
    /// being formatted concurrently with a held lock would deadlock the
    /// runtime thread. We hold the lock for the duration of the format
    /// call; reaching the assertion proves the format completed without
    /// trying to acquire it.
    #[tokio::test]
    async fn debug_does_not_lock_mutex() {
        let session = empty_session_with_payload(Some(sample_payload()));
        let guard = session.0.lock().await;

        let rendered = format!("{session:?}");

        assert!(
            rendered.contains("Session"),
            "Debug must name the type: got {rendered:?}"
        );
        assert!(
            !rendered.contains("alice"),
            "payload must never appear in Debug output: got {rendered:?}"
        );
        // Hold the guard until after the format call so a buggy `Debug`
        // that tried to lock would deadlock instead of racing.
        drop(guard);
    }
}