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#[derive(Default, Debug, Clone, PartialEq)]
15pub struct Cookie {
16 pub domain: String,
18 pub include_subdomains: bool,
20 pub path: String,
22 pub https_only: bool,
24 pub http_only: bool,
26 pub expires: u64,
28 pub name: String,
30 pub value: String,
32}
33
34#[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#[derive(Debug, PartialEq)]
73pub enum ParseError {
74 InvalidFormat(String),
76 InvalidValue(String),
79 Empty,
81 InternalError(String),
84 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 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 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 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 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 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 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}