Skip to main content

rget/
resume.rs

1//! Resume: remote validation and state reconciliation (PRD §12, §13).
2//!
3//! Resume is the normal case, not the exceptional one. Two questions have to be
4//! answered before a single byte is reused:
5//!
6//! 1. **Is the remote still the same object?** — [`validate`]
7//! 2. **Is the local file still the file we were writing?** — [`check_identity`]
8//!    and [`reconcile`]
9//!
10//! Either answer being "no" means we refuse or re-download, never "hope".
11
12use crate::file::DestFile;
13use crate::http::RemoteInfo;
14use crate::storage::{DownloadRecord, RangeRecord, RangeState};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Validators {
18    pub etag: Option<String>,
19    pub last_modified: Option<String>,
20    pub size: Option<u64>,
21}
22
23impl Validators {
24    pub fn of_record(rec: &DownloadRecord) -> Self {
25        Self {
26            etag: rec.etag.clone(),
27            last_modified: rec.last_modified.clone(),
28            size: rec.total_size,
29        }
30    }
31
32    pub fn of_remote(info: &RemoteInfo) -> Self {
33        Self {
34            etag: info.etag.clone(),
35            last_modified: info.last_modified.clone(),
36            size: info.size,
37        }
38    }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum Validation {
43    /// A validator positively confirmed the resource is unchanged.
44    Unchanged,
45    /// Nothing contradicts resuming, but nothing proves it either. The engine
46    /// warns; a checksum is the only real protection here.
47    Unverifiable(String),
48    /// The resource changed. Refuse (PRD Invariant 4).
49    Changed {
50        reason: String,
51        previous: Validators,
52        current: Validators,
53    },
54}
55
56/// Compare what we recorded last time against a fresh probe.
57pub fn validate(rec: &DownloadRecord, info: &RemoteInfo) -> Validation {
58    let previous = Validators::of_record(rec);
59    let current = Validators::of_remote(info);
60
61    let changed = |reason: String| Validation::Changed {
62        reason,
63        previous: previous.clone(),
64        current: current.clone(),
65    };
66
67    // Size is the cheapest and bluntest check, and it is decisive.
68    if let (Some(before), Some(now)) = (previous.size, current.size) {
69        if before != now {
70            return changed(format!("size changed from {before} to {now} bytes"));
71        }
72    }
73
74    let strong_before = previous
75        .etag
76        .as_deref()
77        .filter(|t| !t.trim_start().starts_with("W/"));
78    let strong_now = current
79        .etag
80        .as_deref()
81        .filter(|t| !t.trim_start().starts_with("W/"));
82
83    match (strong_before, strong_now) {
84        (Some(a), Some(b)) if a == b => return Validation::Unchanged,
85        (Some(a), Some(b)) => {
86            return changed(format!("ETag changed from {a} to {b}"));
87        }
88        (Some(_), None) => {
89            return Validation::Unverifiable(
90                "the server no longer sends a strong ETag, so we cannot confirm the file is \
91                 unchanged"
92                    .into(),
93            );
94        }
95        _ => {}
96    }
97
98    match (
99        previous.last_modified.as_deref(),
100        current.last_modified.as_deref(),
101    ) {
102        (Some(a), Some(b)) if a == b => return Validation::Unchanged,
103        (Some(a), Some(b)) => {
104            return changed(format!("Last-Modified changed from {a} to {b}"));
105        }
106        _ => {}
107    }
108
109    // Weak ETags are equality-of-meaning, not equality-of-bytes; matching ones
110    // are reassuring but not proof.
111    match (previous.etag.as_deref(), current.etag.as_deref()) {
112        (Some(a), Some(b)) if a == b => {
113            return Validation::Unverifiable(
114                "only a weak ETag is available, which does not guarantee identical bytes".into(),
115            );
116        }
117        (Some(a), Some(b)) => {
118            return changed(format!("ETag changed from {a} to {b}"));
119        }
120        _ => {}
121    }
122
123    if previous.size.is_some() && previous.size == current.size {
124        return Validation::Unverifiable(
125            "the server sends no ETag or Last-Modified; only the size matches".into(),
126        );
127    }
128
129    Validation::Unverifiable("the server provides no validators at all".into())
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum Identity {
134    /// Same file we were writing into.
135    Same,
136    /// A different file now occupies the destination path.
137    Replaced,
138    /// We never recorded an identity (older record, or first run).
139    Unrecorded,
140}
141
142/// Is the file at the destination the one we recorded progress against?
143///
144/// Path equality is not enough: the file can be deleted and recreated, or
145/// swapped for an unrelated one, between runs.
146pub fn check_identity(rec: &DownloadRecord, file: &DestFile) -> Identity {
147    let (Some(dev), Some(ino)) = (rec.file_dev, rec.file_ino) else {
148        return Identity::Unrecorded;
149    };
150    match file.identity() {
151        Ok(id) if id.dev == dev && id.ino == ino => Identity::Same,
152        Ok(_) => Identity::Replaced,
153        Err(_) => Identity::Unrecorded,
154    }
155}
156
157#[derive(Debug, Clone)]
158pub struct Reconciled {
159    pub ranges: Vec<RangeRecord>,
160    /// Bytes we are confident about and will not fetch again.
161    pub trusted_bytes: u64,
162    /// Bytes the database claimed but we chose to re-download.
163    pub discarded_bytes: u64,
164    pub notes: Vec<String>,
165}
166
167/// Bring persisted ranges into line with the file that is actually on disk.
168///
169/// The durability protocol (see `docs/CRASH_CONSISTENCY.md`) guarantees the
170/// database never claims more than the filesystem holds, so this is a
171/// belt-and-braces pass rather than the primary defence. It still earns its
172/// keep: it catches a destination truncated by filesystem recovery, a file
173/// restored from a smaller backup, or a plan whose total size no longer
174/// matches the remote.
175pub fn reconcile(ranges: &[RangeRecord], file_len: u64, total: Option<u64>) -> Reconciled {
176    let mut out = Vec::with_capacity(ranges.len());
177    let mut trusted = 0u64;
178    let mut discarded = 0u64;
179    let mut notes = Vec::new();
180
181    for r in ranges {
182        let mut r = *r;
183
184        // A range beyond the resource's current size is meaningless.
185        if let Some(total) = total {
186            if r.start >= total {
187                discarded += r.bytes_written;
188                notes.push(format!(
189                    "range {} starts past the end of the file ({} >= {total}); dropping it",
190                    r.idx, r.start
191                ));
192                continue;
193            }
194            if !r.is_open_ended() && r.end >= total {
195                r.end = total - 1;
196                if r.bytes_written > r.size() {
197                    discarded += r.bytes_written - r.size();
198                    r.bytes_written = r.size();
199                }
200            }
201        }
202
203        let claimed_end = r.start + r.bytes_written;
204        if claimed_end > file_len {
205            // The file is shorter than the database claims: trust the file.
206            let keep = file_len.saturating_sub(r.start);
207            discarded += r.bytes_written - keep;
208            notes.push(format!(
209                "range {} claimed {} bytes but the file is only {file_len} bytes; keeping {keep}",
210                r.idx, r.bytes_written
211            ));
212            r.bytes_written = keep;
213            r.state = RangeState::Pending;
214        }
215
216        if r.state == RangeState::Complete && r.bytes_written < r.size() {
217            // Complete but short: contradiction, so re-download it.
218            notes.push(format!(
219                "range {} was marked complete with only {} of {} bytes; re-downloading",
220                r.idx,
221                r.bytes_written,
222                r.size()
223            ));
224            discarded += r.bytes_written;
225            r.bytes_written = 0;
226            r.state = RangeState::Pending;
227        }
228
229        if r.state == RangeState::Complete {
230            trusted += r.size();
231        } else {
232            // Anything left `downloading` belonged to a process that died.
233            // Its durable prefix is trustworthy; nothing beyond it is.
234            r.state = RangeState::Pending;
235            trusted += r.bytes_written;
236        }
237        out.push(r);
238    }
239
240    Reconciled {
241        ranges: out,
242        trusted_bytes: trusted,
243        discarded_bytes: discarded,
244        notes,
245    }
246}
247
248/// Does this plan still cover exactly `total` bytes with no gaps? If not the
249/// engine must replan rather than patch (PRD Invariant 3).
250pub fn plan_is_intact(ranges: &[RangeRecord], total: u64) -> bool {
251    if ranges.is_empty() {
252        return total == 0;
253    }
254    let mut sorted: Vec<&RangeRecord> = ranges.iter().collect();
255    sorted.sort_by_key(|r| r.start);
256    let mut cursor = 0u64;
257    for r in sorted {
258        if r.start != cursor || r.end < r.start {
259            return false;
260        }
261        cursor = r.end + 1;
262    }
263    cursor == total
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::storage::{Status, mint_cookie, now};
270
271    fn rec(etag: Option<&str>, last_modified: Option<&str>, size: Option<u64>) -> DownloadRecord {
272        DownloadRecord {
273            id: "aa11bb".into(),
274            original_url: "https://x.example/f".into(),
275            resolved_url: None,
276            mirrors: vec![],
277            destination: "/tmp/f".into(),
278            filename: "f".into(),
279            total_size: size,
280            etag: etag.map(String::from),
281            last_modified: last_modified.map(String::from),
282            content_type: None,
283            accept_ranges: true,
284            expected_checksum: None,
285            checksum_algorithm: None,
286            file_cookie: mint_cookie(),
287            file_dev: Some(1),
288            file_ino: Some(2),
289            durable_bytes: 0,
290            status: Status::Paused,
291            error: None,
292            created_at: now(),
293            updated_at: now(),
294            completed_at: None,
295        }
296    }
297
298    fn info(etag: Option<&str>, last_modified: Option<&str>, size: Option<u64>) -> RemoteInfo {
299        RemoteInfo {
300            final_url: url::Url::parse("https://x.example/f").unwrap(),
301            size,
302            accept_ranges: true,
303            etag: etag.map(String::from),
304            last_modified: last_modified.map(String::from),
305            content_type: None,
306            content_disposition: None,
307            content_encoding: None,
308        }
309    }
310
311    #[test]
312    fn same_strong_etag_is_unchanged() {
313        let v = validate(
314            &rec(Some("\"abc\""), None, Some(100)),
315            &info(Some("\"abc\""), None, Some(100)),
316        );
317        assert_eq!(v, Validation::Unchanged);
318    }
319
320    #[test]
321    fn changed_etag_is_refused() {
322        let v = validate(
323            &rec(Some("\"abc\""), None, Some(100)),
324            &info(Some("\"def\""), None, Some(100)),
325        );
326        match v {
327            Validation::Changed {
328                reason,
329                previous,
330                current,
331            } => {
332                assert!(reason.contains("ETag"), "{reason}");
333                assert_eq!(previous.etag.as_deref(), Some("\"abc\""));
334                assert_eq!(current.etag.as_deref(), Some("\"def\""));
335            }
336            other => panic!("expected Changed, got {other:?}"),
337        }
338    }
339
340    #[test]
341    fn size_change_is_decisive_even_with_matching_etag() {
342        // A server can serve a stale ETag for changed content; size disagrees,
343        // so refuse.
344        let v = validate(
345            &rec(Some("\"abc\""), None, Some(100)),
346            &info(Some("\"abc\""), None, Some(101)),
347        );
348        assert!(matches!(v, Validation::Changed { .. }));
349    }
350
351    #[test]
352    fn changed_last_modified_is_refused() {
353        let v = validate(
354            &rec(None, Some("Mon, 01 Jan 2024 00:00:00 GMT"), Some(100)),
355            &info(None, Some("Tue, 02 Jan 2024 00:00:00 GMT"), Some(100)),
356        );
357        assert!(matches!(v, Validation::Changed { .. }));
358    }
359
360    #[test]
361    fn weak_etags_are_never_proof() {
362        let v = validate(
363            &rec(Some("W/\"abc\""), None, Some(100)),
364            &info(Some("W/\"abc\""), None, Some(100)),
365        );
366        assert!(matches!(v, Validation::Unverifiable(_)), "{v:?}");
367    }
368
369    #[test]
370    fn vanished_etag_is_unverifiable_not_unchanged() {
371        let v = validate(
372            &rec(Some("\"abc\""), None, Some(100)),
373            &info(None, None, Some(100)),
374        );
375        assert!(matches!(v, Validation::Unverifiable(_)), "{v:?}");
376    }
377
378    #[test]
379    fn no_validators_at_all_is_unverifiable() {
380        let v = validate(&rec(None, None, Some(100)), &info(None, None, Some(100)));
381        assert!(matches!(v, Validation::Unverifiable(_)), "{v:?}");
382        let v = validate(&rec(None, None, None), &info(None, None, None));
383        assert!(matches!(v, Validation::Unverifiable(_)), "{v:?}");
384    }
385
386    fn ranges() -> Vec<RangeRecord> {
387        vec![
388            RangeRecord {
389                idx: 0,
390                start: 0,
391                end: 499,
392                state: RangeState::Complete,
393                bytes_written: 500,
394            },
395            RangeRecord {
396                idx: 1,
397                start: 500,
398                end: 999,
399                state: RangeState::Downloading,
400                bytes_written: 200,
401            },
402        ]
403    }
404
405    #[test]
406    fn reconcile_keeps_durable_progress() {
407        let r = reconcile(&ranges(), 1000, Some(1000));
408        assert_eq!(r.trusted_bytes, 700);
409        assert_eq!(r.discarded_bytes, 0);
410        assert_eq!(r.ranges[0].state, RangeState::Complete);
411        // In-flight ranges come back as pending, keeping their prefix.
412        assert_eq!(r.ranges[1].state, RangeState::Pending);
413        assert_eq!(r.ranges[1].bytes_written, 200);
414    }
415
416    #[test]
417    fn reconcile_trusts_a_short_file_over_the_database() {
418        // Filesystem recovery truncated the file to 600 bytes.
419        let r = reconcile(&ranges(), 600, Some(1000));
420        assert_eq!(r.ranges[1].bytes_written, 100);
421        assert_eq!(r.discarded_bytes, 100);
422        assert!(!r.notes.is_empty());
423        assert_eq!(r.trusted_bytes, 600);
424    }
425
426    #[test]
427    fn reconcile_rejects_complete_but_short_ranges() {
428        let mut rs = ranges();
429        rs[0].bytes_written = 10; // complete, but only 10 of 500 bytes
430        let r = reconcile(&rs, 1000, Some(1000));
431        assert_eq!(r.ranges[0].state, RangeState::Pending);
432        assert_eq!(r.ranges[0].bytes_written, 0);
433        assert_eq!(r.discarded_bytes, 10);
434    }
435
436    #[test]
437    fn reconcile_clips_ranges_past_a_shrunken_resource() {
438        let r = reconcile(&ranges(), 1000, Some(700));
439        assert_eq!(r.ranges.len(), 2);
440        assert_eq!(r.ranges[1].end, 699);
441        // Range 0 is still fully inside the resource.
442        assert_eq!(r.ranges[0].state, RangeState::Complete);
443
444        // A range entirely past the new end is dropped, and the surviving
445        // range is clipped: 100 bytes clipped off range 0 plus range 1's 200.
446        let r = reconcile(&ranges(), 1000, Some(400));
447        assert_eq!(r.ranges.len(), 1);
448        assert_eq!(r.ranges[0].end, 399);
449        assert_eq!(r.discarded_bytes, 300);
450        assert_eq!(r.trusted_bytes, 400);
451    }
452
453    #[test]
454    fn plan_integrity_check() {
455        assert!(plan_is_intact(&ranges(), 1000));
456        assert!(!plan_is_intact(&ranges(), 1001));
457        assert!(!plan_is_intact(&[], 10));
458        assert!(plan_is_intact(&[], 0));
459
460        let gapped = vec![
461            RangeRecord {
462                idx: 0,
463                start: 0,
464                end: 99,
465                state: RangeState::Pending,
466                bytes_written: 0,
467            },
468            RangeRecord {
469                idx: 1,
470                start: 200,
471                end: 999,
472                state: RangeState::Pending,
473                bytes_written: 0,
474            },
475        ];
476        assert!(!plan_is_intact(&gapped, 1000));
477    }
478
479    #[test]
480    fn identity_detects_a_swapped_file() {
481        let dir = std::env::temp_dir().join(format!("rget-resume-{}", std::process::id()));
482        std::fs::create_dir_all(&dir).unwrap();
483        let path = dir.join("f");
484        std::fs::write(&path, b"x").unwrap();
485        let file = DestFile::open(&path).unwrap();
486        let id = file.identity().unwrap();
487
488        let mut r = rec(None, None, Some(1));
489        r.file_dev = Some(id.dev);
490        r.file_ino = Some(id.ino);
491        assert_eq!(check_identity(&r, &file), Identity::Same);
492
493        r.file_ino = Some(id.ino.wrapping_add(1));
494        assert_eq!(check_identity(&r, &file), Identity::Replaced);
495
496        r.file_dev = None;
497        assert_eq!(check_identity(&r, &file), Identity::Unrecorded);
498
499        std::fs::remove_dir_all(&dir).ok();
500    }
501}