vsd 0.5.0

A command-line utility and library for downloading streams from DASH manifests and HLS playlists.
Documentation
//! Parser and representation of HTTP cookies in [Netscape](https://curl.se/docs/http-cookies.html) format.

use chrono::{TimeZone, Utc};
use reqwest::{Url, cookie::Jar};
use std::str;

/// Represents a single HTTP cookie parsed from a Netscape cookie file.
#[derive(Clone, Debug)]
pub struct Cookie<'a> {
    domain: &'a str,
    #[allow(unused)]
    include_subdomains: bool,
    path: &'a str,
    secure: bool,
    expires: i64,
    name: &'a str,
    value: &'a str,
    http_only: bool,
}

/// A wrapper around a collection of parsed [`Cookie`]s.
#[derive(Clone, Debug)]
pub struct Cookies<'a>(Vec<Cookie<'a>>);

impl<'a> std::ops::Deref for Cookies<'a> {
    type Target = Vec<Cookie<'a>>;

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

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

impl<'a> Cookies<'a> {
    /// Parses a byte slice of a Netscape cookie file.
    ///
    /// # Errors
    ///
    /// Returns [`ParseError`] if columns are invalid, booleans/integers are malformed,
    /// or UTF-8 parsing fails.
    pub fn parse(input: &'a [u8]) -> Result<Self, ParseError> {
        let mut cookies = Vec::new();
        let mut line_num = 0;
        let mut start = 0;

        while start < input.len() {
            line_num += 1;
            let end = input[start..]
                .iter()
                .position(|&b| b == b'\n')
                .map(|p| start + p)
                .unwrap_or(input.len());
            let mut line = &input[start..end];
            start = end + 1;
            if line.ends_with(b"\r") {
                line = &line[..line.len() - 1];
            }
            if line.is_empty() {
                continue;
            }

            let (http_only, effective) = match line {
                line if line.starts_with(b"#HttpOnly_") => (true, &line[10..]),
                line if line.starts_with(b"#") => continue,
                line => (false, line),
            };

            let parts = effective.split(|&b| b == b'\t').collect::<Vec<_>>();
            if parts.len() != 7 {
                return Err(ParseError::InvalidColumnParams(line_num, parts.len()));
            }

            cookies.push(Cookie {
                domain: str::from_utf8(parts[0])?,
                include_subdomains: match str::from_utf8(parts[1])? {
                    "TRUE" | "true" => true,
                    "FALSE" | "false" => false,
                    s => return Err(ParseError::InvalidBoolean(line_num, s.to_owned())),
                },
                path: str::from_utf8(parts[2])?,
                secure: match str::from_utf8(parts[3])? {
                    "TRUE" | "true" => true,
                    "FALSE" | "false" => false,
                    s => return Err(ParseError::InvalidBoolean(line_num, s.to_owned())),
                },
                expires: str::from_utf8(parts[4])?.parse::<i64>().map_err(|_| {
                    ParseError::InvalidInteger(
                        line_num,
                        str::from_utf8(parts[4]).unwrap().to_owned(),
                    )
                })?,
                name: str::from_utf8(parts[5])?,
                value: str::from_utf8(parts[6])?,
                http_only,
            });
        }

        Ok(Cookies(cookies))
    }

    /// Serializes the collection of cookies back into a Netscape cookie file format string.
    pub fn as_netscape(&self) -> String {
        let mut out = "# Netscape HTTP Cookie File\n\
        # https://curl.se/docs/http-cookies.html\n\
        # This file was generated by vsd! Edit at your own risk.\n\n"
            .to_owned();
        for c in &self.0 {
            out.push_str(&c.as_netscape());
            out.push('\n');
        }
        out
    }

