cookiestxt_rs/
lib.rs

1#![warn(missing_docs)]
2#![doc = include_str!("../README.md")]
3
4use std::{
5    convert::TryFrom,
6    error::Error,
7    fmt::Display,
8    ops::{Deref, DerefMut},
9};
10
11const HTTP_ONLY: &str = "#HttpOnly_";
12
13/// Cookie representation
14#[derive(Default, Debug, Clone, PartialEq)]
15pub struct Cookie {
16    /// the domain the cookie is valid for
17    pub domain: String,
18    /// whether or not the cookie is also valid for subdomains
19    pub include_subdomains: bool,
20    /// a subpath the cookie is valid for
21    pub path: String,
22    /// should it only be valid in https contexts
23    pub https_only: bool,
24    /// should it only be valid in http contexts
25    pub http_only: bool,
26    /// unix timestamp for when the cookie expires
27    pub expires: u64,
28    /// the cookie name
29    pub name: String,
30    /// the value of the cookie
31    pub value: String,
32}
33
34/// Type containing multiple cookies
35#[derive(Default, Debug, Clone, PartialEq)]
36pub struct Cookies(Vec<Cookie>);
37
38impl Deref for Cookies {
39    type Target = Vec<Cookie>;
40
41    fn deref(&self) -> &Self::Target {
42        &self.0
43    }
44}
45
46impl DerefMut for Cookies {
47    fn deref_mut(&mut self) -> &mut Self::Target {
48        &mut self.0
49    }
50}
51
52impl From<Vec<Cookie>> for Cookies {
53    fn from(value: Vec<Cookie>) -> Self {
54        Cookies(value)
55    }
56}
57
58impl From<Cookies> for Vec<Cookie> {
59    fn from(value: Cookies) -> Self {
60        value.0
61    }
62}
63
64#[cfg(any(feature = "cookie", test))]
65impl From<Cookies> for Vec<cookie::Cookie<'_>> {
66    fn from(value: Cookies) -> Self {
67        value.iter().map(cookie::Cookie::from).collect()
68    }
69}
70
71/// represents an error that can occur while parsing or converting cookies
72#[derive(Debug, PartialEq)]
73pub enum ParseError {
74    /// can occur while parsing a cookies.txt string and it being formatted wrong
75    InvalidFormat(String),
76    /// can occur while parsing a cookies.txt string and converting string representations to
77    /// concrete types
78    InvalidValue(String),
79    /// can occur if the cookies.txt string is empty or only contains comments
80    Empty,
81    /// should ideally not occur and should be reported if it does.
82    /// it will contain an error message
83    InternalError(String),
84    /// can occur if parsing the url for the cookie fails
85    InvalidUrl,
86}
87
88impl Display for ParseError {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            ParseError::InvalidFormat(m) => write!(f, "{}", m),
92            ParseError::InvalidValue(m) => write!(f, "{}", m),
93            ParseError::Empty => write!(f, "Input does not contain cookie"),
94            ParseError::InternalError(m) => {
95                write!(f, "Internal error occured, report this: \"{}\"", m)
96            }
97            ParseError::InvalidUrl => {
98                write!(f, "The URL stored in the cookie could not be converted")
99            }
100        }
101    }
102}
103
104impl Error for ParseError {}
105
106#[doc(hidden)]
107fn parse_bool<T>(s: T) -> Result<bool, ParseError>
108where
109    T: AsRef<str> + std::fmt::Debug,
110{
111    let input: &str = s.as_ref();
112    match input.to_lowercase().as_ref() {
113        "true" => Ok(true),
114        "false" => Ok(false),
115        _ => Err(ParseError::InvalidValue(format!(
116            "Expected \"TRUE\" or \"FALSE\", got \"{:?}\"",
117            s
118        ))),
119    }
120}
121
122#[doc(hidden)]
123fn parse_u64<T>(s: T) -> Result<u64, ParseError>
124where
125    T: AsRef<str>,
126{
127    let input: &str = s.as_ref();
128    match input.parse::<u64>() {
129        Ok(v) => Ok(v),
130        Err(_) => Err(ParseError::InvalidValue(format!(
131            "Expected a value between {} and {}, got \"{}\"",
132            u64::MIN,
133            u64::MAX,
134            input
135        ))),
136    }
137}
138
139impl TryFrom<&str> for Cookie {
140    type Error = ParseError;
141
142    /// tries to convert a single line in cookies.txt format to a [crate::Cookie].
143    fn try_from(input: &str) -> Result<Self, Self::Error> {
144        let mut input = input.trim();
145
146        let mut domain: String = Default::default();
147        let mut include_subdomains: bool = Default::default();
148        let mut path: String = Default::default();
149        let mut https_only: bool = Default::default();
150        let mut http_only: bool = Default::default();
151        let mut expires: u64 = Default::default();
152        let mut name: String = Default::default();
153        let mut value: String = Default::default();
154
155        if input.starts_with('#') && !input.starts_with(HTTP_ONLY) {
156            return Err(ParseError::Empty);
157        }
158
159        if input.starts_with(HTTP_ONLY) {
160            http_only = true;
161            input = if let Some(v) = input.strip_prefix(HTTP_ONLY) {
162                v.trim()
163            } else {
164                return Err(ParseError::InternalError(
165                    "Could not strip HTTP_ONLY prefix, even though it is present".to_string(),
166                ));
167            };
168        }
169
170        let splits = input.split('\t').enumerate();
171        if splits.clone().count() != 7 {
172            return Err(ParseError::Empty);
173        }
174
175        for (i, part) in splits {
176            let part = part.trim();
177            match i {
178                0 => domain = part.to_string(),
179                1 => include_subdomains = parse_bool(part)?,
180                2 => path = part.to_string(),
181                3 => https_only = parse_bool(part)?,
182                4 => expires = parse_u64(part)?,
183                5 => name = part.to_string(),
184                6 => value = part.to_string(),
185                v => {
186                    return Err(ParseError::InvalidFormat(format!(
187                        "Too many fields: {}, expected 7",
188                        v
189                    )))
190                }
191            }
192        }
193
194        Ok(Cookie {
195            domain,
196            include_subdomains,
197            path,
198            https_only,
199            http_only,
200            expires,
201            name,
202            value,
203        })
204    }
205}
206
207impl TryFrom<&str> for Cookies {
208    type Error = ParseError;
209
210    /// tries to convert a multiple lines in the cookies.txt format to [crate::Cookies].
211    fn try_from(value: &str) -> Result<Self, Self::Error> {
212        if value.lines().peekable().peek().is_none() {
213            return Err(ParseError::Empty);
214        }
215
216        let mut cookies: Cookies = Cookies(vec![]);
217
218        for line in value.lines() {
219            let cookie = match Cookie::try_from(line) {
220                Ok(c) => c,
221                Err(ParseError::Empty) => continue,
222                e => e?,
223            };
224
225            cookies.push(cookie);
226        }
227
228        if cookies.is_empty() {
229            return Err(ParseError::Empty);
230        }
231
232        Ok(cookies)
233    }
234}
235
236#[cfg(any(feature = "cookie", test))]
237impl From<&Cookie> for cookie::Cookie<'_> {
238    /// convert from [crate::Cookie] to [cookie::Cookie]
239    fn from(value: &Cookie) -> Self {
240        Self::build((value.clone().name, value.clone().value))
241            .domain(value.clone().domain)
242            .path(value.clone().path)
243            .secure(value.https_only)
244            .http_only(value.http_only)
245            .expires(cookie::Expiration::from(match value.expires {
246                0 => None,
247                v => time::OffsetDateTime::from_unix_timestamp(v as i64).ok(),
248            }))
249            .build()
250    }
251}
252
253#[cfg(any(feature = "thirtyfour", test))]
254impl From<Cookie> for thirtyfour::Cookie<'_> {
255    /// convert from [crate::Cookie] to [thirtyfour::Cookie]
256    fn from(value: Cookie) -> Self {
257        Self::build(value.name, value.value)
258            .domain(value.domain)
259            .path(value.path)
260            .secure(value.https_only)
261            .http_only(value.http_only)
262            .expires(thirtyfour::cookie::Expiration::from(match value.expires {
263                0 => None,
264                v => time::OffsetDateTime::from_unix_timestamp(v as i64).ok(),
265            }))
266            .finish()
267    }
268}
269
270#[cfg(any(feature = "cookie_store", test))]
271impl Cookie {
272    /// converts [crate::Cookie] to a [cookie_store::Cookie].
273    /// This takes a URL, for which the cookie is valid. Parsing this URL can fail.
274    pub fn into_cookie_store_cookie(
275        self,
276        domain: &str,
277    ) -> Result<cookie_store::Cookie<'_>, ParseError> {
278        cookie_store::Cookie::try_from_raw_cookie(
279            &self.into(),
280            &url::Url::parse(domain).map_err(|_| ParseError::InvalidUrl)?,
281        )
282        .map_err(|e| ParseError::InternalError(e.to_string()))
283    }
284}
285
286#[cfg(any(feature = "cookie_store", test))]
287impl From<Cookie> for cookie_store::RawCookie<'_> {
288    /// convert from [crate::Cookie] to [cookie_store::RawCookie]
289    fn from(value: Cookie) -> Self {
290        Self::build((value.name, value.value))
291            .domain(value.domain)
292            .path(value.path)
293            .secure(value.https_only)
294            .http_only(value.http_only)
295            .expires(cookie::Expiration::from(match value.expires {
296                0 => None,
297                v => time::OffsetDateTime::from_unix_timestamp(v as i64).ok(),
298            }))
299            .build()
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    const COOKIE_TXT: &str = r#"
308# Netscape HTTP Cookie File
309# http://curl.haxx.se/rfc/cookie_spec.html
310# This is a generated file!  Do not edit.
311
312.example.com	TRUE	/	TRUE	0000000000	foo	bar
313.example.com	TRUE	/	TRUE	1740743335	foo2	bar2
314#HttpOnly_	.example.com	TRUE	/	TRUE	1740743335	foo3	bar3
315"#;
316
317    #[test]
318    fn parse_cookie_line() {
319        let input = r#"
320.example.com	TRUE	/	TRUE	1234567890	foo	bar
321"#;
322        assert_eq!(
323            Cookie::try_from(input),
324            Ok(Cookie {
325                domain: ".example.com".to_string(),
326                include_subdomains: true,
327                path: "/".to_string(),
328                https_only: true,
329                http_only: false,
330                expires: 1234567890,
331                name: "foo".to_string(),
332                value: "bar".to_string()
333            })
334        );
335    }
336
337    #[test]
338    fn parse_cookie_line_http_only() {
339        let input = r#"
340#HttpOnly_ .example.com	TRUE	/	TRUE	1234567890	foo	bar
341"#;
342        assert_eq!(
343            Cookie::try_from(input),
344            Ok(Cookie {
345                domain: ".example.com".to_string(),
346                include_subdomains: true,
347                path: "/".to_string(),
348                https_only: true,
349                http_only: true,
350                expires: 1234567890,
351                name: "foo".to_string(),
352                value: "bar".to_string()
353            })
354        );
355    }
356
357    #[test]
358    fn parse_empty_line() {
359        let input = "";
360        assert_eq!(Cookie::try_from(input), Err(ParseError::Empty))
361    }
362
363    #[test]
364    fn parse_comment() {
365        let input = "# hello world";
366        assert_eq!(Cookie::try_from(input), Err(ParseError::Empty))
367    }
368
369    #[test]
370    fn parse_cookie_txt() {
371        let exp = vec![
372            Cookie {
373                domain: ".example.com".to_string(),
374                include_subdomains: true,
375                path: "/".to_string(),
376                https_only: true,
377                http_only: false,
378                expires: 0,
379                name: "foo".to_string(),
380                value: "bar".to_string(),
381            },
382            Cookie {
383                domain: ".example.com".to_string(),
384                include_subdomains: true,
385                path: "/".to_string(),
386                https_only: true,
387                http_only: false,
388                expires: 1740743335,
389                name: "foo2".to_string(),
390                value: "bar2".to_string(),
391            },
392            Cookie {
393                domain: ".example.com".to_string(),
394                include_subdomains: true,
395                path: "/".to_string(),
396                https_only: true,
397                http_only: true,
398                expires: 1740743335,
399                name: "foo3".to_string(),
400                value: "bar3".to_string(),
401            },
402        ];
403
404        let cookies: Vec<Cookie> = Cookies::try_from(COOKIE_TXT).unwrap().to_vec();
405        assert_eq!(cookies, exp);
406    }
407
408    #[cfg(any(feature = "cookie", test))]
409    #[test]
410    fn test_convert_to_cookie() {
411        let converted: Vec<cookie::Cookie> = Cookies::try_from(COOKIE_TXT).unwrap().into();
412        let exp = vec![
413            cookie::Cookie::build(("foo", "bar"))
414                .domain(".example.com")
415                .path("/")
416                .secure(true)
417                .http_only(false)
418                .expires(cookie::Expiration::Session)
419                .build(),
420            cookie::Cookie::build(("foo2", "bar2"))
421                .domain(".example.com")
422                .path("/")
423                .secure(true)
424                .http_only(false)
425                .expires(time::OffsetDateTime::from_unix_timestamp(1740743335).unwrap())
426                .build(),
427            cookie::Cookie::build(("foo3", "bar3"))
428                .domain(".example.com")
429                .path("/")
430                .secure(true)
431                .http_only(true)
432                .expires(time::OffsetDateTime::from_unix_timestamp(1740743335).unwrap())
433                .build(),
434        ];
435        assert_eq!(converted, exp);
436    }
437}