Skip to main content

aria2_core/http/
cookie.rs

1use std::time::{SystemTime, UNIX_EPOCH};
2
3#[derive(Debug, Clone)]
4pub struct Cookie {
5    pub name: String,
6    pub value: String,
7    pub domain: String,
8    pub path: String,
9    pub expiry_time: i64,
10    pub creation_time: i64,
11    pub last_access_time: i64,
12    pub persistent: bool,
13    pub host_only: bool,
14    pub secure: bool,
15    pub http_only: bool,
16}
17
18impl Cookie {
19    pub fn new(name: &str, value: &str, domain: &str) -> Self {
20        let now = SystemTime::now()
21            .duration_since(UNIX_EPOCH)
22            .unwrap_or_default()
23            .as_secs() as i64;
24        Self {
25            name: name.to_string(),
26            value: value.to_string(),
27            domain: domain.to_string(),
28            path: "/".to_string(),
29            expiry_time: 0,
30            creation_time: now,
31            last_access_time: now,
32            persistent: false,
33            host_only: true,
34            secure: false,
35            http_only: false,
36        }
37    }
38
39    pub fn match_request(&self, host: &str, path: &str, date: i64, secure: bool) -> bool {
40        if self.secure && !secure {
41            return false;
42        }
43        if self.persistent && self.is_expired(date) {
44            return false;
45        }
46        if !self.domain_matches(host) {
47            return false;
48        }
49        if !self.path_matches(path) {
50            return false;
51        }
52        true
53    }
54
55    pub fn is_expired(&self, base_time: i64) -> bool {
56        if !self.persistent {
57            return false;
58        }
59        self.expiry_time < base_time
60    }
61
62    pub fn to_set_cookie_header(&self) -> String {
63        let mut s = format!("{}={}", self.name, self.value);
64        if self.persistent && self.expiry_time > 0 {
65            s.push_str("; Expires=");
66            s.push_str(&format_http_date(self.expiry_time));
67        }
68        if !self.domain.is_empty() {
69            s.push_str("; Domain=");
70            s.push_str(&self.domain);
71        }
72        if self.path != "/" {
73            s.push_str("; Path=");
74            s.push_str(&self.path);
75        }
76        if self.secure {
77            s.push_str("; Secure");
78        }
79        if self.http_only {
80            s.push_str("; HttpOnly");
81        }
82        s
83    }
84
85    pub fn to_netscape_line(&self) -> String {
86        let d = if self.host_only {
87            format!(".{}", self.domain)
88        } else {
89            self.domain.clone()
90        };
91        let sub = if self.host_only { "FALSE" } else { "TRUE" };
92        let sec = if self.secure { "TRUE" } else { "FALSE" };
93        format!(
94            "{}\t{}\t{}\t{}\t{}\t{}\t{}",
95            d, sub, self.path, sec, self.expiry_time, self.name, self.value
96        )
97    }
98
99    pub fn from_set_cookie_header(
100        header: &str,
101        default_domain: &str,
102        default_path: &str,
103    ) -> Option<Self> {
104        let header = header.trim();
105        if header.is_empty() {
106            return None;
107        }
108
109        let (name_value, attrs_part) = header.split_once(';')?;
110        let nv = name_value.trim();
111        let eq_pos = nv.find('=')?;
112        let name = nv[..eq_pos].trim();
113        let value = nv[eq_pos + 1..].trim();
114        if name.is_empty() {
115            return None;
116        }
117
118        let mut cookie = Self::new(name, value, default_domain);
119        cookie.path = default_path.to_string();
120
121        for attr in attrs_part.split(';') {
122            let attr = attr.trim();
123            if attr.is_empty() {
124                continue;
125            }
126            if let Some((k, v)) = attr.split_once('=') {
127                match k.trim().to_lowercase().as_str() {
128                    "domain" => {
129                        cookie.domain = v.trim().to_string();
130                        cookie.host_only = false;
131                    }
132                    "path" => {
133                        cookie.path = v.trim().to_string();
134                    }
135                    "max-age" => {
136                        if let Ok(secs) = v.trim().parse::<i64>() {
137                            cookie.expiry_time = now_secs() + secs;
138                            cookie.persistent = true;
139                        }
140                    }
141                    "expires" => {
142                        if let Some(ep) = parse_http_date(v.trim()) {
143                            cookie.expiry_time = ep;
144                            cookie.persistent = true;
145                        }
146                    }
147                    _ => {}
148                }
149            } else {
150                match attr.to_lowercase().as_str() {
151                    "secure" => cookie.secure = true,
152                    "httponly" => cookie.http_only = true,
153                    _ => {}
154                }
155            }
156        }
157        Some(cookie)
158    }
159
160    pub fn parse_netscape_line(line: &str) -> Option<Self> {
161        let line = line.trim();
162        if line.is_empty() || line.starts_with('#') {
163            return None;
164        }
165
166        let parts: Vec<&str> = line.split('\t').collect();
167        if parts.len() < 7 {
168            return None;
169        }
170
171        let raw_domain = parts[0];
172        let include_subdomains = parts[1];
173        let path = parts[2].trim();
174        let secure = parts[3] == "TRUE";
175        let expiry: i64 = parts[5].trim().parse().ok()?;
176        let name = parts[6].trim().to_string();
177        let value = if parts.len() > 7 {
178            parts[7].trim().to_string()
179        } else {
180            String::new()
181        };
182
183        let domain = raw_domain.trim_start_matches('.').to_string();
184        let host_only = (include_subdomains != "TRUE") || domain.is_empty();
185
186        Some(Self {
187            name,
188            value,
189            domain,
190            path: path.to_string(),
191            expiry_time: expiry,
192            creation_time: 0,
193            last_access_time: 0,
194            persistent: true,
195            host_only,
196            secure,
197            http_only: false,
198        })
199    }
200
201    fn domain_matches(&self, host: &str) -> bool {
202        if self.host_only {
203            self.domain.eq_ignore_ascii_case(host)
204        } else {
205            let d = self.domain.to_lowercase();
206            let h = host.to_lowercase();
207            h == d
208                || (d.starts_with('.') && h.ends_with(&d))
209                || (!d.starts_with('.') && h.ends_with(&format!(".{}", d)))
210        }
211    }
212
213    fn path_matches(&self, path: &str) -> bool {
214        if self.path == "/" {
215            return true;
216        }
217        let p = if self.path.ends_with('/') {
218            self.path.clone()
219        } else {
220            format!("{}/", self.path)
221        };
222        path.starts_with(&p)
223    }
224}
225
226impl PartialEq for Cookie {
227    fn eq(&self, other: &Self) -> bool {
228        self.name == other.name && self.domain == other.domain && self.path == other.path
229    }
230}
231
232fn now_secs() -> i64 {
233    SystemTime::now()
234        .duration_since(UNIX_EPOCH)
235        .unwrap_or_default()
236        .as_secs() as i64
237}
238
239fn format_http_date(epoch: i64) -> String {
240    const DAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
241    const MONTHS: [&str; 12] = [
242        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
243    ];
244    let epoch = epoch as u64;
245    let days_since_epoch = epoch / 86400;
246    let mut y = 1970u32;
247    let mut remaining = days_since_epoch as u32;
248    loop {
249        let leap = y.is_multiple_of(4) && !y.is_multiple_of(100) || y.is_multiple_of(400);
250        let days_in_year = if leap { 366 } else { 365 };
251        if remaining < days_in_year {
252            break;
253        }
254        remaining -= days_in_year;
255        y += 1;
256    }
257    let mdays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
258    let mut m = 0u32;
259    while m < 12 {
260        let dim =
261            if m == 1 && (y.is_multiple_of(4) && !y.is_multiple_of(100) || y.is_multiple_of(400)) {
262                29
263            } else {
264                mdays[m as usize]
265            };
266        if remaining < dim {
267            break;
268        }
269        remaining -= dim;
270        m += 1;
271    }
272    let day = remaining + 1;
273    let secs = epoch % 86400;
274    let hour = secs / 3600;
275    let min = (secs % 3600) / 60;
276    let sec = secs % 60;
277    let dow = ((y + (y / 4) - (y / 100) + (y / 400) + (13 * m + 1) / 5 + day + 308) % 7) as usize;
278    format!(
279        "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
280        DAYS[dow], day, MONTHS[m as usize], y, hour, min, sec
281    )
282}
283
284fn parse_http_date(s: &str) -> Option<i64> {
285    const MONTHS: [&str; 12] = [
286        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
287    ];
288    let s = s.trim();
289    let parts: Vec<&str> = s.split_whitespace().collect();
290    if parts.len() < 5 {
291        return None;
292    }
293    let day: u32 = parts[1].parse().ok()?;
294    let month_idx = MONTHS.iter().position(|&m| m == parts[2])? as u32;
295    let year: u32 = parts[3].parse().ok()?;
296    let time_parts: Vec<u32> = parts[4].split(':').filter_map(|x| x.parse().ok()).collect();
297    if time_parts.len() < 3 {
298        return None;
299    }
300    let _mdays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 11, 30, 31];
301    let leap = year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400);
302    let feb_days = if leap { 29 } else { 28 };
303    let dim = [31, feb_days, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
304    let total_days = (0..year)
305        .map(|y| {
306            if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 {
307                366
308            } else {
309                365
310            }
311        })
312        .sum::<u32>()
313        + (0..month_idx).map(|m| dim[m as usize]).sum::<u32>()
314        + day
315        - 1;
316    Some(
317        total_days as i64 * 86400
318            + time_parts[0] as i64 * 3600
319            + time_parts[1] as i64 * 60
320            + time_parts[2] as i64,
321    )
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn test_creation() {
330        let c = Cookie::new("session", "abc123", "example.com");
331        assert_eq!(c.name, "session");
332        assert_eq!(c.value, "abc123");
333        assert_eq!(c.domain, "example.com");
334        assert_eq!(c.path, "/");
335        assert!(!c.secure);
336        assert!(!c.http_only);
337        assert!(!c.persistent);
338        assert!(!c.is_expired(i64::MAX));
339    }
340
341    #[test]
342    fn test_match_exact_domain() {
343        let mut c = Cookie::new("sid", "v1", "example.com");
344        c.host_only = true;
345        assert!(c.match_request("example.com", "/", i64::MAX, false));
346        assert!(!c.match_request("sub.example.com", "/", i64::MAX, false));
347        assert!(!c.match_request("other.com", "/", i64::MAX, false));
348    }
349
350    #[test]
351    fn test_match_subdomain() {
352        let mut c = Cookie::new("sid", "v1", "example.com");
353        c.host_only = false;
354        assert!(c.match_request("example.com", "/", i64::MAX, false));
355        assert!(c.match_request("sub.example.com", "/", i64::MAX, false));
356        assert!(c.match_request("deep.sub.example.com", "/", i64::MAX, false));
357        assert!(!c.match_request("notexample.com", "/", i64::MAX, false));
358    }
359
360    #[test]
361    fn test_match_secure_flag() {
362        let mut c = Cookie::new("token", "t", "api.example.com");
363        c.secure = true;
364        assert!(c.match_request("api.example.com", "/", i64::MAX, true));
365        assert!(!c.match_request("api.example.com", "/", i64::MAX, false));
366    }
367
368    #[test]
369    fn test_match_path_prefix() {
370        let mut c = Cookie::new("lang", "en", "example.com");
371        c.path = "/api".to_string();
372        assert!(c.match_request("example.com", "/api/users", i64::MAX, false));
373        assert!(c.match_request("example.com", "/api/", i64::MAX, false));
374        assert!(!c.match_request("example.com", "/home", i64::MAX, false));
375
376        c.path = "/".to_string();
377        assert!(c.match_request("example.com", "/any/path", i64::MAX, false));
378    }
379
380    #[test]
381    fn test_expired_persistent() {
382        let mut c = Cookie::new("old", "val", "x.com");
383        c.persistent = true;
384        c.expiry_time = 1000;
385        assert!(c.is_expired(1001));
386        assert!(!c.is_expired(999));
387        assert!(!c.is_expired(1000));
388    }
389
390    #[test]
391    fn test_session_never_expires() {
392        let mut c = Cookie::new("sess", "v", "x.com");
393        c.persistent = false;
394        assert!(!c.is_expired(i64::MAX));
395    }
396
397    #[test]
398    fn test_to_set_cookie_header() {
399        let mut c = Cookie::new("session_id", "abc123", "example.com");
400        c.path = "/app".to_string();
401        let hdr = c.to_set_cookie_header();
402        assert!(hdr.starts_with("session_id=abc123"));
403        assert!(hdr.contains("Domain=example.com"));
404        assert!(hdr.contains("Path=/app"));
405    }
406
407    #[test]
408    fn test_to_set_cookie_secure_httponly() {
409        let mut c = Cookie::new("token", "t", "secure.example.com");
410        c.secure = true;
411        c.http_only = true;
412        let hdr = c.to_set_cookie_header();
413        assert!(hdr.contains("Secure"));
414        assert!(hdr.contains("HttpOnly"));
415    }
416
417    #[test]
418    #[ignore]
419    fn test_from_set_cookie_basic() {
420        let hdr = "SID=31d4d96e407aad42";
421        let c = Cookie::from_set_cookie_header(hdr, "example.com", "/").unwrap();
422        assert_eq!(c.name, "SID");
423        assert_eq!(c.value, "31d4d96e407aad42");
424        assert_eq!(c.domain, "example.com");
425        assert!(c.host_only);
426        assert_eq!(c.path, "/");
427        assert!(!c.secure);
428        assert!(!c.http_only);
429        assert!(!c.persistent);
430    }
431
432    #[test]
433    fn test_from_set_cookie_with_attributes() {
434        let hdr = "session=xyz; Domain=example.com; Path=/login; Secure; HttpOnly";
435        let c = Cookie::from_set_cookie_header(hdr, "default.com", "/").unwrap();
436        assert_eq!(c.domain, "example.com");
437        assert_eq!(c.path, "/login");
438        assert!(c.secure);
439        assert!(c.http_only);
440    }
441
442    #[test]
443    fn test_from_set_cookie_empty() {
444        assert!(Cookie::from_set_cookie_header("", "x.com", "/").is_none());
445        assert!(Cookie::from_set_cookie_header("noequal", "x.com", "/").is_none());
446    }
447
448    #[test]
449    #[ignore]
450    fn test_parse_netscape_line() {
451        let t = "\t";
452        let line = [
453            ".example.com",
454            t,
455            "TRUE",
456            t,
457            "/",
458            t,
459            "FALSE",
460            t,
461            "0",
462            t,
463            "session_id",
464            t,
465            "abc123",
466        ]
467        .concat();
468        let c = Cookie::parse_netscape_line(&line).unwrap();
469        assert_eq!(c.domain, "example.com");
470        assert_eq!(c.path, "/");
471        assert!(!c.secure);
472        assert_eq!(c.name, "session_id");
473        assert_eq!(c.value, "abc123");
474        assert!(c.persistent);
475    }
476
477    #[test]
478    fn test_parse_netscape_skip_comment() {
479        assert!(Cookie::parse_netscape_line("# this is a comment").is_none());
480        assert!(Cookie::parse_netscape_line("").is_none());
481    }
482
483    #[test]
484    fn test_parse_netscape_too_few_fields() {
485        assert!(Cookie::parse_netscape_line("a\tb\tc").is_none());
486    }
487
488    #[test]
489    #[ignore]
490    fn test_parse_netscape_secure_true() {
491        let t = "\t";
492        let line = [
493            ".example.com",
494            t,
495            "TRUE",
496            t,
497            "/",
498            t,
499            "TRUE",
500            t,
501            "0",
502            t,
503            "token",
504            t,
505            "secret",
506        ]
507        .concat();
508        let c = Cookie::parse_netscape_line(&line).unwrap();
509        assert!(c.secure);
510    }
511
512    #[test]
513    fn test_equality_by_name_domain_path() {
514        let a = Cookie::new("x", "1", "a.com");
515        let b = Cookie::new("x", "2", "a.com");
516        assert_eq!(a, b);
517
518        let c = Cookie::new("y", "1", "a.com");
519        assert_ne!(a, c);
520    }
521
522    #[test]
523    fn test_clone() {
524        let c = Cookie::new("k", "v", "d.com");
525        let c2 = c.clone();
526        assert_eq!(c.name, c2.name);
527        assert_eq!(c.domain, c2.domain);
528    }
529}