seshcookie 0.1.0

Stateless, encrypted, type-safe session cookies for Rust web applications.
//! Public error types.
//!
//! pattern: Functional Core
//!
//! [`BuildError`] signals misuse at construction time — an empty or too-short secret.
//! It is returned from [`crate::SessionKeys::new`] and related constructors. It is never
//! produced during request handling.
//!
//! [`SessionRejection`] signals an Axum extractor failure — specifically, that a
//! handler asked for a [`crate::Session<T>`] but no matching [`crate::SessionLayer<T>`]
//! was mounted. It maps to HTTP 500 via the `IntoResponse` impl.
//!
//! This module is part of the Functional Core: it contains pure type
//! definitions and `Display` implementations only, with no I/O or runtime
//! side effects. [`SessionRejection`]'s `IntoResponse` impl constructs an
//! `axum_core::response::Response` from a status code and a static string —
//! a pure construction (no network, no clock, no randomness).

use axum_core::response::{IntoResponse, Response};
use http::StatusCode;

/// Errors returned when constructing [`crate::SessionKeys`] or [`crate::SessionLayer`].
///
/// All variants indicate caller misuse at construction time and never occur on the
/// request-handling path.
///
/// ```
/// use seshcookie::{BuildError, SessionKeys};
///
/// assert_eq!(SessionKeys::new(&[]).err(), Some(BuildError::EmptyKey));
/// assert_eq!(
///     SessionKeys::new(&[0u8; 8]).err(),
///     Some(BuildError::ShortKey { len: 8 }),
/// );
/// ```
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum BuildError {
    /// The caller provided an empty secret. At least 16 bytes of high-entropy keying
    /// material are required.
    #[error("secret key must not be empty")]
    EmptyKey,

    /// The caller provided a secret shorter than 16 bytes. The actual length is included
    /// to aid diagnostics.
    #[error("secret must be at least 16 bytes, got {len}")]
    ShortKey {
        /// Number of bytes in the offending secret.
        len: usize,
    },
}

/// Error returned when a [`crate::Session<T>`] extractor cannot find a matching
/// [`crate::SessionLayer<T>`] on the request. This indicates a server-side
/// configuration error: the handler required a session but no middleware was
/// mounted to provide one.
///
/// `IntoResponse` maps this rejection to HTTP 500 with a plain-text body
/// describing the misconfiguration. Handlers that wish to tolerate a missing
/// layer should declare `Option<Session<T>>` instead, which yields `None`
/// without producing this rejection.
///
/// # Example
///
/// ```
/// use seshcookie::SessionRejection;
/// use axum_core::response::IntoResponse;
///
/// let response = SessionRejection::NotMounted.into_response();
/// assert_eq!(response.status(), http::StatusCode::INTERNAL_SERVER_ERROR);
/// ```
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SessionRejection {
    /// No `SessionLayer<T>` was mounted for the requested payload type.
    #[error("SessionLayer<T> is not mounted for this request")]
    NotMounted,
}

impl IntoResponse for SessionRejection {
    fn into_response(self) -> Response {
        match self {
            Self::NotMounted => (
                StatusCode::INTERNAL_SERVER_ERROR,
                "session layer not mounted for this request",
            )
                .into_response(),
        }
    }
}

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

    use http_body_util::BodyExt;

    /// seshcookie-rs.AC5.4: `SessionRejection::NotMounted` maps to HTTP 500
    /// when converted via `IntoResponse`.
    #[test]
    fn not_mounted_into_response_is_http_500_ac5_4() {
        let response = SessionRejection::NotMounted.into_response();
        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
    }

    /// seshcookie-rs.AC5.4: the response body for `NotMounted` carries a
    /// plain-text message that mentions the misconfiguration so an operator
    /// reading the response can identify it. We assert the substring
    /// `"not mounted"` rather than the exact wording so the test isn't
    /// brittle against minor copy edits.
    #[tokio::test]
    async fn not_mounted_response_body_mentions_not_mounted_ac5_4() {
        let response = SessionRejection::NotMounted.into_response();
        let body_bytes = response
            .into_body()
            .collect()
            .await
            .expect("collecting an in-memory body never fails")
            .to_bytes();
        let body_str =
            std::str::from_utf8(&body_bytes).expect("body is constructed from a &'static str");
        assert!(
            body_str.contains("not mounted"),
            "expected body to mention \"not mounted\", got {body_str:?}"
        );
    }

    /// seshcookie-rs.AC5.4: the standard derives — `Debug`, `Clone`,
    /// `PartialEq`, `Eq` — are all present on `SessionRejection`. This is a
    /// compile-time-anchored test: failure of any derive prevents the test
    /// from compiling. The runtime assertions sanity-check `Eq`/`PartialEq`
    /// against `Self` and the `Debug` representation.
    #[test]
    fn not_mounted_has_standard_derives_ac5_4() {
        let original = SessionRejection::NotMounted;
        let cloned = original.clone();
        assert_eq!(original, cloned);
        let debug_repr = format!("{original:?}");
        assert!(
            debug_repr.contains("NotMounted"),
            "Debug representation should name the variant, got {debug_repr:?}"
        );
    }

    /// seshcookie-rs.AC5.4: the `Display` impl (via `thiserror`) produces a
    /// human-readable message identifying the misconfiguration. This is
    /// distinct from the response body text: `Display` is what shows up in
    /// log lines and `Error::source` chains, the response body is what the
    /// HTTP client sees.
    #[test]
    fn not_mounted_display_message_is_useful_ac5_4() {
        let message = format!("{}", SessionRejection::NotMounted);
        assert!(
            message.contains("not mounted") || message.contains("SessionLayer"),
            "Display should describe the missing layer, got {message:?}"
        );
    }
}