Skip to main content

aria2_core/http/
cookie_storage.rs

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
10// ==================== Existing CookieStorage ====================
11
12/// Thread-safe cookie storage using RwLock for concurrent access.
13///
14/// This is the original cookie storage that works with the `Cookie` struct
15/// from `cookie.rs`, providing add/find/expire operations with host+path matching.
16pub 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// ==================== Enhanced CookieJar (J4) ====================
129
130/// An enhanced cookie representation using SystemTime for expiration tracking.
131///
132/// This struct provides URL-based matching, Set-Cookie header parsing,
133/// and serialization. It is designed to work alongside the existing `Cookie`
134/// from `cookie.rs` while adding SystemTime-based expiration and simpler
135/// URL-based matching.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct JarCookie {
138    /// Cookie name
139    pub name: String,
140    /// Cookie value
141    pub value: String,
142    /// Domain this cookie belongs to (e.g., "example.com")
143    pub domain: String,
144    /// Path scope (usually "/")
145    pub path: String,
146    /// Expiration time; None means session cookie (no persistent expiry)
147    pub expires: Option<SystemTime>,
148    /// Only send over HTTPS connections
149    pub secure: bool,
150    /// Not accessible to JavaScript (client-side only)
151    pub http_only: bool,
152    /// When this cookie was created
153    pub creation_time: SystemTime,
154}
155
156impl JarCookie {
157    /// Create a new basic session cookie.
158    ///
159    /// # Arguments
160    ///
161    /// * `name` - Cookie name
162    /// * `value` - Cookie value
163    /// * `domain` - Domain the cookie belongs to
164    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    /// Check whether this cookie should be sent for the given URL.
178    ///
179    /// Matching rules:
180    /// 1. Secure cookies are only sent over HTTPS (`is_secure = true`)
181    /// 2. The URL must contain the cookie's domain
182    /// 3. The URL must match the cookie's path scope
183    /// 4. The cookie must not have expired
184    ///
185    /// # Arguments
186    ///
187    /// * `url` - The request URL string to match against
188    /// * `is_secure` - Whether the connection uses HTTPS
189    ///
190    /// # Returns
191    ///
192    /// `true` if this cookie should be included in requests to the given URL.
193    pub fn matches_url(&self, url: &str, is_secure: bool) -> bool {
194        // Secure flag check: secure cookies only over HTTPS
195        if self.secure && !is_secure {
196            return false;
197        }
198
199        // Domain matching: URL must contain the cookie's domain
200        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        // Path matching: if path is "/", it matches everything
207        if self.path != "/" {
208            // Strip leading slash from path for comparison against URL path portion
209            let path_clean = self.path.trim_start_matches('/');
210            if !url_lower.contains(&path_clean.to_lowercase()) {
211                // For non-root paths, require at least a partial match
212                // Allow if the domain already matched and path is a prefix of URL path
213                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        // Expiry check: remove expired cookies
225        if let Some(expires) = self.expires
226            && SystemTime::now() > expires
227        {
228            return false;
229        }
230
231        true
232    }
233
234    /// Format this cookie as a Set-Cookie header value string.
235    ///
236    /// Produces output like: `name=value; Domain=example.com; Path=/`
237    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            // Use simplified date formatting for expires attribute
245            if let Ok(_dur) = _expires.duration_since(SystemTime::UNIX_EPOCH) {
246                // Approximate HTTP-date format
247                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    /// Parse a cookie from a Set-Cookie response header value.
263    ///
264    /// Supports standard attributes:
265    /// - `Domain=` - cookie domain scope
266    /// - `Path=` - cookie path scope
267    /// - `Expires=` - expiration timestamp (RFC 7231 / RFC 850 / asctime formats)
268    /// - `Secure` - HTTPS-only flag
269    /// - `HttpOnly` - JavaScript-inaccessible flag
270    /// - `Max-Age=` - relative expiration in seconds
271    ///
272    /// # Arguments
273    ///
274    /// * `header_value` - The raw Set-Cookie header value string
275    ///
276    /// # Returns
277    ///
278    /// `Some(JarCookie)` on successful parse, `None` if the header is malformed.
279    ///
280    /// # Example
281    ///
282    /// ```
283    /// use aria2_core::http::cookie_storage::JarCookie;
284    ///
285    /// let cookie = JarCookie::parse_set_cookie(
286    ///     "session=abc123; Domain=example.com; Path=/; Secure; HttpOnly"
287    /// ).unwrap();
288    /// assert_eq!(cookie.name, "session");
289    /// assert_eq!(cookie.domain, "example.com");
290    /// assert!(cookie.secure);
291    /// assert!(cookie.http_only);
292    /// ```
293    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        // Split into name=value part and attributes
300        let parts: Vec<&str> = header.split(';').collect();
301        if parts.is_empty() {
302            return None;
303        }
304
305        // Parse name=value (first part before any semicolon)
306        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        // Parse remaining attributes
320        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                    // Max-Age takes precedence over Expires per RFC 6265
335                    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                    // Only set if not already set by Max-Age
341                    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                _ => {} // Ignore unknown attributes
354            }
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
367// ==================== CookieJar Storage ====================
368
369/// A cookie jar that stores cookies and provides URL-based matching for HTTP requests.
370///
371/// `CookieJar` manages a collection of `JarCookie` instances, supporting:
372/// - Storing cookies received from Set-Cookie response headers
373/// - Retrieving matching cookies for outgoing requests by URL
374/// - Generating Cookie header strings from matching cookies
375/// - Loading cookies from Netscape/Mozilla cookie file format
376/// - Cleaning up expired cookies
377///
378/// # Example
379///
380/// ```rust,no_run
381/// use aria2_core::http::cookie_storage::{CookieJar, JarCookie};
382///
383/// let mut jar = CookieJar::new();
384///
385/// // Store a cookie from a Set-Cookie response header
386/// if let Some(cookie) = JarCookie::parse_set_cookie("sid=abc; Domain=example.com") {
387///     jar.store(cookie);
388/// }
389///
390/// // Get cookies for an outgoing request
391/// if let Some(header) = jar.cookie_header_for_url("https://example.com/api", true) {
392///     println!("Cookie: {}", header); // "sid=abc"
393/// }
394/// ```
395pub struct CookieJar {
396    /// Internal cookie storage - made pub for session persistence serialization
397    pub cookies: Vec<JarCookie>,
398}
399
400impl CookieJar {
401    /// Create a new empty cookie jar.
402    pub fn new() -> Self {
403        Self {
404            cookies: Vec::new(),
405        }
406    }
407
408    /// Store a cookie received from a Set-Cookie response header.
409    ///
410    /// If a cookie with the same name and domain already exists, it is replaced.
411    /// This implements the update semantics defined in RFC 6265 Section 5.3.
412    pub fn store(&mut self, cookie: JarCookie) {
413        // Remove existing cookie with same name+domain (update semantics)
414        self.cookies
415            .retain(|c| !(c.name == cookie.name && c.domain == cookie.domain));
416        self.cookies.push(cookie);
417    }
418
419    /// Get all cookies that match the given URL for sending in a Cookie request header.
420    ///
421    /// Filters stored cookies based on domain, path, security, and expiration status.
422    ///
423    /// # Arguments
424    ///
425    /// * `url` - The request URL string
426    /// * `is_secure` - Whether the connection uses HTTPS
427    ///
428    /// # Returns
429    ///
430    /// A vector of matching `JarCookie` clones.
431    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    /// Format all matching cookies as a Cookie header value string.
440    ///
441    /// Produces output like `"name1=val1; name2=val2"` suitable for the
442    /// HTTP Cookie request header. Returns `None` if no cookies match.
443    ///
444    /// # Arguments
445    ///
446    /// * `url` - The request URL string
447    /// * `is_secure` - Whether the connection uses HTTPS
448    ///
449    /// # Returns
450    ///
451    /// `Some(header_string)` if matching cookies exist, `None` otherwise.
452    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    /// Remove all expired cookies from the jar.
466    ///
467    /// Session cookies (those without an expiration time) are never removed
468    /// by this method — they persist until the session ends or explicitly deleted.
469    ///
470    /// # Returns
471    ///
472    /// The number of cookies that were removed.
473    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 // No expiry = session cookie, keep it
480            }
481        });
482        before - self.cookies.len()
483    }
484
485    /// Load cookies from a Netscape/Mozilla cookie file.
486    ///
487    /// The Netscape cookie file format uses tab-separated fields:
488    /// ```text
489    /// # Netscape HTTP Cookie File
490    ///
491    /// .example.com    TRUE    /    FALSE    0    session-cookie    value
492    /// ```
493    ///
494    /// Fields: domain, include_subdomains, path, is_secure, expires_timestamp, name, value
495    ///
496    /// # Arguments
497    ///
498    /// * `path` - Path to the Netscape-format cookie file
499    ///
500    /// # Errors
501    ///
502    /// Returns an error if the file cannot be read.
503    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            // Skip comments, empty lines, and the header line
509            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                // Parse the 7+ tab-separated fields
516                let domain = fields[0].trim();
517                // fields[1]: include_subdomains (TRUE/FALSE) — not used for matching here
518                let path = fields[2].trim();
519                let secure = fields[3] == "TRUE";
520                // fields[4]: expires timestamp (Unix epoch), 0 = session cookie
521                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 // Session cookie
526                    }
527                });
528                let name = fields[5].trim().to_string();
529                let value = if fields.len() > 7 {
530                    // Value may contain tabs if there are extra fields; join remainder
531                    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, // Netscape format doesn't track HttpOnly
544                    creation_time: SystemTime::now(),
545                };
546
547                self.cookies.push(cookie);
548                loaded += 1;
549            }
550        }
551
552        Ok(loaded)
553    }
554
555    /// Return the number of cookies currently stored in the jar.
556    pub fn len(&self) -> usize {
557        self.cookies.len()
558    }
559
560    /// Check if the jar contains no cookies.
561    pub fn is_empty(&self) -> bool {
562        self.cookies.is_empty()
563    }
564
565    /// Remove all cookies from the jar.
566    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
577// ==================== HTTP Date Helpers ====================
578
579/// Format a SystemTime as an HTTP-date string (RFC 7231 IMF-fixdate).
580///
581/// Produces output like: `Sun, 06 Nov 1994 08:49:37 GMT`
582fn 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    // Zeller's congruence to determine day of week (0=Sun..6=Sat)
630    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
640/// Parse an HTTP-date string into a SystemTime.
641///
642/// Supports common date formats from RFC 7231 Section 7.1.1.1:
643/// - IMF-fixdate: `Sun, 06 Nov 1994 08:49:37 GMT`
644/// - RFC 850: `Sunday, 06-Nov-94 08:49:37 GMT`
645/// - ANSI C asctime: `Sun Nov  6 08:49:37 1994`
646///
647/// If parsing fails, returns a far-future timestamp as fallback (1 year from now)
648/// to avoid prematurely expiring cookies due to unparseable dates.
649fn parse_http_date(s: &str) -> Result<SystemTime> {
650    let s = s.trim();
651    let parts: Vec<&str> = s.split_whitespace().collect();
652
653    // Handle various formats
654    if parts.len() >= 5 {
655        // Try to extract day, month, year, time components
656        let (day_str, mon_str, year_str, time_str) = if parts[0].ends_with(',') {
657            // IMF-fixdate or RFC 850 format: "Sun, 06 Nov 1994 08:49:37 GMT"
658            // or "Sunday, 06-Nov-94 08:49:37 GMT"
659            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            // asctime format: "Sun Nov  6 08:49:37 1994"
678            (
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            // Handle 2-digit years (RFC 850)
697            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        // Convert to Unix timestamp (simplified calculation)
707        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    // Fallback: treat as far-future expiry (1 year from now) to avoid
715    // incorrectly expiring cookies when we can't parse the date format.
716    // This is safer than returning an error which would cause cookie rejection.
717    Ok(SystemTime::now() + Duration::from_secs(86400 * 365))
718}
719
720/// Calculate the number of days since Unix epoch (1970-01-01) for a given date.
721fn calculate_days_since_epoch(year: i32, month: u32, day: u32) -> i64 {
722    let mut days: i64 = 0;
723
724    // Full years before current year
725    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    // Months before current month in current year
734    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 in current month (day is 1-indexed)
746    days += day as i64 - 1;
747
748    days
749}
750
751// ==================== Tests ====================
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756
757    // ===== Original CookieStorage tests =====
758
759    #[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    // ===== J4: New JarCookie Tests =====
895
896    /// Test J4.4 #1: Parse Set-Cookie header with various attributes.
897    ///
898    /// Verifies that name/value extraction and common attribute parsing work correctly,
899    /// including Domain, Path, Secure, HttpOnly, and Expires.
900    #[test]
901    fn test_cookie_parse_set_cookie() {
902        // Basic parsing with all common attributes
903        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 J4.4 #1 continued: Parse minimal Set-Cookie (name=value only).
917    #[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, ""); // No domain specified
925        assert_eq!(cookie.path, "/");
926        assert!(!cookie.secure);
927        assert!(!cookie.http_only);
928    }
929
930    /// Test J4.4 #1 continued: Parse with Max-Age attribute.
931    #[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 J4.4 #1 continued: Invalid headers return None.
941    #[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 J4.4 #2: Secure cookie must not be sent over plain HTTP.
950    ///
951    /// A cookie with the Secure flag should only match URLs accessed via HTTPS.
952    /// When `is_secure=false`, the cookie should not match even if domain/path align.
953    #[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; // Mark as secure-only
957
958        // Should match HTTPS URL
959        assert!(
960            cookie.matches_url("https://secure.example.com/api", true),
961            "Secure cookie should match HTTPS URL"
962        );
963
964        // Should NOT match HTTP URL
965        assert!(
966            !cookie.matches_url("http://secure.example.com/api", false),
967            "Secure cookie must NOT match HTTP URL"
968        );
969    }
970
971    /// Test J4.4 #2 continued: Non-secure cookies work on both HTTP and HTTPS.
972    #[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 J4.4 #2 continued: Expired cookies don't match.
987    #[test]
988    fn test_cookie_matches_url_expired() {
989        let mut cookie = JarCookie::new("old_session", "val", "example.com");
990        // Set expiration to 1 second in the past
991        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 J4.4 #3: CookieJar returns correct subset of cookies for a given URL.
1000    ///
1001    /// Stores multiple cookies for different domains/paths and verifies that
1002    /// querying for a specific URL returns only the matching subset.
1003    #[test]
1004    fn test_cookie_jar_get_for_url() {
1005        let mut jar = CookieJar::new();
1006
1007        // Add cookies for different domains and paths
1008        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        // Query for example.com — should get 2 cookies
1013        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        // Query for other.com — should get 1 cookie
1025        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 J4.4 #3 continued: cookie_header_for_url produces correct header format.
1031    #[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        // Verify format: "name=val; name=val"
1043        assert!(
1044            hdr == "a=1; b=2" || hdr == "b=2; a=1",
1045            "Unexpected header format: {}",
1046            hdr
1047        );
1048    }
1049
1050    /// Test J4.4 #3 continued: No matching cookies returns None.
1051    #[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 J4.4 #4: Load cookies from Netscape/Mozilla cookie file format.
1061    ///
1062    /// Creates a temporary file in the Netscape cookie format and verifies
1063    /// that loading it produces the expected cookies with correct field values.
1064    #[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        // Write a Netscape-format cookie file
1071        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        // Load into CookieJar
1079        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        // Verify specific cookie properties
1092        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        // Cleanup
1119        fs::remove_dir_all(dir).ok();
1120    }
1121
1122    /// Test J4.4 #4 continued: Loading nonexistent file returns error.
1123    #[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 J4.4 #4 continued: Empty file loads zero cookies.
1131    #[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    // ===== Additional JarCookie/CookieJar tests =====
1147
1148    #[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()); // Session cookie by default
1158    }
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"); // Same name+domain+path
1176        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        // Add an expired cookie
1198        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        // Add a fresh cookie (far future expiry)
1203        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        // Add a session cookie (no expiry)
1208        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); // Known timestamp
1228        let formatted = format_systemtime_as_http_date(time);
1229        // Should produce something like "Thu, 29 Nov 1984 20:22:57 GMT"
1230        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        // Nov 6, 1994 08:49:37 GMT ≈ 784629777 seconds since epoch
1246        // Allow generous range for different parser implementations
1247        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        // Unparseable input should return far-future (not error)
1257        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}