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) if matches!(e, crate::error::Error::Auth { .. }) => {
367            return Err(ResumableError::Http(Box::new(e)));
368        }
369        Err(e) => return Ok(AttemptOutcome::Dropped(e.to_string())),
370    };
371    let (mut offset, total) =
372        match response.status {
373            206 if have > 0 => {
374                let (start, total) = parse_content_range(response.header("content-range"))
375                    .ok_or_else(|| ResumableError::Protocol {
376                        url: url.to_owned(),
377                        detail: "206 without a usable Content-Range".to_owned(),
378                    })?;
379                if start != have {
380                    return Err(ResumableError::Protocol {
381                        url: url.to_owned(),
382                        detail: format!("asked for bytes from {have}, got bytes from {start}"),
383                    });
384                }
385                (have, total)
386            }
387            200 | 206 => {
388                // A full answer: the server ignored the range or the file changed.
389                let total = response
390                    .header("content-length")
391                    .and_then(|v| v.trim().parse().ok());
392                (0, total)
393            }
394            416 if have > 0 && state.total == Some(have) => {
395                on_start(
396                    progress,
397                    &AttemptStart {
398                        total: state.total,
399                        resumed_from: have,
400                    },
401                );
402                return Ok(AttemptOutcome::Complete(have));
403            }
404            416 => {
405                // The partial file is longer than the server's: start over.
406                File::create(part)?;
407                return Ok(AttemptOutcome::Dropped(
408                    "the server's file is shorter than the partial download".to_owned(),
409                ));
410            }
411            status => {
412                return Err(ResumableError::Status {
413                    url: url.to_owned(),
414                    status,
415                })
416            }
417        };
418    state.total = total;
419    state.validator = validator(&response);
420    save_state(state_path, state)?;
421    on_start(
422        progress,
423        &AttemptStart {
424            total,
425            resumed_from: offset,
426        },
427    );
428
429    let mut file = if offset == 0 {
430        File::create(part)?
431    } else {
432        OpenOptions::new().append(true).open(part)?
433    };
434    let mut body = response.body;
435    let mut buf = vec![0u8; READ_CHUNK];
436    loop {
437        if cancel.is_cancelled() {
438            file.flush()?;
439            return Err(ResumableError::Cancelled);
440        }
441        let n = match body.read(&mut buf) {
442            Ok(0) => break,
443            Ok(n) => n,
444            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
445            Err(e) => {
446                file.flush()?;
447                return Ok(AttemptOutcome::Dropped(e.to_string()));
448            }
449        };
450        file.write_all(&buf[..n])?;
451        offset += n as u64;
452        progress.set(offset);
453        progress.resumable_transfer(&ResumableEvent::Advanced {
454            done: offset,
455            total,
456        });
457    }
458    file.flush()?;
459    file.sync_all()?;
460    match total {
461        Some(total) if offset < total => Ok(AttemptOutcome::Dropped(format!(
462            "the connection closed after {offset} of {total} bytes"
463        ))),
464        _ => Ok(AttemptOutcome::Complete(offset)),
465    }
466}
467
468/// The value to send back as `If-Range`: a strong `ETag`, else `Last-Modified`.
469fn validator(response: &HttpResponse) -> Option<String> {
470    response
471        .header("etag")
472        .filter(|v| !v.starts_with("W/"))
473        .or_else(|| response.header("last-modified"))
474        .map(str::to_owned)
475}
476
477/// Parse `bytes <start>-<end>/<total>` into the start and (known) total.
478fn parse_content_range(value: Option<&str>) -> Option<(u64, Option<u64>)> {
479    let rest = value?.trim().strip_prefix("bytes ")?;
480    let (range, total) = rest.split_once('/')?;
481    let (start, _end) = range.split_once('-')?;
482    let start = start.trim().parse().ok()?;
483    let total = match total.trim() {
484        "*" => None,
485        t => Some(t.parse().ok()?),
486    };
487    Some((start, total))
488}
489
490/// An upload to a TUS endpoint.
491///
492/// [`TusUpload::create`] asks the endpoint for a new upload of a known length
493/// and returns its URL; [`TusUpload::send_file`] sends a file to it, starting
494/// at whatever offset the server already has, so calling it again after an
495/// interruption (with the same upload URL) only sends the rest.
496#[derive(Debug, Clone, PartialEq, Eq)]
497pub struct TusUpload {
498    /// Absolute URL of the upload.
499    pub url: String,
500    /// Total bytes of the upload.
501    pub length: u64,
502}
503
504impl TusUpload {
505    /// Create an upload of `length` bytes at `endpoint` (TUS creation
506    /// extension).
507    ///
508    /// # Errors
509    ///
510    /// [`ResumableError::Status`] when the server refuses, and
511    /// [`ResumableError::Protocol`] when it answers without a `Location`.
512    pub fn create(client: &dyn HttpClient, endpoint: &str, length: u64) -> Result<Self> {
513        let request = HttpRequest::new(HttpMethod::Post, endpoint)
514            .header("Tus-Resumable", TUS_VERSION)
515            .header("Upload-Length", length.to_string());
516        let response = client.send(&request)?;
517        if response.status != 201 {
518            return Err(ResumableError::Status {
519                url: endpoint.to_owned(),
520                status: response.status,
521            });
522        }
523        let location = response
524            .header("location")
525            .ok_or_else(|| ResumableError::Protocol {
526                url: endpoint.to_owned(),
527                detail: "no Location for the new upload".to_owned(),
528            })?;
529        let url = resolve_url(endpoint, location).ok_or_else(|| ResumableError::Protocol {
530            url: endpoint.to_owned(),
531            detail: format!("bad Location {location:?}"),
532        })?;
533        Ok(Self { url, length })
534    }
535
536    /// How many bytes the server has, or `None` when it no longer knows the
537    /// upload (expired or already used).
538    ///
539    /// # Errors
540    ///
541    /// [`ResumableError::Status`] for other refusals, and
542    /// [`ResumableError::Protocol`] for an answer without `Upload-Offset`.
543    pub fn offset(&self, client: &dyn HttpClient) -> Result<Option<u64>> {
544        let request =
545            HttpRequest::new(HttpMethod::Head, &self.url).header("Tus-Resumable", TUS_VERSION);
546        let response = client.send(&request)?;
547        match response.status {
548            404 | 410 => Ok(None),
549            s if (200..300).contains(&s) => upload_offset(&response, &self.url).map(Some),
550            status => Err(ResumableError::Status {
551                url: self.url.clone(),
552                status,
553            }),
554        }
555    }
556
557    /// Send `file` (exactly [`Self::length`] bytes) to the upload, starting at
558    /// the server's offset and resynchronizing after each failed request.
559    ///
560    /// # Errors
561    ///
562    /// [`ResumableError::Status`] when the server no longer knows the upload
563    /// (`404`/`410`) or refuses it, [`ResumableError::Exhausted`] when requests
564    /// keep failing, and I/O errors reading `file`.
565    pub fn send_file(
566        &self,
567        client: &dyn HttpClient,
568        file: &Path,
569        progress: &mut dyn Progress,
570    ) -> Result<()> {
571        let gone = |status| ResumableError::Status {
572            url: self.url.clone(),
573            status,
574        };
575        let mut offset = self.offset(client)?.ok_or_else(|| gone(404))?;
576        let resumed_from = offset;
577        progress.start_with_unit(
578            ProgressPhase::TransferringBundle,
579            ProgressUnit::Bytes,
580            Some(self.length),
581        );
582        progress.set(offset);
583        progress.resumable_transfer(&ResumableEvent::Started {
584            direction: TransferDirection::Upload,
585            url: self.url.clone(),
586            total: Some(self.length),
587            resumed_from,
588        });
589        let mut input = File::open(file)?;
590        let mut buf = vec![0u8; PATCH_CHUNK];
591        let mut failures = 0;
592        while offset < self.length {
593            let n = usize::try_from(self.length - offset)
594                .map_or(PATCH_CHUNK, |left| left.min(PATCH_CHUNK));
595            input.seek(SeekFrom::Start(offset))?;
596            input.read_exact(&mut buf[..n])?;
597            let request = HttpRequest::new(HttpMethod::Patch, &self.url)
598                .header("Tus-Resumable", TUS_VERSION)
599                .header("Upload-Offset", offset.to_string())
600                .header("Content-Type", TUS_PATCH_CONTENT_TYPE)
601                .body(&buf[..n])
602                .long_running();
603            let failure = match client.send(&request) {
604                Ok(r) if r.status == 204 || r.status == 200 => {
605                    offset = upload_offset(&r, &self.url)?;
606                    failures = 0;
607                    progress.set(offset);
608                    progress.resumable_transfer(&ResumableEvent::Advanced {
609                        done: offset,
610                        total: Some(self.length),
611                    });
612                    continue;
613                }
614                Ok(r) if r.status == 404 || r.status == 410 => return Err(gone(r.status)),
615                Ok(r) if r.status == 409 => format!("offset {offset} was out of date"),
616                Ok(r) if (400..500).contains(&r.status) => return Err(gone(r.status)),
617                Ok(r) => format!("HTTP {}", r.status),
618                Err(e) if matches!(e, crate::error::Error::Auth { .. }) => {
619                    return Err(ResumableError::Http(Box::new(e)));
620                }
621                Err(e) => e.to_string(),
622            };
623            failures += 1;
624            if failures >= MAX_ATTEMPTS {
625                return Err(ResumableError::Exhausted {
626                    url: self.url.clone(),
627                    attempts: failures,
628                    last: failure,
629                });
630            }
631            progress.resumable_transfer(&ResumableEvent::Reconnecting {
632                done: offset,
633                attempt: failures,
634            });
635            // Ask the server where it got to; it may have kept part of the chunk.
636            if let Ok(Some(server)) = self.offset(client) {
637                offset = server;
638            }
639        }
640        progress.set(self.length);
641        progress.finish();
642        progress.resumable_transfer(&ResumableEvent::Finished {
643            done: self.length,
644            resumed_from,
645        });
646        Ok(())
647    }
648}
649
650fn upload_offset(response: &HttpResponse, url: &str) -> Result<u64> {
651    response
652        .header("upload-offset")
653        .and_then(|v| v.trim().parse().ok())
654        .ok_or_else(|| ResumableError::Protocol {
655            url: url.to_owned(),
656            detail: "no Upload-Offset".to_owned(),
657        })
658}
659
660/// Resolve `reference` (absolute, or relative to `base`) to an absolute URL.
661#[must_use]
662pub fn resolve_url(base: &str, reference: &str) -> Option<String> {
663    if let Ok(absolute) = url::Url::parse(reference) {
664        return Some(absolute.to_string());
665    }
666    url::Url::parse(base)
667        .ok()?
668        .join(reference)
669        .ok()
670        .map(|u| u.to_string())
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676    use crate::fetch::NoProgress;
677    use crate::progress::{FlagCancel, NeverCancel};
678    use std::collections::VecDeque;
679    use std::sync::Mutex;
680
681    /// Serves one file from memory, honoring `Range`, and can cut a body short.
682    struct FileServer {
683        data: Vec<u8>,
684        etag: String,
685        /// Bytes to send before dropping, one entry per GET (then full bodies).
686        cuts: Mutex<VecDeque<usize>>,
687        ranges: Mutex<Vec<Option<String>>>,
688    }
689
690    struct Cut {
691        data: std::io::Cursor<Vec<u8>>,
692        fail_after: bool,
693    }
694
695    impl Read for Cut {
696        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
697            let n = self.data.read(buf)?;
698            if n == 0 && self.fail_after {
699                return Err(std::io::Error::new(
700                    std::io::ErrorKind::ConnectionReset,
701                    "reset",
702                ));
703            }
704            Ok(n)
705        }
706    }
707
708    impl HttpClient for FileServer {
709        fn get(&self, _: &str, _: Option<&str>) -> crate::error::Result<Vec<u8>> {
710            unreachable!()
711        }
712        fn post(
713            &self,
714            _: &str,
715            _: &str,
716            _: &str,
717            _: &[u8],
718            _: Option<&str>,
719        ) -> crate::error::Result<Vec<u8>> {
720            unreachable!()
721        }
722        fn send(&self, request: &HttpRequest<'_>) -> crate::error::Result<HttpResponse> {
723            let range = request
724                .headers
725                .iter()
726                .find(|(n, _)| *n == "Range")
727                .map(|(_, v)| v.clone());
728            let if_range = request
729                .headers
730                .iter()
731                .find(|(n, _)| *n == "If-Range")
732                .map(|(_, v)| v.clone());
733            self.ranges
734                .lock()
735                .unwrap_or_else(|e| e.into_inner())
736                .push(range.clone());
737            let start = range
738                .as_deref()
739                .and_then(|r| r.strip_prefix("bytes="))
740                .and_then(|r| r.strip_suffix('-'))
741                .and_then(|r| r.parse::<usize>().ok())
742                .filter(|_| if_range.as_deref().is_none_or(|v| v == self.etag));
743            let total = self.data.len();
744            let (status, mut headers, body) = match start {
745                Some(s) if s >= total => (416, vec![], vec![]),
746                Some(s) => (
747                    206,
748                    vec![(
749                        "content-range".to_owned(),
750                        format!("bytes {s}-{}/{total}", total - 1),
751                    )],
752                    self.data[s..].to_vec(),
753                ),
754                None => (
755                    200,
756                    vec![("content-length".to_owned(), total.to_string())],
757                    self.data.clone(),
758                ),
759            };
760            headers.push(("etag".to_owned(), self.etag.clone()));
761            let cut = self
762                .cuts
763                .lock()
764                .unwrap_or_else(|e| e.into_inner())
765                .pop_front();
766            let (body, fail_after) = match cut {
767                Some(n) if n < body.len() => (body[..n].to_vec(), true),
768                _ => (body, false),
769            };
770            Ok(HttpResponse {
771                status,
772                headers,
773                body: Box::new(Cut {
774                    data: std::io::Cursor::new(body),
775                    fail_after,
776                }),
777            })
778        }
779    }
780
781    #[derive(Default)]
782    struct Events(Vec<ResumableEvent>);
783    impl crate::progress::ProgressSink for Events {}
784    impl Progress for Events {
785        fn resumable_transfer(&mut self, event: &ResumableEvent) {
786            if !matches!(event, ResumableEvent::Advanced { .. }) {
787                self.0.push(event.clone());
788            }
789        }
790    }
791
792    fn server(cuts: &[usize]) -> FileServer {
793        FileServer {
794            data: (0..200_000u32).map(|i| (i % 251) as u8).collect(),
795            etag: "\"v1\"".to_owned(),
796            cuts: Mutex::new(cuts.iter().copied().collect()),
797            ranges: Mutex::new(Vec::new()),
798        }
799    }
800
801    fn tempdir() -> tempfile::TempDir {
802        match tempfile::tempdir() {
803            Ok(d) => d,
804            Err(e) => panic!("tempdir: {e}"),
805        }
806    }
807
808    #[test]
809    fn dropped_downloads_resume_in_the_same_run() {
810        let dir = tempdir();
811        let dest = dir.path().join("clone.bundle");
812        let srv = server(&[70_000, 10_000]);
813        let mut events = Events::default();
814        let outcome = download_resumable(&srv, "http://h/b", &dest, &mut events, &NeverCancel);
815        assert_eq!(
816            outcome.ok(),
817            Some(DownloadOutcome {
818                bytes: 200_000,
819                resumed_from: 0
820            })
821        );
822        assert_eq!(std::fs::read(&dest).ok(), Some(srv.data.clone()));
823        let ranges = srv.ranges.lock().unwrap_or_else(|e| e.into_inner()).clone();
824        assert_eq!(
825            ranges,
826            [
827                None,
828                Some("bytes=70000-".to_owned()),
829                Some("bytes=80000-".to_owned())
830            ]
831        );
832        assert!(matches!(
833            events.0.as_slice(),
834            [
835                ResumableEvent::Started {
836                    resumed_from: 0,
837                    total: Some(200_000),
838                    ..
839                },
840                ResumableEvent::Reconnecting {
841                    done: 70_000,
842                    attempt: 1
843                },
844                ResumableEvent::Reconnecting {
845                    done: 80_000,
846                    attempt: 2
847                },
848                ResumableEvent::Finished {
849                    done: 200_000,
850                    resumed_from: 0
851                },
852            ]
853        ));
854    }
855
856    #[test]
857    fn a_later_run_resumes_a_partial_download() {
858        let dir = tempdir();
859        let dest = dir.path().join("clone.bundle");
860        // The first run's connection keeps dropping until it gives up.
861        let failing = server(&[50_000, 0, 0, 0, 0, 0]);
862        assert!(matches!(
863            download_resumable(&failing, "http://h/b", &dest, &mut NoProgress, &NeverCancel),
864            Err(ResumableError::Exhausted { .. })
865        ));
866        assert!(!dest.exists());
867
868        let srv = server(&[]);
869        let mut events = Events::default();
870        let outcome = download_resumable(&srv, "http://h/b", &dest, &mut events, &NeverCancel);
871        assert_eq!(outcome.ok().map(|o| o.resumed_from), Some(50_000));
872        assert_eq!(std::fs::read(&dest).ok(), Some(srv.data.clone()));
873        assert!(matches!(
874            events.0.first(),
875            Some(ResumableEvent::Started {
876                resumed_from: 50_000,
877                ..
878            })
879        ));
880
881        // Complete already: nothing is requested.
882        let again = server(&[]);
883        let outcome =
884            download_resumable(&again, "http://h/b", &dest, &mut NoProgress, &NeverCancel);
885        assert_eq!(outcome.ok().map(|o| o.resumed_from), Some(200_000));
886        assert!(again
887            .ranges
888            .lock()
889            .unwrap_or_else(|e| e.into_inner())
890            .is_empty());
891    }
892
893    #[test]
894    fn a_changed_file_or_url_starts_over() {
895        let dir = tempdir();
896        let dest = dir.path().join("clone.bundle");
897        let failing = server(&[50_000, 0, 0, 0, 0, 0]);
898        let _ = download_resumable(&failing, "http://h/b", &dest, &mut NoProgress, &NeverCancel);
899
900        let mut changed = server(&[]);
901        changed.etag = "\"v2\"".to_owned();
902        let outcome =
903            download_resumable(&changed, "http://h/b", &dest, &mut NoProgress, &NeverCancel);
904        assert_eq!(outcome.ok().map(|o| o.bytes), Some(200_000));
905        assert_eq!(std::fs::read(&dest).ok(), Some(changed.data.clone()));
906
907        let other = server(&[]);
908        let outcome = download_resumable(
909            &other,
910            "http://h/other",
911            &dest,
912            &mut NoProgress,
913            &NeverCancel,
914        );
915        assert_eq!(outcome.ok().map(|o| o.resumed_from), Some(0));
916        assert_eq!(
917            other
918                .ranges
919                .lock()
920                .unwrap_or_else(|e| e.into_inner())
921                .as_slice(),
922            [None]
923        );
924    }
925
926    /// Cancels once `after` bytes of the download are on disk.
927    struct CancelAfter {
928        flag: FlagCancel,
929        after: u64,
930    }
931    impl crate::progress::ProgressSink for CancelAfter {}
932    impl Progress for CancelAfter {
933        fn resumable_transfer(&mut self, event: &ResumableEvent) {
934            if let ResumableEvent::Advanced { done, .. } = event {
935                if *done >= self.after {
936                    self.flag.cancel();
937                }
938            }
939        }
940    }
941
942    #[test]
943    fn a_cancelled_download_stops_mid_body_and_resumes_next_time() {
944        let dir = tempdir();
945        let dest = dir.path().join("clone.bundle");
946        let srv = server(&[]);
947        let flag = FlagCancel::new();
948        let mut progress = CancelAfter {
949            flag: flag.clone(),
950            after: 1,
951        };
952        assert!(matches!(
953            download_resumable(&srv, "http://h/b", &dest, &mut progress, &flag),
954            Err(ResumableError::Cancelled)
955        ));
956        assert!(!dest.exists());
957        let (part, _) = download_paths(&dest);
958        let kept = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
959        assert!(
960            kept > 0 && kept < 200_000,
961            "one chunk is kept, got {kept} bytes"
962        );
963
964        let outcome = download_resumable(&srv, "http://h/b", &dest, &mut NoProgress, &NeverCancel);
965        assert_eq!(outcome.ok().map(|o| o.resumed_from), Some(kept));
966        assert_eq!(std::fs::read(&dest).ok(), Some(srv.data.clone()));
967    }
968
969    #[test]
970    fn auth_failure_does_not_retry_the_download() {
971        use crate::error::{AuthFailure, Error};
972
973        struct AuthClient;
974        impl HttpClient for AuthClient {
975            fn get(&self, _: &str, _: Option<&str>) -> crate::error::Result<Vec<u8>> {
976                unreachable!()
977            }
978            fn post(
979                &self,
980                _: &str,
981                _: &str,
982                _: &str,
983                _: &[u8],
984                _: Option<&str>,
985            ) -> crate::error::Result<Vec<u8>> {
986                unreachable!()
987            }
988            fn send(&self, _: &HttpRequest<'_>) -> crate::error::Result<HttpResponse> {
989                Err(Error::Auth {
990                    url: "http://h/b".to_owned(),
991                    reason: AuthFailure::Rejected,
992                })
993            }
994        }
995
996        let dir = tempdir();
997        let dest = dir.path().join("clone.bundle");
998        let outcome = download_resumable(
999            &AuthClient,
1000            "http://h/b",
1001            &dest,
1002            &mut NoProgress,
1003            &NeverCancel,
1004        );
1005        assert!(
1006            matches!(outcome, Err(ResumableError::Http(_))),
1007            "auth errors must fail immediately: {outcome:?}"
1008        );
1009        assert!(
1010            !matches!(outcome, Err(ResumableError::Exhausted { .. })),
1011            "auth must not be retried like a dropped connection"
1012        );
1013    }
1014
1015    #[test]
1016    fn an_already_cancelled_download_sends_no_request() {
1017        let dir = tempdir();
1018        let dest = dir.path().join("clone.bundle");
1019        let srv = server(&[]);
1020        let flag = FlagCancel::new();
1021        flag.cancel();
1022        assert!(matches!(
1023            download_resumable(&srv, "http://h/b", &dest, &mut NoProgress, &flag),
1024            Err(ResumableError::Cancelled)
1025        ));
1026        assert!(srv
1027            .ranges
1028            .lock()
1029            .unwrap_or_else(|e| e.into_inner())
1030            .is_empty());
1031    }
1032
1033    #[test]
1034    fn content_ranges_parse() {
1035        assert_eq!(
1036            parse_content_range(Some("bytes 10-99/100")),
1037            Some((10, Some(100)))
1038        );
1039        assert_eq!(parse_content_range(Some("bytes 10-99/*")), Some((10, None)));
1040        assert_eq!(parse_content_range(Some("items 1-2/3")), None);
1041        assert_eq!(parse_content_range(None), None);
1042    }
1043
1044    #[test]
1045    fn urls_resolve_against_the_endpoint() {
1046        assert_eq!(
1047            resolve_url("http://h:1/repo/push", "/repo/push/abc").as_deref(),
1048            Some("http://h:1/repo/push/abc")
1049        );
1050        assert_eq!(
1051            resolve_url("http://h/repo/push", "https://other/x").as_deref(),
1052            Some("https://other/x")
1053        );
1054    }
1055
1056    /// A TUS server in memory that drops some PATCHes after keeping part of them.
1057    struct TusServer {
1058        stored: Mutex<Vec<u8>>,
1059        /// Per PATCH: bytes to keep before failing (`None` = accept all).
1060        patch_cuts: Mutex<VecDeque<Option<usize>>>,
1061        length: Mutex<u64>,
1062    }
1063
1064    impl HttpClient for TusServer {
1065        fn get(&self, _: &str, _: Option<&str>) -> crate::error::Result<Vec<u8>> {
1066            unreachable!()
1067        }
1068        fn post(
1069            &self,
1070            _: &str,
1071            _: &str,
1072            _: &str,
1073            _: &[u8],
1074            _: Option<&str>,
1075        ) -> crate::error::Result<Vec<u8>> {
1076            unreachable!()
1077        }
1078        fn send(&self, request: &HttpRequest<'_>) -> crate::error::Result<HttpResponse> {
1079            let header = |name: &str| {
1080                request
1081                    .headers
1082                    .iter()
1083                    .find(|(n, _)| n.eq_ignore_ascii_case(name))
1084                    .map(|(_, v)| v.clone())
1085            };
1086            let mut stored = self.stored.lock().unwrap_or_else(|e| e.into_inner());
1087            let reply = |status: u16, headers: Vec<(String, String)>| HttpResponse {
1088                status,
1089                headers,
1090                body: Box::new(std::io::empty()),
1091            };
1092            let offset = |n: usize| vec![("upload-offset".to_owned(), n.to_string())];
1093            Ok(match request.method {
1094                HttpMethod::Post => {
1095                    *self.length.lock().unwrap_or_else(|e| e.into_inner()) =
1096                        header("Upload-Length")
1097                            .and_then(|v| v.parse().ok())
1098                            .unwrap_or(0);
1099                    reply(201, vec![("location".to_owned(), "/push/u1".to_owned())])
1100                }
1101                HttpMethod::Head => reply(200, offset(stored.len())),
1102                HttpMethod::Patch => {
1103                    let at: usize = header("Upload-Offset")
1104                        .and_then(|v| v.parse().ok())
1105                        .unwrap_or(usize::MAX);
1106                    if at != stored.len() {
1107                        return Ok(reply(409, vec![]));
1108                    }
1109                    let cut = self
1110                        .patch_cuts
1111                        .lock()
1112                        .unwrap_or_else(|e| e.into_inner())
1113                        .pop_front()
1114                        .flatten();
1115                    match cut {
1116                        Some(keep) => {
1117                            stored.extend_from_slice(&request.body[..keep]);
1118                            return Err(crate::error::Error::Message("reset".to_owned()));
1119                        }
1120                        None => {
1121                            stored.extend_from_slice(request.body);
1122                            reply(204, offset(stored.len()))
1123                        }
1124                    }
1125                }
1126                HttpMethod::Get | HttpMethod::Put | HttpMethod::Delete => reply(405, vec![]),
1127            })
1128        }
1129    }
1130
1131    #[test]
1132    fn tus_uploads_resume_from_the_server_offset() {
1133        let dir = tempdir();
1134        let file = dir.path().join("push.bundle");
1135        let data: Vec<u8> = (0..(PATCH_CHUNK * 2 + 1000))
1136            .map(|i| (i % 253) as u8)
1137            .collect();
1138        assert!(std::fs::write(&file, &data).is_ok());
1139        let srv = TusServer {
1140            stored: Mutex::new(Vec::new()),
1141            patch_cuts: Mutex::new([None, Some(1000)].into_iter().collect()),
1142            length: Mutex::new(0),
1143        };
1144        let upload = match TusUpload::create(&srv, "http://h/push", data.len() as u64) {
1145            Ok(u) => u,
1146            Err(e) => panic!("create: {e}"),
1147        };
1148        assert_eq!(upload.url, "http://h/push/u1");
1149        let mut events = Events::default();
1150        assert!(upload.send_file(&srv, &file, &mut events).is_ok());
1151        assert_eq!(*srv.stored.lock().unwrap_or_else(|e| e.into_inner()), data);
1152        assert!(matches!(
1153            events.0.as_slice(),
1154            [
1155                ResumableEvent::Started {
1156                    resumed_from: 0,
1157                    ..
1158                },
1159                ResumableEvent::Reconnecting { attempt: 1, .. },
1160                ResumableEvent::Finished { .. },
1161            ]
1162        ));
1163        assert_eq!(upload.offset(&srv).ok().flatten(), Some(data.len() as u64));
1164    }
1165}