cookiestxt-rs 0.1.4

Parses cookie.txt/netscape cookie format
Documentation
#![warn(missing_docs)]
#![doc = include_str!("../README.md")]

use std::{
    convert::TryFrom,
    error::Error,
    fmt::Display,
    ops::{Deref, DerefMut},
};

const HTTP_ONLY: &str = "#HttpOnly_";

/// Cookie representation
#[derive(Default, Debug, Clone, PartialEq)]
pub struct Cookie {
    /// the domain the cookie is valid for
    pub domain: String,
    /// whether or not the cookie is also valid for subdomains
    pub include_subdomains: bool,
    /// a subpath the cookie is valid for
    pub path: String,
    /// should it only be valid in https contexts
    pub https_only: bool,
    /// should it only be valid in http contexts
    pub http_only: bool,
    /// unix timestamp for when the cookie expires
    pub expires: u64,
    /// the cookie name
    pub name: String,
    /// the value of the cookie
    pub value: String,
}

/// Type containing multiple cookies
#[derive(Default, Debug, Clone, PartialEq)]
pub struct Cookies(Vec<Cookie>);

impl Deref for Cookies {
    type Target = Vec<Cookie>;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

impl DerefMut for Cookies {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.0
    }
}

impl From<Vec<Cookie>> for Cookies {
    fn from(value: Vec<Cookie>) -> Self {
        Cookies(value)
    }
}

impl From<Cookies> for Vec<Cookie> {
    fn from(value: Cookies) -> Self {
        value.0
    }
}

#[cfg(any(feature = "cookie", test))]
impl From<Cookies> for Vec<cookie::Cookie<'_>> {
    fn from(value: Cookies) -> Self {
        value.iter().map(cookie::Cookie::from).collect()
    }
}

/// represents an error that can occur while parsing or converting cookies
#[derive(Debug, PartialEq)]
pub enum ParseError {
    /// can occur while parsing a cookies.txt string and it being formatted wrong
    InvalidFormat(String),
    /// can occur while parsing a cookies.txt string and converting string representations to
    /// concrete types
    InvalidValue(String),
    /// can occur if the cookies.txt string is empty or only contains comments
    Empty,
    /// should ideally not occur and should be reported if it does.
    /// it will contain an error message
    InternalError(String),
    /// can occur if parsing the url for the cookie fails
    InvalidUrl,
}

impl Display for ParseError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ParseError::InvalidFormat(m) => write!(f, "{}", m),
            ParseError::InvalidValue(m) => write!(f, "{}", m),
            ParseError::Empty => write!(f, "Input does not contain cookie"),
            ParseError::InternalError(m) => {
                write!(f, "Internal error occured, report this: \"{}\"", m)
            }
            ParseError::InvalidUrl => {
                write!(f, "The URL stored in the cookie could not be converted")
            }
        }
    }
}

impl Error for ParseError {}

#[doc(hidden)]
fn parse_bool<T>(s: T) -> Result<bool, ParseError>
where
    T: AsRef<str> + std::fmt::Debug,
{
    let input: &str = s.as_ref();
    match input.to_lowercase().as_ref() {
        "true" => Ok(true),
        "false" => Ok(false),
        _ => Err(ParseError::InvalidValue(format!(
            "Expected \"TRUE\" or \"FALSE\", got \"{:?}\"",
            s
        ))),
    }
}

#[doc(hidden)]
fn parse_u64<T>(s: T) -> Result<u64, ParseError>
where
    T: AsRef<str>,
{
    let input: &str = s.as_ref();
    match input.parse::<u64>() {
        Ok(v) => Ok(v),
        Err(_) => Err(ParseError::InvalidValue(format!(
            "Expected a value between {} and {}, got \"{}\"",
            u64::MIN,
            u64::MAX,
            input
        ))),
    }
}

impl TryFrom<&str> for Cookie {
    type Error = ParseError;

