Skip to main content

aria2_core/http/
conditional_get.rs

1// Conditional GET and Smart Resume support for HTTP downloads.
2// Implements RFC 7232 (Conditional Requests) and RFC 7233 (Range Requests)
3// for efficient download resumption and unchanged-file detection.
4
5use std::collections::HashMap;
6
7/// Simple datetime structure for RFC 2822 date handling without chrono dependency.
8/// Stores timestamp as seconds since Unix epoch for simplicity.
9#[derive(Debug, Clone, Copy)]
10pub struct SimpleDateTime {
11    timestamp: i64, // seconds since Unix epoch
12}
13
14impl SimpleDateTime {
15    /// Create from Unix timestamp
16    pub fn from_timestamp(ts: i64) -> Self {
17        Self { timestamp: ts }
18    }
19
20    /// Format date according to RFC 7231 (IMF-fixdate format).
21    /// Example: "Sun, 06 Nov 1994 08:49:37 GMT"
22    pub fn format_imf_fixdate(&self) -> String {
23        // Days of week
24        const DAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
25        // Months
26        const MONTHS: [&str; 12] = [
27            "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
28        ];
29
30        // Calculate date components from timestamp
31        // This is a simplified calculation - in production you'd use a proper library
32        let total_days = self.timestamp / 86400;
33
34        // Simplified: assume year 2024-2030 range for formatting
35        // In production, use full date arithmetic
36        let days_since_epoch = total_days + 719528; // Adjust for Unix epoch
37
38        // Calculate year (simplified approximation)
39        let mut year = 1970i64;
40        let mut remaining_days = days_since_epoch;
41
42        while remaining_days >= 365 {
43            let days_in_year = if is_leap_year(year) { 366 } else { 365 };
44            if remaining_days >= days_in_year {
45                remaining_days -= days_in_year;
46                year += 1;
47            } else {
48                break;
49            }
50        }
51
52        // Calculate month and day
53        const DAYS_IN_MONTH: [u64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
54        let mut month = 0usize;
55        while month < 12 {
56            let dim = if month == 1 && is_leap_year(year) {
57                29
58            } else {
59                DAYS_IN_MONTH[month]
60            };
61            if remaining_days >= dim as i64 {
62                remaining_days -= dim as i64;
63                month += 1;
64            } else {
65                break;
66            }
67        }
68
69        let day = remaining_days + 1; // 1-indexed
70
71        // Calculate time of day
72        let secs_in_day = ((self.timestamp % 86400) + 86400) % 86400;
73        let hour = secs_in_day / 3600;
74        let minute = (secs_in_day % 3600) / 60;
75        let second = secs_in_day % 60;
76
77        // Calculate day of week (Zeller's congruence simplified)
78        // For Gregorian calendar
79        let q = day;
80        let m = if month < 2 {
81            (month + 12) as i64
82        } else {
83            (month + 3) as i64
84        };
85        let y = if month < 2 { year - 1 } else { year };
86        let h = (q + (13 * (m + 1)) / 5 + y + y / 4 - y / 100 + y / 400) % 7;
87        let weekday_index = (h + 6) % 7; // Adjust so Sunday=0
88
89        format!(
90            "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
91            DAYS[weekday_index as usize], day, MONTHS[month], year, hour, minute, second
92        )
93    }
94
95    /// Try to parse an RFC 2822 formatted date string.
96    /// Returns None if parsing fails.
97    pub fn parse_rfc2822(date_str: &str) -> Option<Self> {
98        // Example formats:
99        // "Sun, 06 Nov 1994 08:49:37 GMT"
100        // "Sunday, 06-Nov-94 08:49:37 GMT"
101
102        let parts: Vec<&str> = date_str.split_whitespace().collect();
103        if parts.len() < 5 {
104            return None;
105        }
106
107        // Handle both "Day, DD Mon YYYY" and "Day, DD-Mon-YY" formats
108        let (day_str, mon_str, year_str);
109
110        if parts[1].contains('-') {
111            // Format: "DD-Mon-YY" or "DD-Mon-YYYY"
112            let date_parts: Vec<&str> = parts[1].split('-').collect();
113            if date_parts.len() != 3 {
114                return None;
115            }
116            day_str = date_parts[0];
117            mon_str = date_parts[1];
118            year_str = date_parts[2];
119        } else {
120            // Format: "DD Mon YYYY"
121            day_str = parts[1];
122            mon_str = parts[2];
123            year_str = parts[3];
124        }
125
126        let day: u32 = day_str.parse().ok()?;
127        let year: i64 = if year_str.len() == 2 {
128            // Two-digit year: assume 1900 or 2000
129            let yy: i32 = year_str.parse().ok()?;
130            if yy < 70 {
131                (2000 + yy) as i64
132            } else {
133                (1900 + yy) as i64
134            }
135        } else {
136            year_str.parse().ok()?
137        };
138
139        // Parse month name to number
140        const MONTH_NAMES: [&str; 12] = [
141            "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
142        ];
143        let month = MONTH_NAMES
144            .iter()
145            .position(|&m| m.eq_ignore_ascii_case(mon_str))? as u32
146            + 1;
147
148        // Parse time
149        let time_parts: Vec<&str> = parts[4].split(':').collect();
150        if time_parts.len() != 3 {
151            return None;
152        }
153        let hour: u32 = time_parts[0].parse().ok()?;
154        let minute: u32 = time_parts[1].parse().ok()?;
155        let second: u32 = time_parts[2].parse().ok()?;
156
157        // Convert to Unix timestamp (simplified calculation)
158        let timestamp = datetime_to_timestamp(year, month, day, hour, minute, second);
159
160        Some(Self { timestamp })
161    }
162}
163
164/// Check if a year is a leap year
165fn is_leap_year(year: i64) -> bool {
166    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
167}
168
169/// Convert datetime components to Unix timestamp (seconds since 1970-01-01)
170/// Simplified implementation for common use cases
171fn datetime_to_timestamp(
172    year: i64,
173    month: u32,
174    day: u32,
175    hour: u32,
176    minute: u32,
177    second: u32,
178) -> i64 {
179    // Days per month (non-leap year)
180    const DAYS_IN_MONTH: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
181
182    // Calculate total days from 1970-01-01
183    let mut total_days: i64 = 0;
184
185    // Add years
186    for y in 1970..year {
187        total_days += if is_leap_year(y) { 366 } else { 365 };
188    }
189
190    // Add months in current year
191    for m in 1..month {
192        total_days += if m == 2 && is_leap_year(year) {
193            29
194        } else {
195            DAYS_IN_MONTH[(m - 1) as usize]
196        } as i64;
197    }
198
199    // Add days (day is 1-indexed, so subtract 1)
200    total_days += (day - 1) as i64;
201
202    // Add time
203    total_days * 86400 + hour as i64 * 3600 + minute as i64 * 60 + second as i64
204}
205
206/// Manages HTTP conditional headers for smart resume and unchanged-file detection.
207pub struct ConditionalRequest {
208    pub last_modified: Option<SimpleDateTime>,
209    pub etag: Option<String>,
210    pub content_length: Option<u64>,
211    /// Track whether server returned 304 Not Modified in last request
212    pub not_modified: bool,
213}
214
215impl ConditionalRequest {
216    /// Create a new empty ConditionalRequest
217    pub fn new() -> Self {
218        Self {
219            last_modified: None,
220            etag: None,
221            content_length: None,
222            not_modified: false,
223        }
224    }
225}
226
227impl Default for ConditionalRequest {
228    fn default() -> Self {
229        Self::new()
230    }
231}
232
233impl ConditionalRequest {
234    /// Build headers for conditional request.
235    /// If both Last-Modified and ETag present, prefer ETag (stronger validation).
236    pub fn to_headers(&self) -> Vec<(String, String)> {
237        let mut headers = Vec::new();
238
239        if let Some(ref etag) = self.etag {
240            headers.push(("If-None-Match".into(), etag.clone()));
241            headers.push(("If-Match".into(), etag.clone()));
242        }
243
244        if let Some(ref lm) = self.last_modified {
245            headers.push(("If-Modified-Since".into(), lm.format_imf_fixdate()));
246            headers.push(("If-Unmodified-Since".into(), lm.format_imf_fixdate()));
247        }
248
249        if let Some(len) = self.content_length {
250            headers.push(("Range".into(), format!("bytes={}-", len)));
251        }
252
253        headers
254    }
255
256    /// Parse response headers to update state for next request.
257    pub fn update_from_response(&mut self, status: u16, headers: &[(String, String)]) {
258        for (name, value) in headers {
259            match name.to_lowercase().as_str() {
260                "last-modified" => {
261                    if let Some(dt) = SimpleDateTime::parse_rfc2822(value) {
262                        self.last_modified = Some(dt);
263                    }
264                }
265                "etag" => {
266                    self.etag = Some(value.trim_matches('"').to_string());
267                }
268                "content-length" => {
269                    if let Ok(len) = value.parse::<u64>() {
270                        self.content_length = Some(len);
271                    }
272                }
273                _ => {}
274            }
275        }
276
277        // Handle status codes
278        match status {
279            304 => {
280                // Not Modified — file unchanged, skip download
281                self.not_modified = true;
282            }
283            206 => {
284                // Partial Content — resume successful
285                self.not_modified = false;
286            }
287            416 => {
288                // Range Not Satisfiable — need full re-download
289                self.content_length = None;
290                self.not_modified = false;
291            }
292            _ => {
293                self.not_modified = false;
294            }
295        }
296    }
297
298    /// Should we skip this download? (304 Not Modified)
299    pub fn should_skip(&self) -> bool {
300        self.not_modified
301    }
302
303    /// Is resume possible? (have partial data + server supports Range)
304    pub fn can_resume(&self, local_file_size: u64) -> bool {
305        self.content_length.is_some_and(|len| local_file_size < len)
306    }
307
308    /// Need full re-download? (416 or no range support detected)
309    pub fn needs_full_redownload(&self) -> bool {
310        self.content_length.is_none()
311    }
312}
313
314/// Coordinates conditional GET across multiple URI attempts for the same resource.
315pub struct SmartResumeManager {
316    entries: HashMap<String, ConditionalRequest>, // keyed by URL or GID
317}
318
319impl SmartResumeManager {
320    /// Create a new SmartResumeManager
321    pub fn new() -> Self {
322        Self {
323            entries: HashMap::new(),
324        }
325    }
326
327    /// Get or create conditional state for a download
328    pub fn get_or_create(&mut self, key: &str) -> &mut ConditionalRequest {
329        self.entries.entry(key.to_string()).or_default()
330    }
331
332    /// Record that server returned 304 (unchanged)
333    pub fn mark_unchanged(&mut self, key: &str) {
334        if let Some(entry) = self.entries.get_mut(key) {
335            entry.not_modified = true;
336        }
337    }
338
339    /// Check if any URI returned 304 (all unchanged)
340    pub fn is_all_unchanged(&self, keys: &[&str]) -> bool {
341        keys.iter()
342            .all(|key| self.entries.get(*key).is_some_and(|e| e.not_modified))
343    }
344}
345
346impl Default for SmartResumeManager {
347    fn default() -> Self {
348        Self::new()
349    }
350}
351
352/// Action to take based on HTTP status code for smart resume scenarios.
353#[derive(Debug, Clone, PartialEq)]
354pub enum ResumeAction {
355    Continue,       // Normal download/resume
356    SkipUnchanged,  // 304 Not Modified — file up to date
357    RedownloadFull, // 416 Range Not Satisfiable — start over
358    RetryLater,     // 503 Server Unavailable — retry with delay
359}
360
361/// Handle special HTTP statuses for smart resume scenarios.
362/// Returns action to take.
363pub fn handle_resume_status(status: u16, _cond: &ConditionalRequest) -> ResumeAction {
364    match status {
365        200..=299 => ResumeAction::Continue,
366        304 => ResumeAction::SkipUnchanged,
367        416 => ResumeAction::RedownloadFull,
368        500..=599 => ResumeAction::RetryLater,
369        _ => ResumeAction::Continue, // Unknown — try anyway
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn test_conditional_headers_etag() {
379        let mut cond = ConditionalRequest::new();
380        cond.etag = Some("\"abc123\"".to_string());
381
382        let headers = cond.to_headers();
383
384        // Should have If-None-Match and If-Match with the etag value
385        assert!(
386            headers
387                .iter()
388                .any(|(k, v)| k == "If-None-Match" && v == "\"abc123\"")
389        );
390        assert!(
391            headers
392                .iter()
393                .any(|(k, v)| k == "If-Match" && v == "\"abc123\"")
394        );
395
396        // ETag value should be trimmed of quotes when stored but preserved in headers
397        cond.update_from_response(
398            200,
399            &[("ETag".to_string(), "\"new-etag-value\"".to_string())],
400        );
401        assert_eq!(cond.etag, Some("new-etag-value".to_string()));
402    }
403
404    #[test]
405    fn test_conditional_headers_last_modified() {
406        let mut cond = ConditionalRequest::new();
407
408        // Set last modified using a known timestamp
409        // November 6, 1994 08:49:37 GMT = 784111777 (approximately)
410        cond.last_modified = Some(SimpleDateTime::from_timestamp(784111777));
411
412        let headers = cond.to_headers();
413
414        // Should have If-Modified-Since and If-Unmodified-Since headers
415        assert!(headers.iter().any(|(k, _)| k == "If-Modified-Since"));
416        assert!(headers.iter().any(|(k, _)| k == "If-Unmodified-Since"));
417
418        // Verify the date format follows IMF-fixdate pattern (RFC 7231)
419        let ims_header = headers
420            .iter()
421            .find(|(k, _)| k == "If-Modified-Since")
422            .unwrap();
423        assert!(ims_header.1.contains("GMT"), "Date should end with GMT");
424        assert!(
425            ims_header.1.contains(","),
426            "Date should have comma after weekday"
427        );
428    }
429
430    #[test]
431    fn test_update_from_response_304() {
432        let mut cond = ConditionalRequest::new();
433
434        // Simulate server response with 304 status
435        cond.update_from_response(
436            304,
437            &[
438                ("ETag".to_string(), "\"static-content-v1\"".to_string()),
439                (
440                    "Last-Modified".to_string(),
441                    "Mon, 01 Jan 2024 00:00:00 GMT".to_string(),
442                ),
443            ],
444        );
445
446        // should_skip should return true after 304
447        assert!(
448            cond.should_skip(),
449            "should_skip should be true after 304 response"
450        );
451        assert!(cond.not_modified, "not_modified flag should be set");
452
453        // Headers should still be parsed
454        assert_eq!(cond.etag, Some("static-content-v1".to_string()));
455        assert!(cond.last_modified.is_some());
456    }
457
458    #[test]
459    fn test_handle_status_416_needs_full_redownload() {
460        let mut cond = ConditionalRequest::new();
461        cond.content_length = Some(1000); // Assume we had content length before
462
463        // Handle 416 status
464        let action = handle_resume_status(416, &cond);
465
466        assert_eq!(
467            action,
468            ResumeAction::RedownloadFull,
469            "416 should trigger RedownloadFull"
470        );
471
472        // Update from response should clear content_length
473        cond.update_from_response(416, &[]);
474        assert!(
475            cond.needs_full_redownload(),
476            "After 416, needs_full_redownload should be true"
477        );
478        assert!(
479            cond.content_length.is_none(),
480            "content_length should be cleared after 416"
481        );
482
483        // Test other status codes
484        assert_eq!(
485            handle_resume_status(304, &cond),
486            ResumeAction::SkipUnchanged
487        );
488        assert_eq!(handle_resume_status(200, &cond), ResumeAction::Continue);
489        assert_eq!(handle_resume_status(206, &cond), ResumeAction::Continue);
490        assert_eq!(handle_resume_status(503, &cond), ResumeAction::RetryLater);
491    }
492
493    #[test]
494    fn test_smart_resume_manager() {
495        let mut manager = SmartResumeManager::new();
496
497        // Get or create entry
498        let entry = manager.get_or_create("download-1");
499        entry.etag = Some("\"file-v1\"".to_string());
500
501        // Get existing entry
502        let entry2 = manager.get_or_create("download-1");
503        assert_eq!(
504            entry2.etag,
505            Some("\"file-v1\"".to_string()),
506            "Should retrieve existing entry"
507        );
508
509        // Mark as unchanged
510        manager.mark_unchanged("download-1");
511        assert!(
512            manager.is_all_unchanged(&["download-1"]),
513            "Should report as unchanged"
514        );
515
516        // Multiple keys
517        manager.get_or_create("download-2").not_modified = true;
518        assert!(
519            manager.is_all_unchanged(&["download-1", "download-2"]),
520            "All should be unchanged"
521        );
522
523        manager.get_or_create("download-3"); // New entry, not modified=false by default
524        assert!(
525            !manager.is_all_unchanged(&["download-1", "download-3"]),
526            "Not all unchanged"
527        );
528    }
529
530    #[test]
531    fn test_can_resume_and_needs_full_redownload() {
532        let mut cond = ConditionalRequest::new();
533
534        // No content length - cannot resume
535        assert!(
536            !cond.can_resume(100),
537            "Cannot resume without content length"
538        );
539        assert!(
540            cond.needs_full_redownload(),
541            "Needs full redownload without content length"
542        );
543
544        // With content length and local size smaller
545        cond.content_length = Some(1000);
546        assert!(
547            cond.can_resume(500),
548            "Can resume when local file is smaller"
549        );
550        assert!(
551            !cond.can_resume(1000),
552            "Cannot resume when local file equals content length"
553        );
554        assert!(
555            !cond.can_resume(1500),
556            "Cannot resume when local file is larger"
557        );
558        assert!(
559            !cond.needs_full_redownload(),
560            "Doesn't need full redownload with content length"
561        );
562    }
563
564    #[test]
565    fn test_simple_datetime_parsing() {
566        // Test parsing standard RFC 2822 format
567        let dt = SimpleDateTime::parse_rfc2822("Sun, 06 Nov 1994 08:49:37 GMT");
568        assert!(dt.is_some(), "Should parse valid RFC 2822 date");
569
570        let dt = dt.unwrap();
571        let formatted = dt.format_imf_fixdate();
572
573        // Verify it contains expected components (format may vary by implementation)
574        assert!(
575            formatted.ends_with("GMT"),
576            "Formatted date should end with GMT"
577        );
578
579        // Test invalid format
580        let invalid = SimpleDateTime::parse_rfc2822("invalid-date");
581        assert!(invalid.is_none(), "Should return None for invalid date");
582    }
583
584    #[test]
585    fn test_conditional_request_with_range() {
586        let mut cond = ConditionalRequest::new();
587        cond.content_length = Some(1024 * 1024); // 1 MB
588
589        let headers = cond.to_headers();
590
591        // Should have Range header
592        let range_header = headers.iter().find(|(k, _)| k == "Range");
593        assert!(
594            range_header.is_some(),
595            "Should have Range header when content_length is set"
596        );
597        // Range header format: bytes={content_length}-
598        let expected = format!("bytes={}-", cond.content_length.unwrap());
599        assert_eq!(range_header.unwrap().1, expected);
600    }
601}