set_cookie_parser/lib.rs
1//! # set-cookie-parser — parse `Set-Cookie` response headers
2//!
3//! Parse a `Set-Cookie` header value into a structured [`Cookie`], and split a
4//! comma-joined `Set-Cookie` string into individual cookies *without* choking on the
5//! commas inside an `Expires` date. A faithful Rust port of the
6//! [`set-cookie-parser`](https://www.npmjs.com/package/set-cookie-parser) npm package.
7//! Zero dependencies and `#![no_std]`.
8//!
9//! ```
10//! use set_cookie_parser::{parse, parse_all, split_cookies_string};
11//!
12//! let c = parse("sid=abc123; Path=/; HttpOnly; SameSite=Lax").unwrap();
13//! assert_eq!(c.name, "sid");
14//! assert_eq!(c.value, "abc123");
15//! assert_eq!(c.path.as_deref(), Some("/"));
16//! assert!(c.http_only);
17//! assert_eq!(c.same_site.as_deref(), Some("Lax"));
18//!
19//! // A single combined header with two cookies (note the comma inside Expires):
20//! let header = "a=1; Expires=Wed, 09 Jun 2021 10:18:14 GMT, b=2";
21//! assert_eq!(split_cookies_string(header), ["a=1; Expires=Wed, 09 Jun 2021 10:18:14 GMT", "b=2"]);
22//! assert_eq!(parse_all(header).len(), 2);
23//! ```
24
25#![no_std]
26#![doc(html_root_url = "https://docs.rs/set-cookie-parser/0.1.0")]
27
28extern crate alloc;
29
30use alloc::string::{String, ToString};
31use alloc::vec;
32use alloc::vec::Vec;
33
34// Compile-test the README's examples as part of `cargo test`.
35#[cfg(doctest)]
36#[doc = include_str!("../README.md")]
37struct ReadmeDoctests;
38
39/// A parsed cookie from a `Set-Cookie` header.
40///
41/// `name` and `value` come from the first `name=value` pair; the remaining fields are
42/// the cookie's attributes (present only when set). `expires` is kept as the raw
43/// header value (parse it with a date crate if you need a timestamp). Attributes other
44/// than the well-known ones are collected, in order, into [`other`](Cookie::other).
45#[derive(Debug, Clone, PartialEq, Eq, Default)]
46pub struct Cookie {
47 /// The cookie name (may be empty for a bare `value` with no `=`).
48 pub name: String,
49 /// The cookie value (URI-decoded unless decoding is disabled).
50 pub value: String,
51 /// The raw `Expires` attribute value, if present.
52 pub expires: Option<String>,
53 /// The `Max-Age` attribute, parsed as an integer (à la JS `parseInt`).
54 pub max_age: Option<i64>,
55 /// The `Domain` attribute.
56 pub domain: Option<String>,
57 /// The `Path` attribute.
58 pub path: Option<String>,
59 /// Whether the `Secure` attribute is present.
60 pub secure: bool,
61 /// Whether the `HttpOnly` attribute is present.
62 pub http_only: bool,
63 /// The `SameSite` attribute value (e.g. `Lax`, `Strict`, `None`).
64 pub same_site: Option<String>,
65 /// Whether the `Partitioned` attribute is present.
66 pub partitioned: bool,
67 /// Any other attributes, as `(lower-cased key, value)` pairs, in order.
68 pub other: Vec<(String, String)>,
69}
70
71/// Parse a single `Set-Cookie` header value into a [`Cookie`], URI-decoding the value.
72///
73/// Returns `None` for an empty/blank input or a cookie whose name is a reserved
74/// JavaScript object key (a prototype-pollution guard kept for fidelity).
75///
76/// ```
77/// assert_eq!(set_cookie_parser::parse("foo=bar").unwrap().value, "bar");
78/// assert_eq!(set_cookie_parser::parse("enc=a%20b").unwrap().value, "a b");
79/// ```
80#[must_use]
81pub fn parse(set_cookie: &str) -> Option<Cookie> {
82 parse_with(set_cookie, true)
83}
84
85/// Parse a single `Set-Cookie` header value, controlling whether the cookie value is
86/// URI-decoded (`decodeURIComponent`). On a decode error the raw value is kept.
87#[must_use]
88pub fn parse_with(set_cookie: &str, decode_values: bool) -> Option<Cookie> {
89 let parts: Vec<&str> = set_cookie
90 .split(';')
91 .filter(|p| !p.trim_matches(is_js_whitespace).is_empty())
92 .collect();
93 let name_value = *parts.first()?;
94 let (name, raw_value) = parse_name_value_pair(name_value);
95 if is_forbidden_key(name) {
96 return None;
97 }
98
99 let value = if decode_values {
100 decode_uri_component(raw_value).unwrap_or_else(|| raw_value.to_string())
101 } else {
102 raw_value.to_string()
103 };
104
105 let mut cookie = Cookie {
106 name: name.to_string(),
107 value,
108 ..Cookie::default()
109 };
110
111 for part in &parts[1..] {
112 let (key, val) = match part.split_once('=') {
113 Some((k, v)) => (k, v),
114 None => (*part, ""),
115 };
116 let key = key.trim_start_matches(is_js_whitespace).to_lowercase();
117 if is_forbidden_key(&key) {
118 continue;
119 }
120 match key.as_str() {
121 "expires" => cookie.expires = Some(val.to_string()),
122 "max-age" => {
123 if let Some(n) = parse_int_js(val) {
124 cookie.max_age = Some(n);
125 }
126 }
127 "domain" => cookie.domain = Some(val.to_string()),
128 "path" => cookie.path = Some(val.to_string()),
129 "secure" => cookie.secure = true,
130 "httponly" => cookie.http_only = true,
131 "samesite" => cookie.same_site = Some(val.to_string()),
132 "partitioned" => cookie.partitioned = true,
133 "" => {}
134 _ => {
135 // Mirror JS object assignment: a repeated key overwrites in place.
136 if let Some(slot) = cookie.other.iter_mut().find(|(k, _)| *k == key) {
137 slot.1 = val.to_string();
138 } else {
139 cookie.other.push((key, val.to_string()));
140 }
141 }
142 }
143 }
144
145 Some(cookie)
146}
147
148/// Split a combined `Set-Cookie` header string into individual cookie strings.
149///
150/// Some servers/proxies join multiple `Set-Cookie` field values with commas. This
151/// splits on those separators while leaving alone the commas inside a single value,
152/// such as the date in an `Expires` attribute.
153///
154/// ```
155/// assert_eq!(set_cookie_parser::split_cookies_string("a=1, b=2"), ["a=1", "b=2"]);
156/// ```
157#[must_use]
158pub fn split_cookies_string(cookies_string: &str) -> Vec<String> {
159 let chars: Vec<char> = cookies_string.chars().collect();
160 let len = chars.len();
161 let mut result = Vec::new();
162 let mut pos = 0;
163
164 while pos < len {
165 let mut start = pos;
166 let mut separator_found = false;
167
168 loop {
169 while pos < len && is_js_whitespace(chars[pos]) {
170 pos += 1;
171 }
172 if pos >= len {
173 break;
174 }
175 if chars[pos] == ',' {
176 let last_comma = pos;
177 pos += 1;
178 while pos < len && is_js_whitespace(chars[pos]) {
179 pos += 1;
180 }
181 let next_start = pos;
182 while pos < len && {
183 let c = chars[pos];
184 c != '=' && c != ';' && c != ','
185 } {
186 pos += 1;
187 }
188 if pos < len && chars[pos] == '=' {
189 // A real cookie separator: the next token reaches a '='.
190 separator_found = true;
191 pos = next_start;
192 result.push(chars[start..last_comma].iter().collect());
193 start = pos;
194 } else {
195 // A comma inside a value (e.g. an Expires date) — keep going.
196 pos = last_comma + 1;
197 }
198 } else {
199 pos += 1;
200 }
201 }
202
203 if !separator_found || pos >= len {
204 result.push(chars[start..len].iter().collect());
205 }
206 }
207
208 result
209}
210
211/// Split a combined `Set-Cookie` header and parse each cookie (URI-decoding values).
212///
213/// ```
214/// let cookies = set_cookie_parser::parse_all("a=1, b=2; Path=/");
215/// assert_eq!(cookies.len(), 2);
216/// assert_eq!(cookies[1].path.as_deref(), Some("/"));
217/// ```
218#[must_use]
219pub fn parse_all(combined: &str) -> Vec<Cookie> {
220 parse_all_with(combined, true)
221}
222
223/// Split a combined `Set-Cookie` header and parse each cookie, controlling URI-decoding.
224#[must_use]
225pub fn parse_all_with(combined: &str, decode_values: bool) -> Vec<Cookie> {
226 if combined.trim_matches(is_js_whitespace).is_empty() {
227 return Vec::new();
228 }
229 split_cookies_string(combined)
230 .into_iter()
231 .filter_map(|s| parse_with(&s, decode_values))
232 .collect()
233}
234
235/// Split a `name=value` pair: name is the text before the first `=`, value the rest.
236/// With no `=`, the whole string is the value and the name is empty.
237fn parse_name_value_pair(s: &str) -> (&str, &str) {
238 match s.split_once('=') {
239 Some((name, value)) => (name, value),
240 None => ("", s),
241 }
242}
243
244/// JavaScript object prototype keys, rejected as cookie names / skipped as attribute
245/// keys to mirror the reference's prototype-pollution guard (`key in {}`).
246const FORBIDDEN_KEYS: &[&str] = &[
247 "constructor",
248 "__proto__",
249 "__defineGetter__",
250 "__defineSetter__",
251 "hasOwnProperty",
252 "__lookupGetter__",
253 "__lookupSetter__",
254 "isPrototypeOf",
255 "propertyIsEnumerable",
256 "toString",
257 "valueOf",
258 "toLocaleString",
259];
260
261fn is_forbidden_key(key: &str) -> bool {
262 FORBIDDEN_KEYS.contains(&key)
263}
264
265/// Parse a leading base-10 integer like JS `parseInt`: skip leading whitespace, an
266/// optional sign, then digits; ignore any trailing characters. `None` if no digits.
267fn parse_int_js(s: &str) -> Option<i64> {
268 let chars: Vec<char> = s.chars().collect();
269 let mut i = 0;
270 while i < chars.len() && is_js_whitespace(chars[i]) {
271 i += 1;
272 }
273 let negative = match chars.get(i) {
274 Some('+') => {
275 i += 1;
276 false
277 }
278 Some('-') => {
279 i += 1;
280 true
281 }
282 _ => false,
283 };
284 let start = i;
285 let mut acc: i64 = 0;
286 while i < chars.len() && chars[i].is_ascii_digit() {
287 let d = i64::from(chars[i] as u32 - '0' as u32);
288 acc = acc.saturating_mul(10).saturating_add(d);
289 i += 1;
290 }
291 if i == start {
292 return None;
293 }
294 Some(if negative { -acc } else { acc })
295}
296
297/// Decode a string as JS `decodeURIComponent`: `%XX` runs are decoded as UTF-8 and
298/// other characters pass through. Returns `None` (a `URIError`) on a malformed escape
299/// or invalid UTF-8 sequence.
300fn decode_uri_component(s: &str) -> Option<String> {
301 let chars: Vec<char> = s.chars().collect();
302 let len = chars.len();
303 let mut out = String::new();
304 let mut i = 0;
305
306 while i < len {
307 if chars[i] == '%' {
308 let b0 = read_hex_byte(&chars, i)?;
309 i += 3;
310 if b0 < 0x80 {
311 out.push(b0 as char);
312 } else {
313 let n = utf8_sequence_len(b0)?;
314 let mut buf = vec![b0];
315 for _ in 1..n {
316 if i >= len || chars[i] != '%' {
317 return None;
318 }
319 let bn = read_hex_byte(&chars, i)?;
320 if bn & 0xC0 != 0x80 {
321 return None;
322 }
323 buf.push(bn);
324 i += 3;
325 }
326 out.push_str(core::str::from_utf8(&buf).ok()?);
327 }
328 } else {
329 out.push(chars[i]);
330 i += 1;
331 }
332 }
333
334 Some(out)
335}
336
337/// Read the two hex digits after `%` at `chars[i]`, returning the byte.
338fn read_hex_byte(chars: &[char], i: usize) -> Option<u8> {
339 let hi = u8::try_from(chars.get(i + 1)?.to_digit(16)?).ok()?;
340 let lo = u8::try_from(chars.get(i + 2)?.to_digit(16)?).ok()?;
341 Some(hi * 16 + lo)
342}
343
344/// Number of bytes in the UTF-8 sequence starting with lead byte `b` (`None` if `b` is
345/// not a valid multi-byte lead).
346fn utf8_sequence_len(b: u8) -> Option<usize> {
347 match b {
348 0xC0..=0xDF => Some(2),
349 0xE0..=0xEF => Some(3),
350 0xF0..=0xF7 => Some(4),
351 _ => None,
352 }
353}
354
355/// Whitespace per JavaScript's regex `\s` (Rust `White_Space` minus NEL `U+0085`,
356/// plus the BOM `U+FEFF`).
357fn is_js_whitespace(c: char) -> bool {
358 (c.is_whitespace() && c != '\u{0085}') || c == '\u{feff}'
359}