use std::fmt::Write as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SameSite {
Strict,
Lax,
None,
}
impl SameSite {
fn as_str(self) -> &'static str {
match self {
SameSite::Strict => "Strict",
SameSite::Lax => "Lax",
SameSite::None => "None",
}
}
}
#[derive(Debug, Clone)]
pub struct Cookie {
name: String,
value: String,
path: Option<String>,
domain: Option<String>,
max_age: Option<i64>,
secure: bool,
http_only: bool,
same_site: Option<SameSite>,
}
impl Cookie {
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
Self {
name: name.into(),
value: value.into(),
path: Some("/".into()),
domain: None,
max_age: None,
secure: false,
http_only: true,
same_site: Some(SameSite::Lax),
}
}
pub fn removal(name: impl Into<String>) -> Self {
let mut c = Self::new(name, "");
c.max_age = Some(0);
c
}
pub fn path(mut self, p: impl Into<String>) -> Self {
self.path = Some(p.into());
self
}
pub fn domain(mut self, d: impl Into<String>) -> Self {
self.domain = Some(d.into());
self
}
pub fn max_age(mut self, secs: i64) -> Self {
self.max_age = Some(secs);
self
}
pub fn secure(mut self, yes: bool) -> Self {
self.secure = yes;
self
}
pub fn http_only(mut self, yes: bool) -> Self {
self.http_only = yes;
self
}
pub fn same_site(mut self, s: SameSite) -> Self {
self.same_site = Some(s);
self
}
pub fn to_header_value(&self) -> String {
let mut out = String::new();
let _ = write!(out, "{}={}", encode(&self.name), encode(&self.value));
if let Some(p) = &self.path {
let _ = write!(out, "; Path={}", attribute_value(p));
}
if let Some(d) = &self.domain {
let _ = write!(out, "; Domain={}", attribute_value(d));
}
if let Some(m) = self.max_age {
let _ = write!(out, "; Max-Age={m}");
}
if self.secure {
out.push_str("; Secure");
}
if self.http_only {
out.push_str("; HttpOnly");
}
if let Some(s) = self.same_site {
let _ = write!(out, "; SameSite={}", s.as_str());
}
out
}
}
fn attribute_value(raw: &str) -> String {
raw.chars()
.filter(|c| *c != ';' && !c.is_control())
.collect()
}
fn encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'%' => out.push_str("%25"),
b'!' | b'#'..=b'+' | b'-'..=b':' | b'<'..=b'[' | b']'..=b'~' => out.push(b as char),
_ => {
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
pub(crate) fn decode(s: &str) -> String {
if !s.contains('%') {
return s.to_string();
}
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(h), Some(l)) = (hi, lo) {
out.push((h * 16 + l) as u8);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
pub(crate) fn find<'a>(header: &'a str, name: &str) -> Option<&'a str> {
header.split(';').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
(k.trim() == name).then_some(v.trim())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encodes_separators() {
assert_eq!(encode("a;b"), "a%3Bb");
assert_eq!(encode("a b"), "a%20b");
assert_eq!(encode("plain"), "plain");
}
#[test]
fn round_trips() {
for v in ["a;b c", "100%", "a%3Db", "plain", "", "ΓΌ"] {
assert_eq!(decode(&encode(v)), v, "round trip failed for {v:?}");
}
}
#[test]
fn finds_a_named_pair() {
assert_eq!(find("a=1; b=2", "b"), Some("2"));
assert_eq!(find("a=1", "missing"), None);
}
}