    /// tries to convert a single line in cookies.txt format to a [crate::Cookie].
    fn try_from(input: &str) -> Result<Self, Self::Error> {
        let mut input = input.trim();

        let mut domain: String = Default::default();
        let mut include_subdomains: bool = Default::default();
        let mut path: String = Default::default();
        let mut https_only: bool = Default::default();
        let mut http_only: bool = Default::default();
        let mut expires: u64 = Default::default();
        let mut name: String = Default::default();
        let mut value: String = Default::default();

        if input.starts_with('#') && !input.starts_with(HTTP_ONLY) {
            return Err(ParseError::Empty);
        }

        if input.starts_with(HTTP_ONLY) {
            http_only = true;
            input = if let Some(v) = input.strip_prefix(HTTP_ONLY) {
                v.trim()
            } else {
                return Err(ParseError::InternalError(
                    "Could not strip HTTP_ONLY prefix, even though it is present".to_string(),
                ));
            };
        }

        let splits = input.split('\t').enumerate();
        if splits.clone().count() != 7 {
            return Err(ParseError::Empty);
        }

        for (i, part) in splits {
            let part = part.trim();
            match i {
                0 => domain = part.to_string(),
                1 => include_subdomains = parse_bool(part)?,
                2 => path = part.to_string(),
                3 => https_only = parse_bool(part)?,
                4 => expires = parse_u64(part)?,
                5 => name = part.to_string(),
                6 => value = part.to_string(),
                v => {
                    return Err(ParseError::InvalidFormat(format!(
                        "Too many fields: {}, expected 7",
                        v
                    )))
                }
            }
        }

        Ok(Cookie {
            domain,
            include_subdomains,
            path,
            https_only,
            http_only,
            expires,
            name,
            value,
        })
    }
}

impl TryFrom<&str> for Cookies {
    type Error = ParseError;

    /// tries to convert a multiple lines in the cookies.txt format to [crate::Cookies].
    fn try_from(value: &str) -> Result<Self, Self::Error> {
        if value.lines().peekable().peek().is_none() {
            return Err(ParseError::Empty);
        }

        let mut cookies: Cookies = Cookies(vec![]);

        for line in value.lines() {
            let cookie = match Cookie::try_from(line) {
                Ok(c) => c,
                Err(ParseError::Empty) => continue,
                e => e?,
            };

            cookies.push(cookie);
        }

        if cookies.is_empty() {
            return Err(ParseError::Empty);
        }

        Ok(cookies)
    }
}

#[cfg(any(feature = "cookie", test))]
impl From<&Cookie> for cookie::Cookie<'_> {
    /// convert from [crate::Cookie] to [cookie::Cookie]
    fn from(value: &Cookie) -> Self {
        Self::build((value.clone().name, value.clone().value))
            .domain(value.clone().domain)
            .path(value.clone().path)
            .secure(value.https_only)
            .http_only(value.http_only)
            .expires(cookie::Expiration::from(match value.expires {
                0 => None,
                v => time::OffsetDateTime::from_unix_timestamp(v as i64).ok(),
            }))
            .build()
    }
}

#[cfg(any(feature = "thirtyfour", test))]
impl From<Cookie> for thirtyfour::Cookie<'_> {
    /// convert from [crate::Cookie] to [thirtyfour::Cookie]
    fn from(value: Cookie) -> Self {
        Self::build(value.name, value.value)
            .domain(value.domain)
            .path(value.path)
            .secure(value.https_only)
            .http_only(value.http_only)
            .expires(thirtyfour::cookie::Expiration::from(match value.expires {
                0 => None,
                v => time::OffsetDateTime::from_unix_timestamp(v as i64).ok(),
            }))
            .finish()
    }
}

#[cfg(any(feature = "cookie_store", test))]
impl Cookie {
    /// converts [crate::Cookie] to a [cookie_store::Cookie].
    /// This takes a URL, for which the cookie is valid. Parsing this URL can fail.
    pub fn into_cookie_store_cookie(
        self,
        domain: &str,
    ) -> Result<cookie_store::Cookie<'_>, ParseError> {
        cookie_store::Cookie::try_from_raw_cookie(
            &self.into(),
            &url::Url::parse(domain).map_err(|_| ParseError::InvalidUrl)?,
        )
        .map_err(|e| ParseError::InternalError(e.to_string()))
    }
}