    /// Converts the cookies into a `reqwest::cookie::Jar` for use in HTTP requests.
    pub fn as_jar(&self) -> Jar {
        let jar = Jar::default();

        for cookie in &self.0 {
            jar.add_cookie_str(&cookie.as_header(), &cookie.url().parse::<Url>().unwrap());
        }

        jar
    }
}

impl<'a> Cookie<'a> {
    /// Formats the cookie as a standard HTTP `Set-Cookie` header value.
    pub fn as_header(&self) -> String {
        let mut h = format!("{}={}", self.name, self.value);
        if !self.domain.is_empty() {
            h.push_str(&format!("; Domain={}", self.domain));
        }
        if !self.path.is_empty() {
            h.push_str(&format!("; Path={}", self.path));
        }
        if self.expires > 0
            && let Some(dt) = Utc.timestamp_opt(self.expires, 0).single()
        {
            h.push_str(&format!(
                "; Expires={}",
                dt.format("%a, %d %b %Y %H:%M:%S GMT")
            ));
        }
        if self.secure {
            h.push_str("; Secure");
        }
        if self.http_only {
            h.push_str("; HttpOnly");
        }
        h
    }

    fn as_netscape(&self) -> String {
        format!(
            "{}\t{}\t{}\t{}\t{}\t{}\t{}",
            if self.http_only {
                format!("#HttpOnly_{}", self.domain)
            } else {
                self.domain.to_owned()
            },
            if self.include_subdomains {
                "TRUE"
            } else {
                "FALSE"
            },
            self.path,
            if self.secure { "TRUE" } else { "FALSE" },
            self.expires,
            self.name,
            self.value
        )
    }

    /// Generates a valid absolute URL string for the cookie based on its secure flag, domain, and path.
    pub fn url(&self) -> String {
        format!(
            "{}://{}{}",
            if self.secure { "https" } else { "http" },
            self.domain.strip_prefix('.').unwrap_or(self.domain),
            if self.path.starts_with('/') {
                self.path
            } else {
                "/"
            }
        )
    }
}

/// Represents errors that can occur while parsing a Netscape cookie file.
#[derive(Debug)]
pub enum ParseError {
    /// Failed to parse a boolean value from the true/false field.
    InvalidBoolean(usize, String),
    /// The line contains an invalid number of columns (Netscape format requires 7 columns).
    InvalidColumnParams(usize, usize),
    /// Failed to parse the expiration timestamp as an integer.
    InvalidInteger(usize, String),
    /// UTF-8 decode error.
    Utf8Error(str::Utf8Error),
}

impl std::error::Error for ParseError {}

impl std::fmt::Display for ParseError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            ParseError::InvalidBoolean(l, v) => write!(f, "Line {} bool: {}", l, v),
            ParseError::InvalidColumnParams(l, c) => write!(f, "Line {} cols: {}", l, c),
            ParseError::InvalidInteger(l, v) => write!(f, "Line {} int: {}", l, v),
            ParseError::Utf8Error(e) => write!(f, "UTF-8 error: {}", e),
        }
    }
}

impl From<str::Utf8Error> for ParseError {
    fn from(err: str::Utf8Error) -> Self {
        ParseError::Utf8Error(err)
    }
}

#[cfg(feature = "capture")]
use chromiumoxide::cdp::browser_protocol::network::{
    Cookie as BrowserCookie, CookieParam, TimeSinceEpoch,
};

#[cfg(feature = "capture")]
impl<'a> From<&'a Vec<CookieParam>> for Cookies<'a> {
    fn from(value: &'a Vec<CookieParam>) -> Self {
        Cookies(
            value
                .iter()
                .map(|c| Cookie {
                    domain: c.domain.as_deref().unwrap_or(""),
                    include_subdomains: false,
                    path: c.path.as_deref().unwrap_or(""),
                    secure: c.secure.unwrap_or(false),
                    expires: c.expires.as_ref().map(|x| *x.inner() as i64).unwrap_or(0),
                    name: &c.name,
                    value: &c.value,
                    http_only: c.http_only.unwrap_or(false),
                })
                .collect(),
        )
    }
}

