resuma 1.3.1

Resuma — resumable SSR Rust web framework: zero hydration, islands, server actions, Flow (Axum).
Documentation
//! Cookie helpers for session auth and PRG responses.
//!
//! Prefer setting session cookies via [`crate::Redirect::with_cookie`] on
//! `#[submit]` / `#[server]` returns so the browser receives `Set-Cookie`
//! (HttpOnly) instead of `document.cookie`.

use std::fmt;

/// SameSite attribute for [`CookieOptions`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SameSite {
    #[default]
    Lax,
    Strict,
    None,
}

impl fmt::Display for SameSite {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(match self {
            Self::Lax => "Lax",
            Self::Strict => "Strict",
            Self::None => "None",
        })
    }
}

/// Options for [`set_cookie`].
#[derive(Debug, Clone)]
pub struct CookieOptions {
    pub path: String,
    pub max_age: Option<i64>,
    pub http_only: bool,
    pub secure: bool,
    pub same_site: SameSite,
}

impl Default for CookieOptions {
    fn default() -> Self {
        Self {
            path: "/".into(),
            max_age: None,
            http_only: true,
            secure: false,
            same_site: SameSite::Lax,
        }
    }
}

impl CookieOptions {
    pub fn session(max_age_secs: i64) -> Self {
        Self {
            max_age: Some(max_age_secs),
            ..Default::default()
        }
    }

    pub fn secure(mut self, secure: bool) -> Self {
        self.secure = secure;
        self
    }

    pub fn same_site(mut self, same_site: SameSite) -> Self {
        self.same_site = same_site;
        self
    }

    pub fn http_only(mut self, http_only: bool) -> Self {
        self.http_only = http_only;
        self
    }

    pub fn path(mut self, path: impl Into<String>) -> Self {
        self.path = path.into();
        self
    }
}

fn is_safe_cookie_name(name: &str) -> bool {
    !name.is_empty()
        && name.len() <= 128
        && name
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
}

fn is_safe_cookie_value(value: &str) -> bool {
    value.len() <= 4096
        && value
            .chars()
            .all(|c| c.is_ascii() && !c.is_ascii_control() && !matches!(c, ';' | ',' | '\\'))
}

fn is_safe_cookie_path(path: &str) -> bool {
    path.starts_with('/')
        && path.len() <= 256
        && path
            .chars()
            .all(|c| !c.is_ascii_control() && c != ';' && c != ',')
}

/// Build a `Set-Cookie` header value (name=value + attributes).
///
/// Returns an empty string (skipped by [`crate::flow::redirect::attach_set_cookies`])
/// when `name`, `value`, or `path` would inject extra cookie attributes.
pub fn set_cookie(name: &str, value: &str, opts: CookieOptions) -> String {
    if !is_safe_cookie_name(name)
        || !is_safe_cookie_value(value)
        || !is_safe_cookie_path(&opts.path)
    {
        tracing::warn!(
            cookie_name = name,
            "refusing Set-Cookie — name, value, or path contains forbidden characters"
        );
        return String::new();
    }
    let mut out = format!("{name}={value}; Path={}", opts.path);
    if let Some(max_age) = opts.max_age {
        out.push_str(&format!("; Max-Age={max_age}"));
    }
    if opts.http_only {
        out.push_str("; HttpOnly");
    }
    out.push_str(&format!("; SameSite={}", opts.same_site));
    if opts.secure || matches!(opts.same_site, SameSite::None) {
        out.push_str("; Secure");
    }
    out
}

/// Expire a cookie (Max-Age=0), HttpOnly + SameSite=Lax by default.
pub fn clear_cookie(name: &str) -> String {
    set_cookie(
        name,
        "",
        CookieOptions {
            max_age: Some(0),
            ..Default::default()
        },
    )
}

/// Read one cookie value from a raw `Cookie` request header.
pub fn cookie_value(raw: &str, name: &str) -> Option<String> {
    raw.split(';').find_map(|part| {
        let (k, v) = part.trim().split_once('=')?;
        if k == name {
            Some(percent_decode(v))
        } else {
            None
        }
    })
}

fn percent_decode(s: &str) -> String {
    let bytes = s.as_bytes();
    let mut out = Vec::with_capacity(s.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%' && i + 2 < bytes.len() {
            if let (Some(h), Some(l)) = (from_hex(bytes[i + 1]), from_hex(bytes[i + 2])) {
                out.push((h << 4) | l);
                i += 3;
                continue;
            }
        }
        out.push(bytes[i]);
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

fn from_hex(b: u8) -> Option<u8> {
    match b {
        b'0'..=b'9' => Some(b - b'0'),
        b'a'..=b'f' => Some(b - b'a' + 10),
        b'A'..=b'F' => Some(b - b'A' + 10),
        _ => None,
    }
}

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

    #[test]
    fn set_cookie_http_only_session() {
        let c = set_cookie("sid", "abc", CookieOptions::session(3600));
        assert!(c.contains("sid=abc"));
        assert!(c.contains("HttpOnly"));
        assert!(c.contains("Max-Age=3600"));
        assert!(c.contains("SameSite=Lax"));
    }

    #[test]
    fn clear_cookie_expires() {
        let c = clear_cookie("sid");
        assert!(c.contains("Max-Age=0"));
        assert!(c.contains("HttpOnly"));
    }

    #[test]
    fn parses_cookie_header() {
        let raw = "a=1; sid=token%2Dvalue; b=2";
        assert_eq!(cookie_value(raw, "sid").as_deref(), Some("token-value"));
    }

    #[test]
    fn percent_decode_is_utf8() {
        let raw = "sid=caf%C3%A9";
        assert_eq!(cookie_value(raw, "sid").as_deref(), Some("café"));
    }

    #[test]
    fn set_cookie_rejects_attribute_injection() {
        let injected = set_cookie("sid", "abc; Domain=evil.test", CookieOptions::session(60));
        assert!(injected.is_empty(), "semicolon in value must be rejected");
        assert!(set_cookie("sid\nSet-Cookie", "x", CookieOptions::default()).is_empty());
        assert!(set_cookie(
            "ok",
            "x",
            CookieOptions {
                path: "/; Domain=evil.test".into(),
                ..CookieOptions::default()
            }
        )
        .is_empty());
    }
}