#[cfg(any(feature = "cookie_store", test))]
impl From<Cookie> for cookie_store::RawCookie<'_> {
    /// convert from [crate::Cookie] to [cookie_store::RawCookie]
    fn from(value: Cookie) -> Self {
        Self::build((value.name, value.value))
            .domain(value.domain)
            .path(value.path)
            .secure(value.https_only)
            .http_only(value.http_only)
            .expires(cookie::Expiration::from(match value.expires {
                0 => None,
                v => time::OffsetDateTime::from_unix_timestamp(v as i64).ok(),
            }))
            .build()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const COOKIE_TXT: &str = r#"
# Netscape HTTP Cookie File
# http://curl.haxx.se/rfc/cookie_spec.html
# This is a generated file!  Do not edit.

.example.com	TRUE	/	TRUE	0000000000	foo	bar
.example.com	TRUE	/	TRUE	1740743335	foo2	bar2
#HttpOnly_	.example.com	TRUE	/	TRUE	1740743335	foo3	bar3
"#;

    #[test]
    fn parse_cookie_line() {
        let input = r#"
.example.com	TRUE	/	TRUE	1234567890	foo	bar
"#;
        assert_eq!(
            Cookie::try_from(input),
            Ok(Cookie {
                domain: ".example.com".to_string(),
                include_subdomains: true,
                path: "/".to_string(),
                https_only: true,
                http_only: false,
                expires: 1234567890,
                name: "foo".to_string(),
                value: "bar".to_string()
            })
        );
    }

    #[test]
    fn parse_cookie_line_http_only() {
        let input = r#"
#HttpOnly_ .example.com	TRUE	/	TRUE	1234567890	foo	bar
"#;
        assert_eq!(
            Cookie::try_from(input),
            Ok(Cookie {
                domain: ".example.com".to_string(),
                include_subdomains: true,
                path: "/".to_string(),
                https_only: true,
                http_only: true,
                expires: 1234567890,
                name: "foo".to_string(),
                value: "bar".to_string()
            })
        );
    }

    #[test]
    fn parse_empty_line() {
        let input = "";
        assert_eq!(Cookie::try_from(input), Err(ParseError::Empty))
    }

    #[test]
    fn parse_comment() {
        let input = "# hello world";
        assert_eq!(Cookie::try_from(input), Err(ParseError::Empty))
    }

    #[test]
    fn parse_cookie_txt() {
        let exp = vec![
            Cookie {
                domain: ".example.com".to_string(),
                include_subdomains: true,
                path: "/".to_string(),
                https_only: true,
                http_only: false,
                expires: 0,
                name: "foo".to_string(),
                value: "bar".to_string(),
            },
            Cookie {
                domain: ".example.com".to_string(),
                include_subdomains: true,
                path: "/".to_string(),
                https_only: true,
                http_only: false,
                expires: 1740743335,
                name: "foo2".to_string(),
                value: "bar2".to_string(),
            },
            Cookie {
                domain: ".example.com".to_string(),
                include_subdomains: true,
                path: "/".to_string(),
                https_only: true,
                http_only: true,
                expires: 1740743335,
                name: "foo3".to_string(),
                value: "bar3".to_string(),
            },
        ];

        let cookies: Vec<Cookie> = Cookies::try_from(COOKIE_TXT).unwrap().to_vec();
        assert_eq!(cookies, exp);
    }

    #[cfg(any(feature = "cookie", test))]
    #[test]
    fn test_convert_to_cookie() {
        let converted: Vec<cookie::Cookie> = Cookies::try_from(COOKIE_TXT).unwrap().into();
        let exp = vec![
            cookie::Cookie::build(("foo", "bar"))
                .domain(".example.com")
                .path("/")
                .secure(true)
                .http_only(false)
                .expires(cookie::Expiration::Session)
                .build(),
            cookie::Cookie::build(("foo2", "bar2"))
                .domain(".example.com")
                .path("/")
                .secure(true)
                .http_only(false)
                .expires(time::OffsetDateTime::from_unix_timestamp(1740743335).unwrap())
                .build(),
            cookie::Cookie::build(("foo3", "bar3"))
                .domain(".example.com")
                .path("/")
                .secure(true)
                .http_only(true)
                .expires(time::OffsetDateTime::from_unix_timestamp(1740743335).unwrap())
                .build(),
        ];
        assert_eq!(converted, exp);
    }
}