Skip to main content

grit_lib/
resumable.rs

1//! Transfers that survive a dropped connection: downloads resumed with HTTP
2//! `Range` requests, and uploads over the [TUS] resumable-upload protocol.
3//!
4//! Clone uses [`download_resumable`] for the bundle a server advertises with
5//! `bundle-uri` ([`crate::bundle_uri`]); push uses [`TusUpload`] to send a large
6//! bundle to the endpoint a server advertises with `pushBundle`
7//! ([`crate::push`]). Both keep their progress on disk, so a transfer that is
8//! interrupted (even by the process exiting) picks up where it stopped the next
9//! time it runs, and both retry a dropped connection a few times before giving
10//! up.
11//!
12//! Requests go through [`HttpClient::send`]; clients that do not implement it
13//! cannot make these transfers. Progress is reported both as the
14//! [`ProgressPhase::TransferringBundle`] phase and, in more detail, as
15//! [`ResumableEvent`]s.
16//!
17//! [TUS]: https://tus.io/protocols/resumable-upload
18
19use std::fs::{File, OpenOptions};
20use std::io::{Read, Seek, SeekFrom, Write};
21use std::path::{Path, PathBuf};
22
23use serde::{Deserialize, Serialize};
24
25use crate::fetch::Progress;
26use crate::progress::{Cancel, ProgressPhase, ProgressUnit};
27use crate::transport::http::{HttpClient, HttpMethod, HttpRequest, HttpResponse};
28
29/// TUS protocol version this client and `grit serve` speak.
30pub const TUS_VERSION: &str = "1.0.0";
31
32/// Content type of a TUS `PATCH` body.
33pub const TUS_PATCH_CONTENT_TYPE: &str = "application/offset+octet-stream";
34
35/// Bytes sent per TUS `PATCH` request.
36const PATCH_CHUNK: usize = 4 * 1024 * 1024;
37
38/// Bytes read from a download body between progress reports.
39const READ_CHUNK: usize = 64 * 1024;
40
41/// Times a dropped connection is retried before the transfer gives up.
42const MAX_ATTEMPTS: u32 = 5;
43
44/// Which way a resumable transfer moves data.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum TransferDirection {
47    /// Downloading a bundle from the server.
48    Download,
49    /// Uploading a bundle to the server.
50    Upload,
51}
52
53/// Progress of a resumable transfer, reported through
54/// [`Progress::resumable_transfer`].
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum ResumableEvent {
57    /// The transfer began. `resumed_from` is how many bytes an earlier attempt
58    /// had already moved (zero for a fresh transfer).
59    Started {
60        /// Download or upload.
61        direction: TransferDirection,
62        /// The URL being transferred.
63        url: String,
64        /// Total bytes, when known.
65        total: Option<u64>,
66        /// Bytes already in place before this run.
67        resumed_from: u64,
68    },
69    /// More bytes arrived or were accepted.
70    Advanced {
71        /// Bytes in place so far, including `resumed_from`.
72        done: u64,
73        /// Total bytes, when known.
74        total: Option<u64>,
75    },
76    /// The connection dropped; the transfer is picking up again at `done`.
77    Reconnecting {
78        /// Bytes in place when the connection dropped.
79        done: u64,
80        /// Which retry this is, starting at 1.
81        attempt: u32,
82    },
83    /// Every byte is in place.
84    Finished {
85        /// Total bytes transferred.
86        done: u64,
87        /// Bytes that were already in place before this run.
88        resumed_from: u64,
89    },
90}
91
92/// Why a resumable transfer failed.
93#[derive(Debug, thiserror::Error)]
94pub enum ResumableError {
95    /// The server answered with a status the transfer cannot continue from.
96    #[error("HTTP {status} from {url}")]
97    Status {
98        /// The request URL.
99        url: String,
100        /// The response status.
101        status: u16,
102    },
103    /// The server's answer did not follow the protocol.
104    #[error("unexpected response from {url}: {detail}")]
105    Protocol {
106        /// The request URL.
107        url: String,
108        /// What was wrong.
109        detail: String,
110    },
111    /// The connection kept dropping.
112    #[error("the transfer of {url} kept failing after {attempts} attempts: {last}")]
113    Exhausted {
114        /// The URL being transferred.
115        url: String,
116        /// Attempts made.
117        attempts: u32,
118        /// The last failure.
119        last: String,
120    },
121    /// The HTTP client failed (transport or authentication).
122    #[error(transparent)]
123    Http(Box<crate::error::Error>),
124    /// Reading or writing the local file failed.
125    #[error(transparent)]
126    Io(#[from] std::io::Error),
127    /// [`Cancel`] asked the download to stop. The partial file is kept, so
128    /// the next call resumes it.
129    #[error("operation cancelled")]
130    Cancelled,
131}
132
133impl From<crate::error::Error> for ResumableError {
134    fn from(e: crate::error::Error) -> Self {
135        Self::Http(Box::new(e))
136    }
137}
138
139/// Result alias for resumable transfers.
140pub type Result<T> = std::result::Result<T, ResumableError>;
141
142/// What [`download_resumable`] did.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub struct DownloadOutcome {
145    /// Size of the downloaded file.
146    pub bytes: u64,
147    /// Bytes that were already on disk from an earlier, interrupted run.
148    pub resumed_from: u64,
149}
150
151/// What is known about a partial download, kept next to it as JSON.
152#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
153struct DownloadState {
154    /// The URL the bytes came from; another URL starts over.
155    url: String,
156    /// The server's `ETag` (or `Last-Modified`), sent back as `If-Range` so a
157    /// changed file is downloaded afresh instead of spliced.
158    validator: Option<String>,
159    /// Total size, once the server said.
160    total: Option<u64>,
161    /// Whether every byte is in place (the file is at its final path).
162    complete: bool,
163}
164
165/// The files a download to `dest` keeps: the bytes so far and their state.
166fn download_paths(dest: &Path) -> (PathBuf, PathBuf) {
167    let name = dest
168        .file_name()
169        .map(|n| n.to_string_lossy().into_owned())
170        .unwrap_or_default();
171    (
172        dest.with_file_name(format!("{name}.part")),
173        dest.with_file_name(format!("{name}.json")),
174    )
175}
176
177fn load_state(path: &Path) -> Option<DownloadState> {
178    serde_json::from_slice(&std::fs::read(path).ok()?).ok()
179}
180
181fn save_state(path: &Path, state: &DownloadState) -> Result<()> {
182    let tmp = path.with_extension("json.tmp");
183    let bytes = serde_json::to_vec(state).map_err(|e| std::io::Error::other(e.to_string()))?;
184    std::fs::write(&tmp, bytes)?;
185    std::fs::rename(&tmp, path)?;
186    Ok(())
187}
188
189/// Remove `dest` and everything [`download_resumable`] keeps for it.
190///
191/// # Errors
192///
193/// A removal failure other than the file not existing.
194pub fn discard_download(dest: &Path) -> std::io::Result<()> {
195    let (part, state) = download_paths(dest);
196    for path in [dest, part.as_path(), state.as_path()] {
197        match std::fs::remove_file(path) {
198            Ok(()) => {}
199            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
200            Err(e) => return Err(e),
201        }
202    }
203    Ok(())
204}
205
206/// Download `url` to `dest`, continuing an earlier interrupted download.
207///
208/// Bytes arrive in `<dest>.part`, with what is known about them in
209/// `<dest>.json`; the file is renamed to `dest` once complete. When a
210/// previous run left a partial file for the same URL, only the rest is asked
211/// for (`Range: bytes=<n>-`, with `If-Range` so a file that changed on the
212/// server starts over). A connection that drops mid-download is retried a few
213/// times from where it stopped. When `dest` is already complete for `url`,
214/// nothing is downloaded.
215///
216/// # Parameters
217///
218/// - `client`: sends the requests ([`HttpClient::send`]).
219/// - `url`: what to download.
220/// - `dest`: where the finished file goes; its directory must exist.
221/// - `progress`: receives [`ResumableEvent`]s.
222/// - `cancel`: checked before each request and between body chunks.
223///
224/// # Errors
225///
226/// [`ResumableError::Status`] when the server refuses the download,
227/// [`ResumableError::Exhausted`] when the connection keeps dropping,
228/// [`ResumableError::Cancelled`] when `cancel` fires, and I/O errors from the
229/// local files. The partial file is kept so a later call can resume.
230pub fn download_resumable(
231    client: &dyn HttpClient,
232    url: &str,
233    dest: &Path,
234    progress: &mut dyn Progress,
235    cancel: &dyn Cancel,
236) -> Result<DownloadOutcome> {
237    let (part, state_path) = download_paths(dest);
238    let mut state = load_state(&state_path).unwrap_or_default();
239    if state.url != url {
240        discard_download(dest)?;
241        state = DownloadState {
242            url: url.to_owned(),
243            ..DownloadState::default()
244        };
245    }
246    if state.complete {
247        if let (Some(total), Ok(meta)) = (state.total, std::fs::metadata(dest)) {
248            if meta.len() == total {
249                return Ok(DownloadOutcome {
250                    bytes: total,
251                    resumed_from: total,
252                });
253            }
254        }
255        discard_download(dest)?;
256        state = DownloadState {
257            url: url.to_owned(),
258            ..DownloadState::default()
259        };
260    }
261
262    let resumed_from = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
263    let mut started = false;
264    let mut attempt = 0;
265    let mut last_failure = String::new();
266    loop {
267        if cancel.is_cancelled() {
268            return Err(ResumableError::Cancelled);
269        }
270        if attempt > 0 {
271            let done = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
272            if attempt >= MAX_ATTEMPTS {
273                return Err(ResumableError::Exhausted {
274                    url: url.to_owned(),
275                    attempts: attempt,
276                    last: last_failure,
277                });
278            }
279            progress.resumable_transfer(&ResumableEvent::Reconnecting { done, attempt });
280        }
281        attempt += 1;
282        match download_attempt(
283            client,
284            url,
285            &part,
286            &mut state,
287            &state_path,
288            progress,
289            cancel,
290            |p, s| {
291                if !started {
292                    started = true;
293                    p.start_with_unit(
294                        ProgressPhase::TransferringBundle,
295                        ProgressUnit::Bytes,
296                        s.total,
297                    );
298                    p.set(s.resumed_from);
299                    p.resumable_transfer(&ResumableEvent::Started {
300                        direction: TransferDirection::Download,
301                        url: url.to_owned(),
302                        total: s.total,
303                        resumed_from: s.resumed_from,
304                    });
305                }
306            },
307        ) {
308            Ok(AttemptOutcome::Complete(bytes)) => {
309                std::fs::rename(&part, dest)?;
310                state.complete = true;
311                state.total = Some(bytes);
312                save_state(&state_path, &state)?;
313                progress.set(bytes);
314                progress.finish();
315                progress.resumable_transfer(&ResumableEvent::Finished {
316                    done: bytes,
317                    resumed_from: resumed_from.min(bytes),
318                });
319                return Ok(DownloadOutcome {
320                    bytes,
321                    resumed_from: resumed_from.min(bytes),
322                });
323            }
324            Ok(AttemptOutcome::Dropped(why)) => last_failure = why,
325            Err(e) => return Err(e),
326        }
327    }
328}
329
330/// Where a download attempt starts, reported once when the first one answers.
331struct AttemptStart {
332    total: Option<u64>,
333    resumed_from: u64,
334}
335
336enum AttemptOutcome {
337    /// Every byte is in the partial file; the value is its size.
338    Complete(u64),
339    /// The connection dropped before the end.
340    Dropped(String),
341}
342
343/// One request of a download: ask for the bytes not yet on disk and append
344/// them to `part`.
345#[allow(clippy::too_many_arguments)]
346fn download_attempt(
347    client: &dyn HttpClient,
348    url: &str,
349    part: &Path,
350    state: &mut DownloadState,
351    state_path: &Path,
352    progress: &mut dyn Progress,
353    cancel: &dyn Cancel,
354    mut on_start: impl FnMut(&mut dyn Progress, &AttemptStart),
355) -> Result<AttemptOutcome> {
356    let have = std::fs::metadata(part).map(|m| m.len()).unwrap_or(0);
357    let mut request = HttpRequest::new(HttpMethod::Get, url).long_running();
358    if have > 0 {
359        request = request.header("Range", format!("bytes={have}-"));
360        if let Some(validator) = &state.validator {
361            request = request.header("If-Range", validator.clone());
362        }
363    }
364    let response = match client.send(&request) {
365        Ok(r) => r,
366        Err(e) => return Ok(AttemptOutcome::Dropped(e.to_string())),
367    };
368    let (mut offset, total) =
369        match response.status {
370            206 if have > 0 => {
371                let (start, total) = parse_content_range(response.header("content-range"))
372                    .ok_or_else(|| ResumableError::Protocol {
373                        url: url.to_owned(),
374                        detail: "206 without a usable Content-Range".to_owned(),
375                    })?;
376                if start != have {
377                    return Err(ResumableError::Protocol {
378                        url: url.to_owned(),
379                        detail: format!("asked for bytes from {have}, got bytes from {start}"),
380                    });
381                }
382                (have, total)
383            }
384            200 | 206 => {
385                // A full answer: the server ignored the range or the file changed.
386                let total = response
387                    .header("content-length")
388                    .and_then(|v| v.trim().parse().ok());
389                (0, total)
390            }
391            416 if have > 0 && state.total == Some(have) => {
392                on_start(
393                    progress,
394                    &AttemptStart {
395                        total: state.total,
396                        resumed_from: have,
397                    },
398                );
399                return Ok(AttemptOutcome::Complete(have));
400            }
401            416 => {
402                // The partial file is longer than the server's: start over.
403                File::create(part)?;
404                return Ok(AttemptOutcome::Dropped(
405                    "the server's file is shorter than the partial download".to_owned(),
406                ));
407            }
408            status => {
409                return Err(ResumableError::Status {
410                    url: url.to_owned(),
411                    status,
412                })
413            }
414        };
415    state.total = total;
416    state.validator = validator(&response);
417    save_state(state_path, state)?;
418    on_start(
419        progress,
420        &AttemptStart {
421            total,
422            resumed_from: offset,
423        },
424    );
425
426    let mut file = if offset == 0 {
427        File::create(part)?
428    } else {
429        OpenOptions::new().append(true).open(part)?
430    };
431    let mut body = response.body;
432    let mut buf = vec![0u8; READ_CHUNK];
433    loop {
434        if cancel.is_cancelled() {
435            file.flush()?;
436            return Err(ResumableError::Cancelled);
437        }
438        let n = match body.read(&mut buf) {
439            Ok(0) => break,
440            Ok(n) => n,
441            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
442            Err(e) => {
443                file.flush()?;
444                return Ok(AttemptOutcome::Dropped(e.to_string()));
445            }
446        };
447        file.write_all(&buf[..n])?;
448        offset += n as u64;
449        progress.set(offset);
450        progress.resumable_transfer(&ResumableEvent::Advanced {
451            done: offset,
452            total,
453        });
454    }
455    file.flush()?;
456    file.sync_all()?;
457    match total {
458        Some(total) if offset < total => Ok(AttemptOutcome::Dropped(format!(
459            "the connection closed after {offset} of {total} bytes"
460        ))),
461        _ => Ok(AttemptOutcome::Complete(offset)),
462    }
463}
464
465/// The value to send back as `If-Range`: a strong `ETag`, else `Last-Modified`.
466fn validator(response: &HttpResponse) -> Option<String> {
467    response
468        .header("etag")
469        .filter(|v| !v.starts_with("W/"))
470        .or_else(|| response.header("last-modified"))
471        .map(str::to_owned)
472}
473
474/// Parse `bytes <start>-<end>/<total>` into the start and (known) total.
475fn parse_content_range(value: Option<&str>) -> Option<(u64, Option<u64>)> {
476    let rest = value?.trim().strip_prefix("bytes ")?;
477    let (range, total) = rest.split_once('/')?;
478    let (start, _end) = range.split_once('-')?;
479    let start = start.trim().parse().ok()?;
480    let total = match total.trim() {
481        "*" => None,
482        t => Some(t.parse().ok()?),
483    };
484    Some((start, total))
485}
486
487/// An upload to a TUS endpoint.
488///
489/// [`TusUpload::create`] asks the endpoint for a new upload of a known length
490/// and returns its URL; [`TusUpload::send_file`] sends a file to it, starting
491/// at whatever offset the server already has, so calling it again after an
492/// interruption (with the same upload URL) only sends the rest.
493#[derive(Debug, Clone, PartialEq, Eq)]
494pub struct TusUpload {
495    /// Absolute URL of the upload.
496    pub url: String,
497    /// Total bytes of the upload.
498    pub length: u64,
499}
500
501impl TusUpload {
502    /// Create an upload of `length` bytes at `endpoint` (TUS creation
503    /// extension).
504    ///
505    /// # Errors
506    ///
507    /// [`ResumableError::Status`] when the server refuses, and
508    /// [`ResumableError::Protocol`] when it answers without a `Location`.
509    pub fn create(client: &dyn HttpClient, endpoint: &str, length: u64) -> Result<Self> {
510        let request = HttpRequest::new(HttpMethod::Post, endpoint)
511            .header("Tus-Resumable", TUS_VERSION)
512            .header("Upload-Length", length.to_string());
513        let response = client.send(&request)?;
514        if response.status != 201 {
515            return Err(ResumableError::Status {
516                url: endpoint.to_owned(),
517                status: response.status,
518            });
519        }
520        let location = response
521            .header("location")
522            .ok_or_else(|| ResumableError::Protocol {
523                url: endpoint.to_owned(),
524                detail: "no Location for the new upload".to_owned(),
525            })?;
526        let url = resolve_url(endpoint, location).ok_or_else(|| ResumableError::Protocol {
527            url: endpoint.to_owned(),
528            detail: format!("bad Location {location:?}"),
529        })?;
530        Ok(Self { url, length })
531    }
532
533    /// How many bytes the server has, or `None` when it no longer knows the
534    /// upload (expired or already used).
535    ///
536    /// # Errors
537    ///
538    /// [`ResumableError::Status`] for other refusals, and
539    /// [`ResumableError::Protocol`] for an answer without `Upload-Offset`.
540    pub fn offset(&self, client: &dyn HttpClient) -> Result<Option<u64>> {
541        let request =
542            HttpRequest::new(HttpMethod::Head, &self.url).header("Tus-Resumable", TUS_VERSION);
543        let response = client.send(&request)?;
544        match response.status {
545            404 | 410 => Ok(None),
546            s if (200..300).contains(&s) => upload_offset(&response, &self.url).map(Some),
547            status => Err(ResumableError::Status {
548                url: self.url.clone(),
549                status,
550            }),
551        }
552    }
553
554    /// Send `file` (exactly [`Self::length`] bytes) to the upload, starting at
555    /// the server's offset and resynchronizing after each failed request.
556    ///
557    /// # Errors
558    ///
559    /// [`ResumableError::Status`] when the server no longer knows the upload
560    /// (`404`/`410`) or refuses it, [`ResumableError::Exhausted`] when requests
561    /// keep failing, and I/O errors reading `file`.
562    pub fn send_file(
563        &self,
564        client: &dyn HttpClient,
565        file: &Path,
566        progress: &mut dyn Progress,
567    ) -> Result<()> {
568        let gone = |status| ResumableError::Status {
569            url: self.url.clone(),
570            status,
571        };
572        let mut offset = self.offset(client)?.ok_or_else(|| gone(404))?;
573        let resumed_from = offset;
574        progress.start_with_unit(
575            ProgressPhase::TransferringBundle,
576            ProgressUnit::Bytes,
577            Some(self.length),
578        );
579        progress.set(offset);
580        progress.resumable_transfer(&ResumableEvent::Started {
581            direction: TransferDirection::Upload,
582            url: self.url.clone(),
583            total: Some(self.length),
584            resumed_from,
585        });
586        let mut input = File::open(file)?;
587        let mut buf = vec![0u8; PATCH_CHUNK];
588        let mut failures = 0;
589        while offset < self.length {
590            let n = usize::try_from(self.length - offset)
591                .map_or(PATCH_CHUNK, |left| left.min(PATCH_CHUNK));
592            input.seek(SeekFrom::Start(offset))?;
593            input.read_exact(&mut buf[..n])?;
594            let request = HttpRequest::new(HttpMethod::Patch, &self.url)
595                .header("Tus-Resumable", TUS_VERSION)
596                .header("Upload-Offset", offset.to_string())
597                .header("Content-Type", TUS_PATCH_CONTENT_TYPE)
598                .body(&buf[..n])
599                .long_running();
600            let failure = match client.send(&request) {
601                Ok(r) if r.status == 204 || r.status == 200 => {
602                    offset = upload_offset(&r, &self.url)?;
603                    failures = 0;
604                    progress.set(offset);
605                    progress.resumable_transfer(&ResumableEvent::Advanced {
606                        done: offset,
607                        total: Some(self.length),
608                    });
609                    continue;
610                }
611                Ok(r) if r.status == 404 || r.status == 410 => return Err(gone(r.status)),
612                Ok(r) if r.status == 409 => format!("offset {offset} was out of date"),
613                Ok(r) if (400..500).contains(&r.status) => return Err(gone(r.status)),
614                Ok(r) => format!("HTTP {}", r.status),
615                Err(e) => e.to_string(),
616            };
617            failures += 1;
618            if failures >= MAX_ATTEMPTS {
619                return Err(ResumableError::Exhausted {
620                    url: self.url.clone(),
621                    attempts: failures,
622                    last: failure,
623                });
624            }
625            progress.resumable_transfer(&ResumableEvent::Reconnecting {
626                done: offset,
627                attempt: failures,
628            });
629            // Ask the server where it got to; it may have kept part of the chunk.
630            if let Ok(Some(server)) = self.offset(client) {
631                offset = server;
632            }
633        }
634        progress.set(self.length);
635        progress.finish();
636        progress.resumable_transfer(&ResumableEvent::Finished {
637            done: self.length,
638            resumed_from,
639        });
640        Ok(())
641    }
642}
643
644fn upload_offset(response: &HttpResponse, url: &str) -> Result<u64> {
645    response
646        .header("upload-offset")
647        .and_then(|v| v.trim().parse().ok())
648        .ok_or_else(|| ResumableError::Protocol {
649            url: url.to_owned(),
650            detail: "no Upload-Offset".to_owned(),
651        })
652}
653
654/// Resolve `reference` (absolute, or relative to `base`) to an absolute URL.
655#[must_use]
656pub fn resolve_url(base: &str, reference: &str) -> Option<String> {
657    if let Ok(absolute) = url::Url::parse(reference) {
658        return Some(absolute.to_string());
659    }
660    url::Url::parse(base)
661        .ok()?
662        .join(reference)
663        .ok()
664        .map(|u| u.to_string())
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670    use crate::fetch::NoProgress;
671    use crate::progress::{FlagCancel, NeverCancel};
672    use std::collections::VecDeque;
673    use std::sync::Mutex;
674
675    /// Serves one file from memory, honoring `Range`, and can cut a body short.
676    struct FileServer {
677        data: Vec<u8>,
678        etag: String,
679        /// Bytes to send before dropping, one entry per GET (then full bodies).
680        cuts: Mutex<VecDeque<usize>>,
681        ranges: Mutex<Vec<Option<String>>>,
682    }
683
684    struct Cut {
685        data: std::io::Cursor<Vec<u8>>,
686        fail_after: bool,
687    }
688
689    impl Read for Cut {
690        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
691            let n = self.data.read(buf)?;
692            if n == 0 && self.fail_after {
693                return Err(std::io::Error::new(
694                    std::io::ErrorKind::ConnectionReset,
695                    "reset",
696                ));
697            }
698            Ok(n)
699        }
700    }
701
702    impl HttpClient for FileServer {
703        fn get(&self, _: &str, _: Option<&str>) -> crate::error::Result<Vec<u8>> {
704            unreachable!()
705        }
706        fn post(
707            &self,
708            _: &str,
709            _: &str,
710            _: &str,
711            _: &[u8],
712            _: Option<&str>,
713        ) -> crate::error::Result<Vec<u8>> {
714            unreachable!()
715        }
716        fn send(&self, request: &HttpRequest<'_>) -> crate::error::Result<HttpResponse> {
717            let range = request
718                .headers
719                .iter()
720                .find(|(n, _)| *n == "Range")
721                .map(|(_, v)| v.clone());
722            let if_range = request
723                .headers
724                .iter()
725                .find(|(n, _)| *n == "If-Range")
726                .map(|(_, v)| v.clone());
727            self.ranges
728                .lock()
729                .unwrap_or_else(|e| e.into_inner())
730                .push(range.clone());
731            let start = range
732                .as_deref()
733                .and_then(|r| r.strip_prefix("bytes="))
734                .and_then(|r| r.strip_suffix('-'))
735                .and_then(|r| r.parse::<usize>().ok())
736                .filter(|_| if_range.as_deref().is_none_or(|v| v == self.etag));
737            let total = self.data.len();
738            let (status, mut headers, body) = match start {
739                Some(s) if s >= total => (416, vec![], vec![]),
740                Some(s) => (
741                    206,
742                    vec![(
743                        "content-range".to_owned(),
744                        format!("bytes {s}-{}/{total}", total - 1),
745                    )],
746                    self.data[s..].to_vec(),
747                ),
748                None => (
749                    200,
750                    vec![("content-length".to_owned(), total.to_string())],
751                    self.data.clone(),
752                ),
753            };
754            headers.push(("etag".to_owned(), self.etag.clone()));
755            let cut = self
756                .cuts
757                .lock()
758                .unwrap_or_else(|e| e.into_inner())
759                .pop_front();
760            let (body, fail_after) = match cut {
761                Some(n) if n < body.len() => (body[..n].to_vec(), true),
762                _ => (body, false),
763            };
764            Ok(HttpResponse {
765                status,
766                headers,
767                body: Box::new(Cut {
768                    data: std::io::Cursor::new(body),
769                    fail_after,
770                }),
771            })
772        }
773    }
774
775    #[derive(Default)]
776    struct Events(Vec<ResumableEvent>);
777    impl crate::progress::ProgressSink for Events {}
778    impl Progress for Events {
779        fn resumable_transfer(&mut self, event: &ResumableEvent) {
780            if !matches!(event, ResumableEvent::Advanced { .. }) {
781                self.0.push(event.clone());
782            }
783        }
784    }
785
786    fn server(cuts: &[usize]) -> FileServer {
787        FileServer {
788            data: (0..200_000u32).map(|i| (i % 251) as u8).collect(),
789            etag: "\"v1\"".to_owned(),
790            cuts: Mutex::new(cuts.iter().copied().collect()),
791            ranges: Mutex::new(Vec::new()),
792        }
793    }
794
795    fn tempdir() -> tempfile::TempDir {
796        match tempfile::tempdir() {
797            Ok(d) => d,
798            Err(e) => panic!("tempdir: {e}"),
799        }
800    }
801
802    #[test]
803    fn dropped_downloads_resume_in_the_same_run() {
804        let dir = tempdir();
805        let dest = dir.path().join("clone.bundle");
806        let srv = server(&[70_000, 10_000]);
807        let mut events = Events::default();
808        let outcome = download_resumable(&srv, "http://h/b", &dest, &mut events, &NeverCancel);
809        assert_eq!(
810            outcome.ok(),
811            Some(DownloadOutcome {
812                bytes: 200_000,
813                resumed_from: 0
814            })
815        );
816        assert_eq!(std::fs::read(&dest).ok(), Some(srv.data.clone()));
817        let ranges = srv.ranges.lock().unwrap_or_else(|e| e.into_inner()).clone();
818        assert_eq!(
819            ranges,
820            [
821                None,
822                Some("bytes=70000-".to_owned()),
823                Some("bytes=80000-".to_owned())
824            ]
825        );
826        assert!(matches!(
827            events.0.as_slice(),
828            [
829                ResumableEvent::Started {
830                    resumed_from: 0,
831                    total: Some(200_000),
832                    ..
833                },
834                ResumableEvent::Reconnecting {
835                    done: 70_000,
836                    attempt: 1
837                },
838                ResumableEvent::Reconnecting {
839                    done: 80_000,
840                    attempt: 2
841                },
842                ResumableEvent::Finished {
843                    done: 200_000,
844                    resumed_from: 0
845                },
846            ]
847        ));
848    }
849
850    #[test]
851    fn a_later_run_resumes_a_partial_download() {
852        let dir = tempdir();
853        let dest = dir.path().join("clone.bundle");
854        // The first run's connection keeps dropping until it gives up.
855        let failing = server(&[50_000, 0, 0, 0, 0, 0]);
856        assert!(matches!(
857            download_resumable(&failing, "http://h/b", &dest, &mut NoProgress, &NeverCancel),
858            Err(ResumableError::Exhausted { .. })
859        ));
860        assert!(!dest.exists());
861
862        let srv = server(&[]);
863        let mut events = Events::default();
864        let outcome = download_resumable(&srv, "http://h/b", &dest, &mut events, &NeverCancel);
865        assert_eq!(outcome.ok().map(|o| o.resumed_from), Some(50_000));
866        assert_eq!(std::fs::read(&dest).ok(), Some(srv.data.clone()));
867        assert!(matches!(
868            events.0.first(),
869            Some(ResumableEvent::Started {
870                resumed_from: 50_000,
871                ..
872            })
873        ));
874
875        // Complete already: nothing is requested.
876        let again = server(&[]);
877        let outcome =
878            download_resumable(&again, "http://h/b", &dest, &mut NoProgress, &NeverCancel);
879        assert_eq!(outcome.ok().map(|o| o.resumed_from), Some(200_000));
880        assert!(again
881            .ranges
882            .lock()
883            .unwrap_or_else(|e| e.into_inner())
884            .is_empty());
885    }
886
887    #[test]
888    fn a_changed_file_or_url_starts_over() {
889        let dir = tempdir();
890        let dest = dir.path().join("clone.bundle");
891        let failing = server(&[50_000, 0, 0, 0, 0, 0]);
892        let _ = download_resumable(&failing, "http://h/b", &dest, &mut NoProgress, &NeverCancel);
893
894        let mut changed = server(&[]);
895        changed.etag = "\"v2\"".to_owned();
896        let outcome =
897            download_resumable(&changed, "http://h/b", &dest, &mut NoProgress, &NeverCancel);
898        assert_eq!(outcome.ok().map(|o| o.bytes), Some(200_000));
899        assert_eq!(std::fs::read(&dest).ok(), Some(changed.data.clone()));
900
901        let other = server(&[]);
902        let outcome = download_resumable(
903            &other,
904            "http://h/other",
905            &dest,
906            &mut NoProgress,
907            &NeverCancel,
908        );
909        assert_eq!(outcome.ok().map(|o| o.resumed_from), Some(0));
910        assert_eq!(
911            other
912                .ranges
913                .lock()
914                .unwrap_or_else(|e| e.into_inner())
915                .as_slice(),
916            [None]
917        );
918    }
919
920    /// Cancels once `after` bytes of the download are on disk.
921    struct CancelAfter {
922        flag: FlagCancel,
923        after: u64,
924    }
925    impl crate::progress::ProgressSink for CancelAfter {}
926    impl Progress for CancelAfter {
927        fn resumable_transfer(&mut self, event: &ResumableEvent) {
928            if let ResumableEvent::Advanced { done, .. } = event {
929                if *done >= self.after {
930                    self.flag.cancel();
931                }
932            }
933        }
934    }
935
936    #[test]
937    fn a_cancelled_download_stops_mid_body_and_resumes_next_time() {
938        let dir = tempdir();
939        let dest = dir.path().join("clone.bundle");
940        let srv = server(&[]);
941        let flag = FlagCancel::new();
942        let mut progress = CancelAfter {
943            flag: flag.clone(),
944            after: 1,
945        };
946        assert!(matches!(
947            download_resumable(&srv, "http://h/b", &dest, &mut progress, &flag),
948            Err(ResumableError::Cancelled)
949        ));
950        assert!(!dest.exists());
951        let (part, _) = download_paths(&dest);
952        let kept = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
953        assert!(
954            kept > 0 && kept < 200_000,
955            "one chunk is kept, got {kept} bytes"
956        );
957
958        let outcome = download_resumable(&srv, "http://h/b", &dest, &mut NoProgress, &NeverCancel);
959        assert_eq!(outcome.ok().map(|o| o.resumed_from), Some(kept));
960        assert_eq!(std::fs::read(&dest).ok(), Some(srv.data.clone()));
961    }
962
963    #[test]
964    fn an_already_cancelled_download_sends_no_request() {
965        let dir = tempdir();
966        let dest = dir.path().join("clone.bundle");
967        let srv = server(&[]);
968        let flag = FlagCancel::new();
969        flag.cancel();
970        assert!(matches!(
971            download_resumable(&srv, "http://h/b", &dest, &mut NoProgress, &flag),
972            Err(ResumableError::Cancelled)
973        ));
974        assert!(srv
975            .ranges
976            .lock()
977            .unwrap_or_else(|e| e.into_inner())
978            .is_empty());
979    }
980
981    #[test]
982    fn content_ranges_parse() {
983        assert_eq!(
984            parse_content_range(Some("bytes 10-99/100")),
985            Some((10, Some(100)))
986        );
987        assert_eq!(parse_content_range(Some("bytes 10-99/*")), Some((10, None)));
988        assert_eq!(parse_content_range(Some("items 1-2/3")), None);
989        assert_eq!(parse_content_range(None), None);
990    }
991
992    #[test]
993    fn urls_resolve_against_the_endpoint() {
994        assert_eq!(
995            resolve_url("http://h:1/repo/push", "/repo/push/abc").as_deref(),
996            Some("http://h:1/repo/push/abc")
997        );
998        assert_eq!(
999            resolve_url("http://h/repo/push", "https://other/x").as_deref(),
1000            Some("https://other/x")
1001        );
1002    }
1003
1004    /// A TUS server in memory that drops some PATCHes after keeping part of them.
1005    struct TusServer {
1006        stored: Mutex<Vec<u8>>,
1007        /// Per PATCH: bytes to keep before failing (`None` = accept all).
1008        patch_cuts: Mutex<VecDeque<Option<usize>>>,
1009        length: Mutex<u64>,
1010    }
1011
1012    impl HttpClient for TusServer {
1013        fn get(&self, _: &str, _: Option<&str>) -> crate::error::Result<Vec<u8>> {
1014            unreachable!()
1015        }
1016        fn post(
1017            &self,
1018            _: &str,
1019            _: &str,
1020            _: &str,
1021            _: &[u8],
1022            _: Option<&str>,
1023        ) -> crate::error::Result<Vec<u8>> {
1024            unreachable!()
1025        }
1026        fn send(&self, request: &HttpRequest<'_>) -> crate::error::Result<HttpResponse> {
1027            let header = |name: &str| {
1028                request
1029                    .headers
1030                    .iter()
1031                    .find(|(n, _)| n.eq_ignore_ascii_case(name))
1032                    .map(|(_, v)| v.clone())
1033            };
1034            let mut stored = self.stored.lock().unwrap_or_else(|e| e.into_inner());
1035            let reply = |status: u16, headers: Vec<(String, String)>| HttpResponse {
1036                status,
1037                headers,
1038                body: Box::new(std::io::empty()),
1039            };
1040            let offset = |n: usize| vec![("upload-offset".to_owned(), n.to_string())];
1041            Ok(match request.method {
1042                HttpMethod::Post => {
1043                    *self.length.lock().unwrap_or_else(|e| e.into_inner()) =
1044                        header("Upload-Length")
1045                            .and_then(|v| v.parse().ok())
1046                            .unwrap_or(0);
1047                    reply(201, vec![("location".to_owned(), "/push/u1".to_owned())])
1048                }
1049                HttpMethod::Head => reply(200, offset(stored.len())),
1050                HttpMethod::Patch => {
1051                    let at: usize = header("Upload-Offset")
1052                        .and_then(|v| v.parse().ok())
1053                        .unwrap_or(usize::MAX);
1054                    if at != stored.len() {
1055                        return Ok(reply(409, vec![]));
1056                    }
1057                    let cut = self
1058                        .patch_cuts
1059                        .lock()
1060                        .unwrap_or_else(|e| e.into_inner())
1061                        .pop_front()
1062                        .flatten();
1063                    match cut {
1064                        Some(keep) => {
1065                            stored.extend_from_slice(&request.body[..keep]);
1066                            return Err(crate::error::Error::Message("reset".to_owned()));
1067                        }
1068                        None => {
1069                            stored.extend_from_slice(request.body);
1070                            reply(204, offset(stored.len()))
1071                        }
1072                    }
1073                }
1074                HttpMethod::Get | HttpMethod::Put | HttpMethod::Delete => reply(405, vec![]),
1075            })
1076        }
1077    }
1078
1079    #[test]
1080    fn tus_uploads_resume_from_the_server_offset() {
1081        let dir = tempdir();
1082        let file = dir.path().join("push.bundle");
1083        let data: Vec<u8> = (0..(PATCH_CHUNK * 2 + 1000))
1084            .map(|i| (i % 253) as u8)
1085            .collect();
1086        assert!(std::fs::write(&file, &data).is_ok());
1087        let srv = TusServer {
1088            stored: Mutex::new(Vec::new()),
1089            patch_cuts: Mutex::new([None, Some(1000)].into_iter().collect()),
1090            length: Mutex::new(0),
1091        };
1092        let upload = match TusUpload::create(&srv, "http://h/push", data.len() as u64) {
1093            Ok(u) => u,
1094            Err(e) => panic!("create: {e}"),
1095        };
1096        assert_eq!(upload.url, "http://h/push/u1");
1097        let mut events = Events::default();
1098        assert!(upload.send_file(&srv, &file, &mut events).is_ok());
1099        assert_eq!(*srv.stored.lock().unwrap_or_else(|e| e.into_inner()), data);
1100        assert!(matches!(
1101            events.0.as_slice(),
1102            [
1103                ResumableEvent::Started {
1104                    resumed_from: 0,
1105                    ..
1106                },
1107                ResumableEvent::Reconnecting { attempt: 1, .. },
1108                ResumableEvent::Finished { .. },
1109            ]
1110        ));
1111        assert_eq!(upload.offset(&srv).ok().flatten(), Some(data.len() as u64));
1112    }
1113}