use super::*;
use std::io::{self, BufRead, BufReader, Cursor, Read};
struct FailingReader;
impl Read for FailingReader {
fn read(&mut self, _buffer: &mut [u8]) -> io::Result<usize> {
Err(io::Error::other("read failed"))
}
}
impl BufRead for FailingReader {
fn fill_buf(&mut self) -> io::Result<&[u8]> {
Err(io::Error::other("read failed"))
}
fn consume(&mut self, _amount: usize) {}
}
struct OneChunkThenFail {
chunk: Cursor<Vec<u8>>,
}
impl OneChunkThenFail {
fn new(input: impl AsRef<[u8]>) -> Self {
Self {
chunk: Cursor::new(input.as_ref().to_vec()),
}
}
}
impl Read for OneChunkThenFail {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
if self.chunk.position() < self.chunk.get_ref().len() as u64 {
self.chunk.read(buffer)
} else {
Err(io::Error::other("read failed"))
}
}
}
const TEST1561_JAR: &str = "\
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
.example.com\tTRUE\t/foo\tFALSE\t0\tpublic\tyes
#HttpOnly_.example.com\tTRUE\t/15\tFALSE\t0\tsuper\tplain
www.example.com\tFALSE\t/\tTRUE\t0\t__Host-SID\t12346
.example.com\tTRUE\t/\tTRUE\t0\tsupersupersuper\tsecret
.example.com\tTRUE\t/\tTRUE\t0\t__SecURE-SID\t12346
.example.com\tTRUE\t/1561/login\tTRUE\t0\tsupersuper\tsecret
.example.com\tTRUE\t/1561\tTRUE\t0\tsuper\tsecret
";
const NESCOOKIE_JAR: &str = "\
# HTTP Cookie File downloaded with cookies.txt by Genuinous @genuinous
# This file can be used by wget, curl, aria2c and other standard compliant tools.
# Usage Examples:
# 1) wget -x --load-cookies cookies.txt \"https://www.pixiv.net/\"
# 2) curl --cookie cookies.txt \"https://www.pixiv.net/\"
# 3) aria2c --load-cookies cookies.txt \"https://www.pixiv.net/\"
#
www.pixiv.net\tFALSE\t/\tTRUE\t1689731332\tfirst_visit_datetime_pc\t2021-07-19+10%3A48%3A50
#HttpOnly_.pixiv.net\tTRUE\t/\tTRUE\t1626662932\tPHPSESSID\tj6amv2igf0cec4fdtld5rre5ud7ig3l2
.pixiv.net\tTRUE\t/\tTRUE\t1784339332\tp_ab_id\t7
.pixiv.net\tTRUE\t/\tTRUE\t1784339332\tp_ab_id_2\t9
.pixiv.net\tTRUE\t/\tTRUE\t1784339332\tp_ab_d_id\t620724492
www.pixiv.net\tFALSE\t/\tTRUE\t1689731332\tyuid_b\tFBdWQEY
";
fn reader(input: impl AsRef<[u8]>) -> BufReader<Cursor<Vec<u8>>> {
BufReader::new(Cursor::new(input.as_ref().to_vec()))
}
fn cookie_by_name<'a>(cookies: &'a [Cookie], name: &[u8]) -> Option<&'a Cookie> {
cookies.iter().find(|cookie| cookie.name == name)
}
#[test]
fn parses_curl_test1561_cookie_jar_from_bufread() {
let cookies = parse(reader(TEST1561_JAR)).unwrap();
assert_eq!(cookies.len(), 7);
assert_eq!(
cookies[0],
Cookie {
domain: b"example.com".to_vec(),
tail_match: true,
path: b"/foo".to_vec(),
secure: false,
expires: 0,
name: b"public".to_vec(),
value: b"yes".to_vec(),
http_only: false,
prefix: CookiePrefix::None,
}
);
assert!(cookies[1].http_only);
assert_eq!(cookies[1].domain, b"example.com");
assert_eq!(cookies[1].path, b"/15");
assert_eq!(cookies[1].name, b"super");
assert_eq!(cookies[1].value, b"plain");
assert_eq!(cookies[2].prefix, CookiePrefix::Host);
assert!(cookies[2].secure);
assert_eq!(cookies[4].prefix, CookiePrefix::None);
assert_eq!(cookies[4].name, b"__SecURE-SID");
}
#[test]
fn parser_iterates_cookies() {
let mut parser = NetscapeCookieParser::new(reader(TEST1561_JAR));
assert_eq!(parser.next().unwrap().unwrap().name, b"public");
assert_eq!(parser.next().unwrap().unwrap().name, b"super");
assert_eq!(parser.collect::<Result<Vec<_>, _>>().unwrap().len(), 5);
}
#[test]
fn parser_is_done_after_eof() {
let mut parser = NetscapeCookieParser::new(reader("example.com\tFALSE\t/\tFALSE\t0\tone\t1"));
assert_eq!(parser.next().unwrap().unwrap().name, b"one");
assert!(parser.next().is_none());
assert!(parser.next().is_none());
}
#[test]
fn parses_semantic_cases_from_multiple_curl_cookie_jars() {
let input = "\
# Netscape HTTP Cookie File
.CURL.CO.UK\tTRUE\t/\tFALSE\t0\tfine\tyesyes
domain..tld\tFALSE\t/want\tFALSE\t22139150993\tmooo\tindeed
#HttpOnly_127.0.0.1\tFALSE\t/func_test\tFALSE\t21709598616\tmycookie2\t5900
.test.curl\tTRUE\t/WE/WANT\tFALSE\t0\tupper\tvalue
.test.curl\tTRUE\t/we/want\tFALSE\t0\tlower\tvalue
example.com\tFALSE\t/\tFALSE\t0\thas_js\t1
";
let cookies = parse(reader(input)).unwrap();
assert_eq!(cookies.len(), 6);
assert_eq!(cookies[0].domain, b"CURL.CO.UK");
assert!(cookies[0].tail_match);
assert_eq!(cookies[1].domain, b"domain..tld");
assert_eq!(cookies[1].expires, 22_139_150_993);
assert!(cookies[2].http_only);
assert_eq!(cookies[2].domain, b"127.0.0.1");
assert_eq!(cookies[2].expires, 21_709_598_616);
assert_eq!(cookies[3].path, b"/WE/WANT");
assert_eq!(cookies[4].path, b"/we/want");
assert_eq!(cookies[5].name, b"has_js");
}
#[test]
fn parses_nescookie_fixture() {
let cookies = parse(reader(NESCOOKIE_JAR)).unwrap();
assert_eq!(cookies.len(), 6);
let first_visit = cookie_by_name(&cookies, b"first_visit_datetime_pc").unwrap();
assert_eq!(first_visit.value, b"2021-07-19+10%3A48%3A50");
assert!(!first_visit.http_only);
let p_ab_id = cookie_by_name(&cookies, b"p_ab_id").unwrap();
assert!(p_ab_id.secure);
let session = cookie_by_name(&cookies, b"PHPSESSID").unwrap();
assert_eq!(session.expires, 1_626_662_932);
assert!(session.http_only);
let yuid = cookie_by_name(&cookies, b"yuid_b").unwrap();
assert_eq!(yuid.path, b"/");
}
#[test]
fn supports_httponly_without_leading_dot() {
let cookie =
parse_line("#HttpOnly_domain..tld\tFALSE\t/want\tFALSE\t2139150993\tmooo2\tindeed2")
.unwrap()
.unwrap();
assert!(cookie.http_only);
assert_eq!(cookie.domain, b"domain..tld");
assert_eq!(cookie.expires, 2_139_150_993);
}
#[test]
fn skips_ordinary_comments_blank_lines_and_httponly_comments() {
assert!(parse_line("# plain comment").unwrap().is_none());
assert!(parse_line("").unwrap().is_none());
assert!(parse_line("#HttpOnly_# still a comment").unwrap().is_none());
}
#[test]
fn skips_newline_only_inputs() {
assert!(parse_line("\n").unwrap().is_none());
assert!(parse_line("\r\n").unwrap().is_none());
assert!(parse_line("\r").unwrap().is_none());
}
#[test]
fn skips_whitespace_only_inputs() {
assert!(parse_line(" \t \n").unwrap().is_none());
}
#[test]
fn httponly_prefix_is_case_sensitive() {
assert!(
parse_line("#httponly_example.com\tFALSE\t/\tFALSE\t0\tname\tvalue")
.unwrap()
.is_none()
);
}
#[test]
fn leading_whitespace_before_hash_is_not_a_comment() {
let error = parse_line(" # comment").unwrap_err();
assert!(matches!(error, ParseErrorKind::MissingFields { found: 1 }));
}
#[test]
fn strips_only_one_leading_domain_dot() {
let cookie = parse_line("..example.com\tTRUE\t/\tFALSE\t0\tname\tvalue")
.unwrap()
.unwrap();
assert_eq!(cookie.domain, b".example.com");
}
#[test]
fn accepts_empty_cookie_name() {
let cookie = parse_line("example.com\tFALSE\t/\tFALSE\t0\t\tvalue")
.unwrap()
.unwrap();
assert_eq!(cookie.name, b"");
assert_eq!(cookie.value, b"value");
}
#[test]
fn accepts_missing_value_as_empty_value() {
let cookie = parse_line("domain..tld\tFALSE\t/want\tFALSE\t0\tempty")
.unwrap()
.unwrap();
assert_eq!(cookie.name, b"empty");
assert_eq!(cookie.value, b"");
}
#[test]
fn accepts_trailing_tab_as_empty_value() {
let cookie = parse_line("domain..tld\tFALSE\t/want\tFALSE\t0\tempty\t")
.unwrap()
.unwrap();
assert_eq!(cookie.name, b"empty");
assert_eq!(cookie.value, b"");
}
#[test]
fn accepts_empty_name_and_empty_value_together() {
let cookie = parse_line("example.com\tFALSE\t/\tFALSE\t0\t\t")
.unwrap()
.unwrap();
assert_eq!(cookie.name, b"");
assert_eq!(cookie.value, b"");
}
#[test]
fn accepts_large_curl_style_name_and_value() {
let name = "F".repeat(4094);
let value = "z".repeat(3998);
let line = format!("127.0.0.1\tFALSE\t/\tFALSE\t0\t{name}\t{value}");
let cookie = parse_line(&line).unwrap().unwrap();
assert_eq!(cookie.name.len(), 4094);
assert_eq!(cookie.value.len(), 3998);
}
#[test]
fn accepts_lines_longer_than_curl_limit_because_parser_already_read_the_line() {
let name = "n".repeat(6000);
let line = format!("example.com\tFALSE\t/\tFALSE\t0\t{name}\tvalue");
let cookie = parse_line(&line).unwrap().unwrap();
assert_eq!(cookie.name.len(), 6000);
}
#[test]
fn preserves_non_utf8_and_high_bit_cookie_bytes() {
let mut line = b"example.com\tFALSE\t/\tFALSE\t0\tna".to_vec();
line.extend_from_slice(&[0x80, 0xff]);
line.extend_from_slice(b"me\tva");
line.extend_from_slice(&[0xfe, 0x81]);
line.extend_from_slice(b"lue");
let cookie = parse_line(&line).unwrap().unwrap();
assert_eq!(cookie.name, b"na\x80\xffme");
assert_eq!(cookie.value, b"va\xfe\x81lue");
}
#[test]
fn parses_non_utf8_bytes_from_bufread() {
let input = b"example.com\tFALSE\t/\x80path\tFALSE\t0\tname\tvalue\xff\n";
let cookies = parse(reader(input)).unwrap();
assert_eq!(cookies.len(), 1);
assert_eq!(cookies[0].path, b"/\x80path");
assert_eq!(cookies[0].value, b"value\xff");
}
#[test]
fn accepts_spaces_quotes_commas_and_semicolons_in_name_or_value() {
let cookie = parse_line("example.com\tFALSE\t/\tFALSE\t0\tname with space\tyes, \"ok\"; fine")
.unwrap()
.unwrap();
assert_eq!(cookie.name, b"name with space");
assert_eq!(cookie.value, b"yes, \"ok\"; fine");
}
#[test]
fn preserves_path_field() {
let cookie = parse_line("example.com\tFALSE\t/account/\tFALSE\t0\tname\tvalue")
.unwrap()
.unwrap();
assert_eq!(cookie.path, b"/account/");
}
#[test]
fn treats_boolean_path_field_as_missing_path_like_curl() {
let cookie = parse_line("example.com\tFALSE\tTRUE\t0\tname\tvalue")
.unwrap()
.unwrap();
assert_eq!(cookie.path, b"/");
assert!(cookie.secure);
assert_eq!(cookie.expires, 0);
assert_eq!(cookie.name, b"name");
assert_eq!(cookie.value, b"value");
}
#[test]
fn treats_empty_legacy_path_field_as_missing_path_like_curl() {
let cookie = parse_line("example.com\tFALSE\t\t0\tname\tvalue")
.unwrap()
.unwrap();
assert_eq!(cookie.path, b"/");
assert!(!cookie.secure);
assert_eq!(cookie.expires, 0);
assert_eq!(cookie.name, b"name");
}
#[test]
fn rejects_standard_record_with_empty_path_like_curl() {
let error = parse_line("example.com\tFALSE\t\tFALSE\t0\tname\tvalue").unwrap_err();
assert!(matches!(error, ParseErrorKind::MissingFields { found: 8 }));
}
#[test]
fn legacy_missing_path_detection_matches_curl_prefix_behavior() {
let cookie = parse_line("example.com\tFALSE\tFALS\t0\tname\tvalue")
.unwrap()
.unwrap();
assert_eq!(cookie.path, b"/");
assert_eq!(cookie.expires, 0);
assert_eq!(cookie.name, b"name");
}
#[test]
fn legacy_missing_path_detection_is_case_sensitive_and_not_longer_than_bool_literal() {
let error = parse_line("example.com\tFALSE\ttrue\t0\tname\tvalue").unwrap_err();
let longer_error = parse_line("example.com\tFALSE\tFALSEX\t0\tname\tvalue").unwrap_err();
assert!(matches!(error, ParseErrorKind::InvalidExpires));
assert!(matches!(longer_error, ParseErrorKind::InvalidExpires));
}
#[test]
fn preserves_paths_that_curl_would_sanitize() {
let quoted = parse_line("example.com\tFALSE\t\"/quoted/\"\tFALSE\t0\tname\tvalue")
.unwrap()
.unwrap();
let relative = parse_line("example.com\tFALSE\trelative\tFALSE\t0\tname\tvalue")
.unwrap()
.unwrap();
let trailing = parse_line("example.com\tFALSE\t/a/b/\tFALSE\t0\tname\tvalue")
.unwrap()
.unwrap();
assert_eq!(quoted.path, b"\"/quoted/\"");
assert_eq!(relative.path, b"relative");
assert_eq!(trailing.path, b"/a/b/");
}
#[test]
fn parses_crlf_line_endings() {
let cookie = parse_line("example.com\tFALSE\t/\tFALSE\t0\tname\tvalue\r\n")
.unwrap()
.unwrap();
assert_eq!(cookie.name, b"name");
assert_eq!(cookie.value, b"value");
}
#[test]
fn parses_bare_cr_line_endings() {
let cookie = parse_line("example.com\tFALSE\t/\tFALSE\t0\tname\tvalue\r")
.unwrap()
.unwrap();
assert_eq!(cookie.name, b"name");
assert_eq!(cookie.value, b"value");
}
#[test]
fn parses_cookie_flags_case_insensitively() {
let cookie = parse_line("example.com\ttRuE\t/\ttRuE\t0\tname\tvalue")
.unwrap()
.unwrap();
assert!(cookie.tail_match);
assert!(cookie.secure);
}
#[test]
fn treats_non_true_cookie_flags_as_false() {
let cookie = parse_line("example.com\tYES\t/\t1\t0\tname\tvalue")
.unwrap()
.unwrap();
assert!(!cookie.tail_match);
assert!(!cookie.secure);
}
#[test]
fn detects_cookie_prefixes_case_sensitively() {
let secure = parse_line("example.com\tFALSE\t/\tTRUE\t0\t__Secure-SID\t123")
.unwrap()
.unwrap();
let host = parse_line("example.com\tFALSE\t/\tTRUE\t0\t__Host-SID\t123")
.unwrap()
.unwrap();
let mixed_case = parse_line("example.com\tFALSE\t/\tTRUE\t0\t__SecURE-SID\t123")
.unwrap()
.unwrap();
assert_eq!(secure.prefix, CookiePrefix::Secure);
assert_eq!(host.prefix, CookiePrefix::Host);
assert_eq!(mixed_case.prefix, CookiePrefix::None);
}
#[test]
fn reports_malformed_lines_with_line_number() {
let error = parse(reader("# ok\nexample.com\tFALSE\n")).unwrap_err();
assert_eq!(error.line, 2);
assert!(matches!(
error.kind,
ParseErrorKind::MissingFields { found: 2 }
));
}
#[test]
fn reports_io_errors_from_bufread() {
let error = parse(FailingReader).unwrap_err();
assert_eq!(error.line, 1);
assert!(matches!(error.kind, ParseErrorKind::Io(_)));
}
#[test]
fn lossy_parser_still_reports_io_errors() {
let error = parse_lossy(FailingReader).unwrap_err();
assert_eq!(error.line, 1);
assert!(matches!(error.kind, ParseErrorKind::Io(_)));
}
#[test]
fn lossy_parser_skips_malformed_lines() {
let cookies = parse_lossy(reader(
"example.com\tFALSE\n\
example.com\tFALSE\t/\tFALSE\tnot-a-number\tname\tvalue\n\
example.com\tFALSE\t/\tFALSE\t0\tname\tvalue\n",
))
.unwrap();
assert_eq!(cookies.len(), 1);
assert_eq!(cookies[0].name, b"name");
}
#[test]
fn lossy_parser_preserves_valid_cookie_order_around_malformed_lines() {
let cookies = parse_lossy(reader(
"example.com\tFALSE\t/\tFALSE\t0\tfirst\t1\n\
example.com\tFALSE\n\
example.com\tFALSE\t/\tFALSE\t18446744073709551616\tbad\tvalue\n\
example.com\tFALSE\t/\tFALSE\t0\tsecond\t2\n",
))
.unwrap();
assert_eq!(cookies.len(), 2);
assert_eq!(cookies[0].name, b"first");
assert_eq!(cookies[1].name, b"second");
}
#[test]
fn iterator_can_continue_after_parse_error() {
let mut parser = NetscapeCookieParser::new(reader(
"example.com\tFALSE\t/\tFALSE\t0\tfirst\tvalue\n\
example.com\tFALSE\n\
example.com\tFALSE\t/\tFALSE\t0\tsecond\tvalue\n",
));
assert_eq!(parser.next().unwrap().unwrap().name, b"first");
assert!(parser.next().unwrap().is_err());
assert_eq!(parser.next().unwrap().unwrap().name, b"second");
assert!(parser.next().is_none());
}
#[test]
fn parser_handles_final_line_without_trailing_newline() {
let cookies = parse(reader(
"# Netscape HTTP Cookie File\nexample.com\tFALSE\t/\tFALSE\t0\tlast\tvalue",
))
.unwrap();
assert_eq!(cookies.len(), 1);
assert_eq!(cookies[0].name, b"last");
}
#[test]
fn parser_reports_io_error_after_buffered_lines() {
let input = OneChunkThenFail::new(
"example.com\tFALSE\t/\tFALSE\t0\tfirst\tvalue\n\
# comment\n",
);
let mut parser = NetscapeCookieParser::new(BufReader::new(input));
assert_eq!(parser.next().unwrap().unwrap().name, b"first");
let error = parser.next().unwrap().unwrap_err();
assert_eq!(error.line, 3);
assert!(matches!(error.kind, ParseErrorKind::Io(_)));
assert!(parser.next().is_none());
}
#[test]
fn rejects_too_few_or_too_many_fields() {
let too_few = parse_line("example.com\tFALSE").unwrap_err();
let too_many = parse_line("example.com\tFALSE\t/\tFALSE\t0\tname\tvalue\textra").unwrap_err();
assert!(matches!(
too_few,
ParseErrorKind::MissingFields { found: 2 }
));
assert!(matches!(
too_many,
ParseErrorKind::MissingFields { found: 8 }
));
}
#[test]
fn rejects_invalid_expires_values() {
for expires in ["", "not-a-number", "-1", "18446744073709551616"] {
let line = format!("example.com\tFALSE\t/\tFALSE\t{expires}\tname\tvalue");
let error = parse_line(&line).unwrap_err();
assert!(matches!(error, ParseErrorKind::InvalidExpires));
}
}
#[test]
fn accepts_u64_max_expires_and_leading_zeroes() {
let max = parse_line("example.com\tFALSE\t/\tFALSE\t18446744073709551615\tname\tvalue")
.unwrap()
.unwrap();
let leading_zeroes = parse_line("example.com\tFALSE\t/\tFALSE\t00042\tname\tvalue")
.unwrap()
.unwrap();
assert_eq!(max.expires, u64::MAX);
assert_eq!(leading_zeroes.expires, 42);
}
#[test]
fn rejects_control_octets_in_name_or_value() {
let name_error = parse_line("example.com\tFALSE\t/\tFALSE\t0\tna\u{7f}me\tvalue").unwrap_err();
let value_error = parse_line("example.com\tFALSE\t/\tFALSE\t0\tname\tva\u{1f}lue").unwrap_err();
let nul_error = parse_line(b"example.com\tFALSE\t/\tFALSE\t0\tname\tva\0lue").unwrap_err();
assert!(matches!(name_error, ParseErrorKind::InvalidOctets));
assert!(matches!(value_error, ParseErrorKind::InvalidOctets));
assert!(matches!(nul_error, ParseErrorKind::InvalidOctets));
}
#[test]
fn displays_parse_errors() {
let error = ParseError {
line: 9,
kind: ParseErrorKind::InvalidExpires,
};
assert_eq!(error.to_string(), "line 9: invalid expires timestamp");
assert_eq!(
ParseErrorKind::MissingFields { found: 3 }.to_string(),
"expected 7 tab-separated fields, found 3"
);
assert_eq!(
ParseErrorKind::InvalidOctets.to_string(),
"cookie name or value contains control octets"
);
assert_eq!(
ParseErrorKind::Io(io::Error::other("read failed")).to_string(),
"read error: read failed"
);
}
#[test]
fn exposes_io_error_source_only_for_io_errors() {
let io_error = ParseError {
line: 1,
kind: ParseErrorKind::Io(io::Error::other("read failed")),
};
let parse_error = ParseError {
line: 1,
kind: ParseErrorKind::InvalidExpires,
};
assert_eq!(
std::error::Error::source(&io_error).unwrap().to_string(),
"read failed"
);
assert!(std::error::Error::source(&parse_error).is_none());
}