1use std::fs;
2use std::path::Path;
3use std::sync::RwLock;
4use std::time::{Duration, SystemTime};
5
6use crate::error::{Aria2Error, Result};
7use crate::http::cookie::Cookie;
8use serde::{Deserialize, Serialize};
9
10pub struct CookieStorage {
17 cookies: RwLock<Vec<Cookie>>,
18}
19
20impl CookieStorage {
21 pub fn new() -> Self {
22 Self {
23 cookies: RwLock::new(Vec::new()),
24 }
25 }
26
27 pub fn add(&self, cookie: Cookie) {
28 let mut cookies = self.cookies.write().unwrap_or_else(|e| e.into_inner());
29 if let Some(pos) = cookies.iter().position(|c| c == &cookie) {
30 cookies[pos] = cookie;
31 } else {
32 cookies.push(cookie);
33 }
34 }
35
36 pub fn find_cookies(&self, host: &str, path: &str, secure: bool) -> Vec<Cookie> {
37 let cookies = self.cookies.read().unwrap_or_else(|e| e.into_inner());
38 let date = SystemTime::now()
39 .duration_since(SystemTime::UNIX_EPOCH)
40 .unwrap_or_default()
41 .as_secs() as i64;
42 cookies
43 .iter()
44 .filter(|c| c.match_request(host, path, date, secure))
45 .cloned()
46 .collect()
47 }
48
49 pub fn find_cookies_for_url(&self, url: &reqwest::Url) -> Vec<Cookie> {
50 let host = url.host_str().unwrap_or("");
51 let path = url.path();
52 let scheme = url.scheme();
53 let secure = scheme == "https";
54 self.find_cookies(host, path, secure)
55 }
56
57 pub fn expire_cookies(&self, base_time: i64) -> usize {
58 let mut cookies = self.cookies.write().unwrap_or_else(|e| e.into_inner());
59 let before = cookies.len();
60 cookies.retain(|c| !c.is_expired(base_time));
61 before - cookies.len()
62 }
63
64 pub fn count(&self) -> usize {
65 self.cookies.read().unwrap_or_else(|e| e.into_inner()).len()
66 }
67
68 pub fn load_file(&self, path: &Path) -> Result<usize> {
69 let data = fs::read_to_string(path).map_err(|e| Aria2Error::Io(e.to_string()))?;
70 let parsed = crate::http::ns_cookie_parser::NsCookieParser::parse_str(&data)?;
71 let n = parsed.len();
72 let mut cookies = self.cookies.write().unwrap_or_else(|e| e.into_inner());
73 for cookie in parsed {
74 if let Some(pos) = cookies.iter().position(|c| *c == cookie) {
75 cookies[pos] = cookie;
76 } else {
77 cookies.push(cookie);
78 }
79 }
80 Ok(n)
81 }
82
83 pub fn save_file(&self, path: &Path) -> Result<()> {
84 let cookies = self.cookies.read().unwrap_or_else(|e| e.into_inner());
85 let mut lines = Vec::with_capacity(cookies.len() + 3);
86 lines.push("# Netscape HTTP Cookie File".to_string());
87 lines.push("# This file is generated by aria2-rust".to_string());
88 for c in cookies.iter() {
89 lines.push(c.to_netscape_line());
90 }
91 fs::write(path, lines.join("\n")).map_err(|e| Aria2Error::Io(e.to_string()))?;
92 Ok(())
93 }
94
95 pub fn clear(&self) {
96 self.cookies
97 .write()
98 .unwrap_or_else(|e| e.into_inner())
99 .clear();
100 }
101
102 pub fn to_header_string(&self, host: &str, path: &str, secure: bool) -> String {
103 let cookies = self.find_cookies(host, path, secure);
104 if cookies.is_empty() {
105 return String::new();
106 }
107 cookies
108 .iter()
109 .map(|c| format!("{}={}", c.name, c.value))
110 .collect::<Vec<_>>()
111 .join("; ")
112 }
113
114 pub fn is_empty(&self) -> bool {
115 self.cookies
116 .read()
117 .unwrap_or_else(|e| e.into_inner())
118 .is_empty()
119 }
120}
121
122impl Default for CookieStorage {
123 fn default() -> Self {
124 Self::new()
125 }
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct JarCookie {
138 pub name: String,
140 pub value: String,
142 pub domain: String,
144 pub path: String,
146 pub expires: Option<SystemTime>,
148 pub secure: bool,
150 pub http_only: bool,
152 pub creation_time: SystemTime,
154}
155
156impl JarCookie {
157 pub fn new(name: &str, value: &str, domain: &str) -> Self {
165 Self {
166 name: name.to_string(),
167 value: value.to_string(),
168 domain: domain.to_string(),
169 path: "/".to_string(),
170 expires: None,
171 secure: false,
172 http_only: false,
173 creation_time: SystemTime::now(),
174 }
175 }
176
177 pub fn matches_url(&self, url: &str, is_secure: bool) -> bool {
194 if self.secure && !is_secure {
196 return false;
197 }
198
199 let url_lower = url.to_lowercase();
201 let domain_lower = self.domain.to_lowercase();
202 if !url_lower.contains(&domain_lower) && !domain_lower.is_empty() {
203 return false;
204 }
205
206 if self.path != "/" {
208 let path_clean = self.path.trim_start_matches('/');
210 if !url_lower.contains(&path_clean.to_lowercase()) {
211 if let Some(after_domain) = url_lower.find(&domain_lower) {
214 let rest = &url_lower[after_domain + domain_lower.len()..];
215 if !rest.starts_with(&format!("/{}", path_clean.to_lowercase()))
216 && !rest.starts_with(&format!("/{}", self.path.to_lowercase()))
217 {
218 return false;
219 }
220 }
221 }
222 }
223
224 if let Some(expires) = self.expires
226 && SystemTime::now() > expires
227 {
228 return false;
229 }
230
231 true
232 }
233
234 pub fn to_header_value(&self) -> String {
238 let mut s = format!("{}={}", self.name, self.value);
239 if !self.domain.is_empty() {
240 s.push_str(&format!("; Domain={}", self.domain));
241 }
242 s.push_str(&format!("; Path={}", self.path));
243 if let Some(_expires) = self.expires {
244 if let Ok(_dur) = _expires.duration_since(SystemTime::UNIX_EPOCH) {
246 s.push_str(&format!(
248 "; Expires={}",
249 format_systemtime_as_http_date(_expires)
250 ));
251 }
252 }
253 if self.secure {
254 s.push_str("; Secure");
255 }
256 if self.http_only {
257 s.push_str("; HttpOnly");
258 }
259 s
260 }
261
262 pub fn parse_set_cookie(header_value: &str) -> Option<Self> {
294 let header = header_value.trim();
295 if header.is_empty() {
296 return None;
297 }
298
299 let parts: Vec<&str> = header.split(';').collect();
301 if parts.is_empty() {
302 return None;
303 }
304
305 let nv: Vec<&str> = parts[0].trim().splitn(2, '=').collect();
307 if nv.len() != 2 {
308 return None;
309 }
310
311 let name = nv[0].trim();
312 let value = nv[1].trim();
313 if name.is_empty() {
314 return None;
315 }
316
317 let mut cookie = Self::new(name, value, "");
318
319 for attr in &parts[1..] {
321 let attr = attr.trim();
322 if attr.is_empty() {
323 continue;
324 }
325 let kv: Vec<&str> = attr.splitn(2, '=').collect();
326 match kv[0].trim().to_lowercase().as_str() {
327 "domain" if kv.len() > 1 => {
328 cookie.domain = kv[1].trim().to_string();
329 }
330 "path" if kv.len() > 1 => {
331 cookie.path = kv[1].trim().to_string();
332 }
333 "max-age" if kv.len() > 1 => {
334 if let Ok(secs) = kv[1].trim().parse::<u64>() {
336 cookie.expires = Some(SystemTime::now() + Duration::from_secs(secs));
337 }
338 }
339 "expires" if kv.len() > 1 => {
340 if cookie.expires.is_none()
342 && let Ok(dt) = parse_http_date(kv[1].trim())
343 {
344 cookie.expires = Some(dt);
345 }
346 }
347 "secure" => {
348 cookie.secure = true;
349 }
350 "httponly" => {
351 cookie.http_only = true;
352 }
353 _ => {} }
355 }
356
357 Some(cookie)
358 }
359}
360
361impl PartialEq for JarCookie {
362 fn eq(&self, other: &Self) -> bool {
363 self.name == other.name && self.domain == other.domain && self.path == other.path
364 }
365}
366
367pub struct CookieJar {
396 pub cookies: Vec<JarCookie>,
398}
399
400impl CookieJar {
401 pub fn new() -> Self {
403 Self {
404 cookies: Vec::new(),
405 }
406 }
407
408 pub fn store(&mut self, cookie: JarCookie) {
413 self.cookies
415 .retain(|c| !(c.name == cookie.name && c.domain == cookie.domain));
416 self.cookies.push(cookie);
417 }
418
419 pub fn get_cookies_for_url(&self, url: &str, is_secure: bool) -> Vec<JarCookie> {
432 self.cookies
433 .iter()
434 .filter(|c| c.matches_url(url, is_secure))
435 .cloned()
436 .collect()
437 }
438
439 pub fn cookie_header_for_url(&self, url: &str, is_secure: bool) -> Option<String> {
453 let matching = self.get_cookies_for_url(url, is_secure);
454 if matching.is_empty() {
455 return None;
456 }
457 let header: String = matching
458 .iter()
459 .map(|c| format!("{}={}", c.name, c.value))
460 .collect::<Vec<_>>()
461 .join("; ");
462 Some(header)
463 }
464
465 pub fn cleanup_expired(&mut self) -> usize {
474 let before = self.cookies.len();
475 self.cookies.retain(|c| {
476 if let Some(exp) = c.expires {
477 SystemTime::now() <= exp
478 } else {
479 true }
481 });
482 before - self.cookies.len()
483 }
484
485 pub fn load_netscape_file(&mut self, path: &Path) -> Result<usize> {
504 let content = fs::read_to_string(path).map_err(|e| Aria2Error::Io(e.to_string()))?;
505 let mut loaded = 0;
506
507 for line in content.lines() {
508 if line.starts_with('#') || line.starts_with('\n') || line.is_empty() {
510 continue;
511 }
512
513 let fields: Vec<&str> = line.split('\t').collect();
514 if fields.len() >= 7 {
515 let domain = fields[0].trim();
517 let path = fields[2].trim();
519 let secure = fields[3] == "TRUE";
520 let expires = fields[4].trim().parse::<i64>().ok().and_then(|ts| {
522 if ts > 0 {
523 Some(SystemTime::UNIX_EPOCH + Duration::from_secs(ts as u64))
524 } else {
525 None }
527 });
528 let name = fields[5].trim().to_string();
529 let value = if fields.len() > 7 {
530 fields[6..].join("\t")
532 } else {
533 fields[6].trim().to_string()
534 };
535
536 let cookie = JarCookie {
537 name,
538 value,
539 domain: domain.to_string(),
540 path: path.to_string(),
541 expires,
542 secure,
543 http_only: false, creation_time: SystemTime::now(),
545 };
546
547 self.cookies.push(cookie);
548 loaded += 1;
549 }
550 }
551
552 Ok(loaded)
553 }
554
555 pub fn len(&self) -> usize {
557 self.cookies.len()
558 }
559
560 pub fn is_empty(&self) -> bool {
562 self.cookies.is_empty()
563 }
564
565 pub fn clear(&mut self) {
567 self.cookies.clear();
568 }
569}
570
571impl Default for CookieJar {
572 fn default() -> Self {
573 Self::new()
574 }
575}
576
577fn format_systemtime_as_http_date(time: SystemTime) -> String {
583 const DAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
584 const MONTHS: [&str; 12] = [
585 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
586 ];
587
588 let dur = time
589 .duration_since(SystemTime::UNIX_EPOCH)
590 .unwrap_or_default();
591 let epoch = dur.as_secs();
592
593 let days_since_epoch = (epoch / 86400) as u32;
594 let mut year = 1970u32;
595 let mut remaining = days_since_epoch;
596
597 loop {
598 let leap =
599 (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400);
600 let days_in_year = if leap { 366 } else { 365 };
601 if remaining < days_in_year {
602 break;
603 }
604 remaining -= days_in_year;
605 year += 1;
606 }
607
608 let mdays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
609 let mut month = 0u32;
610 while month < 12 {
611 let dim = if month == 1
612 && ((year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400))
613 {
614 29
615 } else {
616 mdays[month as usize]
617 };
618 if remaining < dim {
619 break;
620 }
621 remaining -= dim;
622 month += 1;
623 }
624 let day = remaining + 1;
625 let secs = epoch % 86400;
626 let hour = (secs / 3600) as u32;
627 let min = ((secs % 3600) / 60) as u32;
628 let sec = (secs % 60) as u32;
629 let dow: usize =
631 ((year + (year / 4) - (year / 100) + (year / 400) + (13 * month + 1) / 5 + day + 308) % 7)
632 as usize;
633
634 format!(
635 "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
636 DAYS[dow], day, MONTHS[month as usize], year, hour, min, sec
637 )
638}
639
640fn parse_http_date(s: &str) -> Result<SystemTime> {
650 let s = s.trim();
651 let parts: Vec<&str> = s.split_whitespace().collect();
652
653 if parts.len() >= 5 {
655 let (day_str, mon_str, year_str, time_str) = if parts[0].ends_with(',') {
657 if parts.len() >= 6 {
660 (
661 parts[1].trim(),
662 parts[2].trim(),
663 parts[3].trim(),
664 parts[4].trim(),
665 )
666 } else if parts.len() >= 5 {
667 (
668 parts[1].split('-').next().unwrap_or("1"),
669 parts[1].split('-').nth(1).unwrap_or("Jan"),
670 parts[2].trim(),
671 parts[3].trim(),
672 )
673 } else {
674 return Err(Aria2Error::Parse("Invalid date format".to_string()));
675 }
676 } else {
677 (
679 parts[2].trim(),
680 parts[1].trim(),
681 parts[4].trim(),
682 parts[3].trim(),
683 )
684 };
685
686 const MONTHS: [&str; 12] = [
687 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
688 ];
689
690 let day: u32 = day_str.parse().unwrap_or(1);
691 let month_idx = MONTHS
692 .iter()
693 .position(|&m| m.eq_ignore_ascii_case(mon_str))
694 .unwrap_or(0);
695 let year: i32 = year_str.parse().unwrap_or({
696 let y: u32 = year_str.parse().unwrap_or(70);
698 if y < 100 { (1900 + y) as i32 } else { y as i32 }
699 });
700
701 let time_parts: Vec<u32> = time_str.split(':').filter_map(|x| x.parse().ok()).collect();
702 let hour = time_parts.first().copied().unwrap_or(0);
703 let min = time_parts.get(1).copied().unwrap_or(0);
704 let sec = time_parts.get(2).copied().unwrap_or(0);
705
706 let total_days = calculate_days_since_epoch(year, month_idx as u32, day);
708 let timestamp =
709 total_days as u64 * 86400 + hour as u64 * 3600 + min as u64 * 60 + sec as u64;
710
711 return Ok(SystemTime::UNIX_EPOCH + Duration::from_secs(timestamp));
712 }
713
714 Ok(SystemTime::now() + Duration::from_secs(86400 * 365))
718}
719
720fn calculate_days_since_epoch(year: i32, month: u32, day: u32) -> i64 {
722 let mut days: i64 = 0;
723
724 for y in 1970..year {
726 days += if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 {
727 366
728 } else {
729 365
730 };
731 }
732
733 let mdays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
735 let is_leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
736 for m in 0..month {
737 let d = if m == 1 && is_leap {
738 29
739 } else {
740 mdays[m as usize]
741 };
742 days += d as i64;
743 }
744
745 days += day as i64 - 1;
747
748 days
749}
750
751#[cfg(test)]
754mod tests {
755 use super::*;
756
757 #[test]
760 fn test_creation_and_count() {
761 let store = CookieStorage::new();
762 assert_eq!(store.count(), 0);
763 assert!(store.is_empty());
764 }
765
766 #[test]
767 fn test_add_cookie() {
768 let store = CookieStorage::new();
769 store.add(Cookie::new("sid", "v1", "example.com"));
770 assert_eq!(store.count(), 1);
771 assert!(!store.is_empty());
772 }
773
774 #[test]
775 fn test_add_updates_existing() {
776 let store = CookieStorage::new();
777 store.add(Cookie::new("sid", "old", "example.com"));
778 store.add(Cookie::new("sid", "new", "example.com"));
779 assert_eq!(store.count(), 1);
780
781 let found = store.find_cookies("example.com", "/", false);
782 assert_eq!(found.len(), 1);
783 assert_eq!(found[0].value, "new");
784 }
785
786 #[test]
787 fn test_find_cookies_filters() {
788 let store = CookieStorage::new();
789 store.add(Cookie::new("a", "1", "example.com"));
790 store.add(Cookie::new("b", "2", "other.com"));
791
792 let found = store.find_cookies("example.com", "/", false);
793 assert_eq!(found.len(), 1);
794 assert_eq!(found[0].name, "a");
795 }
796
797 #[test]
798 fn test_find_cookies_for_url() {
799 let store = CookieStorage::new();
800 let mut c = Cookie::new("lang", "en", "example.com");
801 c.path = "/api".to_string();
802 store.add(c);
803
804 let url = reqwest::Url::parse("http://example.com/api/data").unwrap();
805 let found = store.find_cookies_for_url(&url);
806 assert_eq!(found.len(), 1);
807
808 let url2 = reqwest::Url::parse("http://example.com/home").unwrap();
809 let found2 = store.find_cookies_for_url(&url2);
810 assert!(found2.is_empty());
811 }
812
813 #[test]
814 fn test_expire_cookies() {
815 let store = CookieStorage::new();
816 let mut expired = Cookie::new("old", "v", "x.com");
817 expired.persistent = true;
818 expired.expiry_time = 1;
819 store.add(expired);
820
821 let mut fresh = Cookie::new("fresh", "v", "x.com");
822 fresh.persistent = true;
823 fresh.expiry_time = i64::MAX;
824 store.add(fresh);
825
826 assert_eq!(store.count(), 2);
827 let removed = store.expire_cookies(2);
828 assert_eq!(removed, 1);
829 assert_eq!(store.count(), 1);
830 }
831
832 #[test]
833 fn test_clear() {
834 let store = CookieStorage::new();
835 store.add(Cookie::new("a", "1", "b.com"));
836 store.add(Cookie::new("c", "2", "d.com"));
837 store.clear();
838 assert_eq!(store.count(), 0);
839 }
840
841 #[test]
842 fn test_to_header_string() {
843 let store = CookieStorage::new();
844 store.add(Cookie::new("a", "1", "example.com"));
845 store.add(Cookie::new("b", "2", "example.com"));
846
847 let hdr = store.to_header_string("example.com", "/", false);
848 assert!(hdr.contains("a=1"));
849 assert!(hdr.contains("b=2"));
850 }
851
852 #[test]
853 fn test_to_header_string_empty_for_no_match() {
854 let store = CookieStorage::new();
855 store.add(Cookie::new("a", "1", "example.com"));
856 let hdr = store.to_header_string("other.com", "/", false);
857 assert!(hdr.is_empty());
858 }
859
860 #[test]
861 #[ignore]
862 fn test_load_save_roundtrip() {
863 let dir = std::env::temp_dir().join("aria2_test_cookie");
864 fs::create_dir_all(&dir).ok();
865 let path = dir.join("cookies.txt");
866
867 let store = CookieStorage::new();
868 store.add(Cookie::new("sid", "abc", "example.com"));
869 store.save_file(&path).expect("save should succeed");
870
871 let store2 = CookieStorage::new();
872 let n = store2.load_file(&path).expect("load should succeed");
873 assert_eq!(n, 1);
874
875 let found = store2.find_cookies("example.com", "/", false);
876 assert_eq!(found.len(), 1);
877 assert_eq!(found[0].value, "abc");
878
879 fs::remove_dir_all(dir).ok();
880 }
881
882 #[test]
883 fn test_multiple_domains_independent() {
884 let store = CookieStorage::new();
885 store.add(Cookie::new("a", "1", "alpha.com"));
886 store.add(Cookie::new("b", "2", "beta.com"));
887 store.add(Cookie::new("c", "3", "alpha.com"));
888
889 assert_eq!(store.count(), 3);
890 assert_eq!(store.find_cookies("alpha.com", "/", false).len(), 2);
891 assert_eq!(store.find_cookies("beta.com", "/", false).len(), 1);
892 }
893
894 #[test]
901 fn test_cookie_parse_set_cookie() {
902 let cookie = JarCookie::parse_set_cookie(
904 "session_id=abc123; Domain=example.com; Path=/login; Secure; HttpOnly",
905 )
906 .expect("Should parse valid Set-Cookie header");
907
908 assert_eq!(cookie.name, "session_id");
909 assert_eq!(cookie.value, "abc123");
910 assert_eq!(cookie.domain, "example.com");
911 assert_eq!(cookie.path, "/login");
912 assert!(cookie.secure, "Secure flag should be set");
913 assert!(cookie.http_only, "HttpOnly flag should be set");
914 }
915
916 #[test]
918 fn test_cookie_parse_minimal() {
919 let cookie = JarCookie::parse_set_cookie("SID=31d4d96e407aad42")
920 .expect("Should parse minimal cookie");
921
922 assert_eq!(cookie.name, "SID");
923 assert_eq!(cookie.value, "31d4d96e407aad42");
924 assert_eq!(cookie.domain, ""); assert_eq!(cookie.path, "/");
926 assert!(!cookie.secure);
927 assert!(!cookie.http_only);
928 }
929
930 #[test]
932 fn test_cookie_parse_max_age() {
933 let cookie =
934 JarCookie::parse_set_cookie("token=xyz; Max-Age=3600").expect("Should parse Max-Age");
935
936 assert_eq!(cookie.name, "token");
937 assert!(cookie.expires.is_some(), "Max-Age should set expiration");
938 }
939
940 #[test]
942 fn test_cookie_parse_invalid_returns_none() {
943 assert!(JarCookie::parse_set_cookie("").is_none());
944 assert!(JarCookie::parse_set_cookie("noequal").is_none());
945 assert!(JarCookie::parse_set_cookie("=").is_none());
946 assert!(JarCookie::parse_set_cookie(";").is_none());
947 }
948
949 #[test]
954 fn test_cookie_matches_url_secure_flag() {
955 let mut cookie = JarCookie::new("auth_token", "secret123", "secure.example.com");
956 cookie.secure = true; assert!(
960 cookie.matches_url("https://secure.example.com/api", true),
961 "Secure cookie should match HTTPS URL"
962 );
963
964 assert!(
966 !cookie.matches_url("http://secure.example.com/api", false),
967 "Secure cookie must NOT match HTTP URL"
968 );
969 }
970
971 #[test]
973 fn test_cookie_matches_url_non_secure_both_schemes() {
974 let cookie = JarCookie::new("lang", "en", "example.com");
975
976 assert!(
977 cookie.matches_url("http://example.com/", false),
978 "Non-secure cookie should match HTTP"
979 );
980 assert!(
981 cookie.matches_url("https://example.com/", true),
982 "Non-secure cookie should also match HTTPS"
983 );
984 }
985
986 #[test]
988 fn test_cookie_matches_url_expired() {
989 let mut cookie = JarCookie::new("old_session", "val", "example.com");
990 cookie.expires = Some(SystemTime::now() - Duration::from_secs(1));
992
993 assert!(
994 !cookie.matches_url("https://example.com/", true),
995 "Expired cookie should not match any URL"
996 );
997 }
998
999 #[test]
1004 fn test_cookie_jar_get_for_url() {
1005 let mut jar = CookieJar::new();
1006
1007 jar.store(JarCookie::new("session", "abc123", "example.com"));
1009 jar.store(JarCookie::new("theme", "dark", "example.com"));
1010 jar.store(JarCookie::new("tracker", "xyz999", "other.com"));
1011
1012 let example_cookies = jar.get_cookies_for_url("http://example.com/page", false);
1014 assert_eq!(
1015 example_cookies.len(),
1016 2,
1017 "Should get exactly 2 cookies for example.com"
1018 );
1019
1020 let names: Vec<&str> = example_cookies.iter().map(|c| c.name.as_str()).collect();
1021 assert!(names.contains(&"session"));
1022 assert!(names.contains(&"theme"));
1023
1024 let other_cookies = jar.get_cookies_for_url("http://other.com/", false);
1026 assert_eq!(other_cookies.len(), 1);
1027 assert_eq!(other_cookies[0].name, "tracker");
1028 }
1029
1030 #[test]
1032 fn test_cookie_jar_header_format() {
1033 let mut jar = CookieJar::new();
1034 jar.store(JarCookie::new("a", "1", "example.com"));
1035 jar.store(JarCookie::new("b", "2", "example.com"));
1036
1037 let header = jar.cookie_header_for_url("http://example.com/", false);
1038 assert!(header.is_some());
1039 let hdr = header.unwrap();
1040 assert!(hdr.contains("a=1"), "Header should contain a=1");
1041 assert!(hdr.contains("b=2"), "Header should contain b=2");
1042 assert!(
1044 hdr == "a=1; b=2" || hdr == "b=2; a=1",
1045 "Unexpected header format: {}",
1046 hdr
1047 );
1048 }
1049
1050 #[test]
1052 fn test_cookie_jar_no_match_returns_none() {
1053 let mut jar = CookieJar::new();
1054 jar.store(JarCookie::new("x", "y", "example.com"));
1055
1056 let header = jar.cookie_header_for_url("http://other.com/", false);
1057 assert!(header.is_none(), "No match should return None");
1058 }
1059
1060 #[test]
1065 fn test_netscape_cookie_file_load() {
1066 let dir = std::env::temp_dir().join("aria2_netscape_test");
1067 fs::create_dir_all(&dir).ok();
1068 let path = dir.join("cookies.txt");
1069
1070 let content = "# Netscape HTTP Cookie File\n\
1072 \n\
1073 .example.com\tTRUE\t/\tFALSE\t0\tsession_id\tabc123\n\
1074 .api.example.com\tTRUE\t/api\tTRUE\t1700000000\ttoken\tsecret\n\
1075 localhost\tFALSE\t/\tFALSE\t0\tlocal_key\tlocal_val\n";
1076 fs::write(&path, content).expect("Failed to write test cookie file");
1077
1078 let mut jar = CookieJar::new();
1080 let result = jar.load_netscape_file(&path);
1081 assert!(
1082 result.is_ok(),
1083 "Loading netscape file should succeed: {:?}",
1084 result.err()
1085 );
1086
1087 let count = result.unwrap();
1088 assert_eq!(count, 3, "Should have loaded 3 cookies");
1089 assert_eq!(jar.len(), 3);
1090
1091 let session_cookie = jar
1093 .cookies
1094 .iter()
1095 .find(|c| c.name == "session_id")
1096 .expect("Should find session_id cookie");
1097 assert_eq!(session_cookie.domain, ".example.com");
1098 assert_eq!(session_cookie.value, "abc123");
1099 assert!(!session_cookie.secure);
1100 assert!(
1101 session_cookie.expires.is_none(),
1102 "Timestamp 0 means session cookie (no expiry)"
1103 );
1104
1105 let token_cookie = jar
1106 .cookies
1107 .iter()
1108 .find(|c| c.name == "token")
1109 .expect("Should find token cookie");
1110 assert_eq!(token_cookie.domain, ".api.example.com");
1111 assert_eq!(token_cookie.path, "/api");
1112 assert!(token_cookie.secure, "Token cookie should be secure");
1113 assert!(
1114 token_cookie.expires.is_some(),
1115 "Token cookie should have expiry time"
1116 );
1117
1118 fs::remove_dir_all(dir).ok();
1120 }
1121
1122 #[test]
1124 fn test_netscape_load_nonexistent_file() {
1125 let mut jar = CookieJar::new();
1126 let result = jar.load_netscape_file(Path::new("/nonexistent/path/cookies.txt"));
1127 assert!(result.is_err(), "Loading nonexistent file should fail");
1128 }
1129
1130 #[test]
1132 fn test_netscape_load_empty_file() {
1133 let dir = std::env::temp_dir().join("aria2_netscape_empty");
1134 fs::create_dir_all(&dir).ok();
1135 let path = dir.join("empty.txt");
1136 fs::write(&path, "").expect("Failed to write empty file");
1137
1138 let mut jar = CookieJar::new();
1139 let result = jar.load_netscape_file(&path).expect("Load should succeed");
1140 assert_eq!(result, 0, "Empty file should yield 0 cookies");
1141 assert!(jar.is_empty());
1142
1143 fs::remove_dir_all(dir).ok();
1144 }
1145
1146 #[test]
1149 fn test_jar_cookie_creation() {
1150 let c = JarCookie::new("test", "value", "example.com");
1151 assert_eq!(c.name, "test");
1152 assert_eq!(c.value, "value");
1153 assert_eq!(c.domain, "example.com");
1154 assert_eq!(c.path, "/");
1155 assert!(!c.secure);
1156 assert!(!c.http_only);
1157 assert!(c.expires.is_none()); }
1159
1160 #[test]
1161 fn test_jar_cookie_to_header_value() {
1162 let mut c = JarCookie::new("sid", "abc", "example.com");
1163 c.secure = true;
1164 c.http_only = true;
1165 let hdr = c.to_header_value();
1166 assert!(hdr.starts_with("sid=abc"));
1167 assert!(hdr.contains("Domain=example.com"));
1168 assert!(hdr.contains("Secure"));
1169 assert!(hdr.contains("HttpOnly"));
1170 }
1171
1172 #[test]
1173 fn test_jar_cookie_equality() {
1174 let a = JarCookie::new("x", "1", "a.com");
1175 let b = JarCookie::new("x", "2", "a.com"); assert_eq!(a, b, "Cookies with same name/domain/path should be equal");
1177
1178 let c = JarCookie::new("y", "1", "a.com");
1179 assert_ne!(a, c, "Different names should not be equal");
1180 }
1181
1182 #[test]
1183 fn test_cookie_jar_store_updates_existing() {
1184 let mut jar = CookieJar::new();
1185 jar.store(JarCookie::new("sid", "old", "example.com"));
1186 jar.store(JarCookie::new("sid", "new", "example.com"));
1187
1188 assert_eq!(jar.len(), 1, "Store should update, not duplicate");
1189 let cookies = jar.get_cookies_for_url("http://example.com/", false);
1190 assert_eq!(cookies[0].value, "new");
1191 }
1192
1193 #[test]
1194 fn test_cookie_jar_cleanup_expired() {
1195 let mut jar = CookieJar::new();
1196
1197 let mut expired = JarCookie::new("old", "val", "x.com");
1199 expired.expires = Some(SystemTime::now() - Duration::from_secs(60));
1200 jar.store(expired);
1201
1202 let mut fresh = JarCookie::new("fresh", "val", "x.com");
1204 fresh.expires = Some(SystemTime::now() + Duration::from_secs(86400 * 365));
1205 jar.store(fresh);
1206
1207 jar.store(JarCookie::new("session", "val", "x.com"));
1209
1210 assert_eq!(jar.len(), 3);
1211 let removed = jar.cleanup_expired();
1212 assert_eq!(removed, 1, "Should remove exactly 1 expired cookie");
1213 assert_eq!(jar.len(), 2, "Fresh + session cookies remain");
1214 }
1215
1216 #[test]
1217 fn test_cookie_jar_clear() {
1218 let mut jar = CookieJar::new();
1219 jar.store(JarCookie::new("a", "1", "x.com"));
1220 jar.store(JarCookie::new("b", "2", "y.com"));
1221 jar.clear();
1222 assert!(jar.is_empty());
1223 }
1224
1225 #[test]
1226 fn test_format_systemtime_as_http_date() {
1227 let time = SystemTime::UNIX_EPOCH + Duration::from_secs(784111777); let formatted = format_systemtime_as_http_date(time);
1229 assert!(formatted.contains("GMT"), "HTTP date should end with GMT");
1231 assert!(
1232 formatted.contains(','),
1233 "IMF-fixdate should have comma after weekday"
1234 );
1235 }
1236
1237 #[test]
1238 fn test_parse_http_date_imf_fixdate() {
1239 let result = parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT");
1240 assert!(result.is_ok(), "Should parse IMF-fixdate format");
1241 let time = result.unwrap();
1242 let dur = time
1243 .duration_since(SystemTime::UNIX_EPOCH)
1244 .unwrap_or_default();
1245 assert!(
1248 dur.as_secs() > 784000000,
1249 "Timestamp should be roughly correct, got {}",
1250 dur.as_secs()
1251 );
1252 }
1253
1254 #[test]
1255 fn test_parse_http_date_fallback() {
1256 let result = parse_http_date("totally-invalid-date-string");
1258 assert!(result.is_ok(), "Should return fallback, not error");
1259 let time = result.unwrap();
1260 assert!(time > SystemTime::now(), "Fallback should be in the future");
1261 }
1262}