Skip to main content

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}