#[cfg(feature = "capture")]
impl<'a> From<&'a Vec<BrowserCookie>> for Cookies<'a> {
    fn from(value: &'a Vec<BrowserCookie>) -> Self {
        Cookies(
            value
                .iter()
                .map(|c| Cookie {
                    domain: &c.domain,
                    include_subdomains: false,
                    path: &c.path,
                    secure: c.secure,
                    expires: c.expires as i64,
                    name: &c.name,
                    value: &c.value,
                    http_only: c.http_only,
                })
                .collect(),
        )
    }
}

#[cfg(feature = "capture")]
impl<'a> From<Cookies<'a>> for Vec<CookieParam> {
    fn from(val: Cookies<'a>) -> Self {
        val.0
            .into_iter()
            .map(|c| CookieParam {
                name: c.name.to_owned(),
                value: c.value.to_owned(),
                url: Some(c.url()),
                domain: if c.domain.is_empty() {
                    None
                } else {
                    Some(c.domain.to_owned())
                },
                path: if c.path.is_empty() {
                    None
                } else {
                    Some(c.path.to_owned())
                },
                secure: Some(c.secure),
                http_only: Some(c.http_only),
                same_site: None,
                expires: Some(TimeSinceEpoch::new(c.expires as f64)),
                priority: None,
                same_party: None,
                source_scheme: None,
                source_port: None,
                partition_key: None,
            })
            .collect()
    }
}

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

    #[test]
    fn parse() {
        let input = b"# Netscape HTTP Cookie File\n\
                     # This is a comment\n\
                     \n\
                     example.com\tTRUE\t/\tFALSE\t1716672000\tfoo\tbar\n\
                     #HttpOnly_secure.com\tFALSE\t/path\tTRUE\t0\tsession\tsecret\n";

        let cookies = Cookies::parse(input).unwrap();
        assert_eq!(cookies.0.len(), 2);

        let c1 = &cookies.0[0];
        assert_eq!(c1.domain, "example.com");
        assert!(c1.include_subdomains);
        assert_eq!(c1.path, "/");
        assert!(!c1.secure);
        assert_eq!(c1.expires, 1716672000);
        assert_eq!(c1.name, "foo");
        assert_eq!(c1.value, "bar");
        assert!(!c1.http_only);

        let c2 = &cookies.0[1];
        assert_eq!(c2.domain, "secure.com");
        assert!(!c2.include_subdomains);
        assert_eq!(c2.path, "/path");
        assert!(c2.secure);
        assert_eq!(c2.expires, 0);
        assert_eq!(c2.name, "session");
        assert_eq!(c2.value, "secret");
        assert!(c2.http_only);
    }

    #[test]
    fn header() {
        let c1 = Cookie {
            domain: "example.com",
            include_subdomains: true,
            path: "/path",
            secure: true,
            expires: 1716672000,
            name: "foo",
            value: "bar",
            http_only: true,
        };
        let header = c1.as_header();
        assert!(header.contains("foo=bar"));
        assert!(header.contains("; Domain=example.com"));
        assert!(header.contains("; Path=/path"));
        assert!(header.contains("; Expires=Sat, 25 May 2024 21:20:00 GMT"));
        assert!(header.contains("; Secure"));
        assert!(header.contains("; HttpOnly"));
    }

    #[test]
    fn url() {
        let c1 = Cookie {
            domain: ".example.com",
            include_subdomains: true,
            path: "/path",
            secure: true,
            expires: 0,
            name: "foo",
            value: "bar",
            http_only: false,
        };
        assert_eq!(c1.url(), "https://example.com/path");

        let c2 = Cookie {
            domain: "example.com",
            include_subdomains: false,
            path: "no_slash",
            secure: false,
            expires: 0,
            name: "foo",
            value: "bar",
            http_only: false,
        };
        assert_eq!(c2.url(), "http://example.com/");
    }
}