1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct Cookie {
7 pub name: String,
8 pub value: String,
9}
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct SetCookie {
14 pub name: String,
15 pub value: String,
16 pub path: Option<String>,
17 pub domain: Option<String>,
18 pub max_age: Option<i64>,
19 pub secure: bool,
20 pub http_only: bool,
21 pub same_site: Option<String>,
22}
23
24#[must_use]
26pub fn parse_cookie_header(input: &str) -> Vec<Cookie> {
27 input
28 .split(';')
29 .filter_map(|segment| {
30 let trimmed = segment.trim();
31 let (name, value) = trimmed.split_once('=')?;
32 let name = name.trim();
33 cookie_name_is_valid(name).then(|| Cookie {
34 name: name.to_string(),
35 value: value.trim().to_string(),
36 })
37 })
38 .collect()
39}
40
41#[must_use]
43pub fn parse_set_cookie_basic(input: &str) -> Option<SetCookie> {
44 let mut parts = input.split(';');
45 let first = parts.next()?.trim();
46 let (name, value) = first.split_once('=')?;
47 let name = name.trim();
48 if !cookie_name_is_valid(name) {
49 return None;
50 }
51
52 let mut cookie = SetCookie {
53 name: name.to_string(),
54 value: value.trim().to_string(),
55 path: None,
56 domain: None,
57 max_age: None,
58 secure: false,
59 http_only: false,
60 same_site: None,
61 };
62
63 for attribute in parts {
64 let attribute = attribute.trim();
65 if attribute.eq_ignore_ascii_case("secure") {
66 cookie.secure = true;
67 continue;
68 }
69 if attribute.eq_ignore_ascii_case("httponly") {
70 cookie.http_only = true;
71 continue;
72 }
73
74 if let Some((key, raw_value)) = attribute.split_once('=') {
75 let key = key.trim();
76 let value = raw_value.trim();
77 if key.eq_ignore_ascii_case("path") {
78 cookie.path = Some(value.to_string());
79 } else if key.eq_ignore_ascii_case("domain") {
80 cookie.domain = Some(value.to_string());
81 } else if key.eq_ignore_ascii_case("max-age") {
82 cookie.max_age = value.parse().ok();
83 } else if key.eq_ignore_ascii_case("samesite") && !value.is_empty() {
84 cookie.same_site = Some(value.to_string());
85 }
86 }
87 }
88
89 Some(cookie)
90}
91
92#[must_use]
94pub fn build_cookie_header(cookies: &[Cookie]) -> String {
95 cookies
96 .iter()
97 .map(|cookie| format!("{}={}", cookie.name, cookie.value))
98 .collect::<Vec<_>>()
99 .join("; ")
100}
101
102#[must_use]
104pub fn get_cookie(input: &str, name: &str) -> Option<String> {
105 parse_cookie_header(input)
106 .into_iter()
107 .find(|cookie| cookie.name == name)
108 .map(|cookie| cookie.value)
109}
110
111#[must_use]
113pub fn has_cookie(input: &str, name: &str) -> bool {
114 get_cookie(input, name).is_some()
115}
116
117#[must_use]
119pub fn cookie_name_is_valid(input: &str) -> bool {
120 let trimmed = input.trim();
121 !trimmed.is_empty() && trimmed.bytes().all(is_token_byte)
122}
123
124fn is_token_byte(byte: u8) -> bool {
125 byte.is_ascii_alphanumeric()
126 || matches!(
127 byte,
128 b'!' | b'#'
129 | b'$'
130 | b'%'
131 | b'&'
132 | b'\''
133 | b'*'
134 | b'+'
135 | b'-'
136 | b'.'
137 | b'^'
138 | b'_'
139 | b'`'
140 | b'|'
141 | b'~'
142 )
143}