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/// Where to download when several processes share one resumable cache slot.
190#[derive(Debug)]
191pub enum ResumableDownloadDest {
192    /// The shared `dest`; hold `lock` until [`discard_download`] on it finishes.
193    Shared {
194        /// Path passed to [`download_resumable`].
195        dest: PathBuf,
196        /// Exclusive lock on the sibling `.lock` file for `dest`.
197        lock: File,
198    },
199    /// A one-off path in the cache directory; safe to use without locking.
200    Private(PathBuf),
201}
202
203/// Pick a resumable download path under a shared bundle cache.
204///
205/// Creates `cache_dir` when missing, then tries an exclusive lock on a sibling
206/// `.lock` file next to `shared_dest`. While that lock is held, only this
207/// process may use `shared_dest` and its `.part`/`.json` sidecars. When another
208/// process already holds the lock, returns a unique private path in `cache_dir`
209/// so parallel clones do not clobber the shared partial download.
210///
211/// # Errors
212///
213/// I/O errors opening or locking the lock file, or creating `cache_dir`.
214pub fn claim_resumable_download(
215    cache_dir: &Path,
216    shared_dest: &Path,
217) -> std::io::Result<ResumableDownloadDest> {
218    std::fs::create_dir_all(cache_dir)?;
219    let lock_path = shared_download_lock_path(shared_dest);
220    let file = OpenOptions::new()
221        .create(true)
222        .read(true)
223        .write(true)
224        .truncate(false)
225        .open(&lock_path)?;
226    match file.try_lock() {
227        Ok(()) => Ok(ResumableDownloadDest::Shared {
228            dest: shared_dest.to_path_buf(),
229            lock: file,
230        }),
231        Err(std::fs::TryLockError::WouldBlock) => Ok(ResumableDownloadDest::Private(
232            private_resumable_download_dest(cache_dir, shared_dest),
233        )),
234        Err(std::fs::TryLockError::Error(e)) => Err(e),
235    }
236}
237
238fn shared_download_lock_path(dest: &Path) -> PathBuf {
239    dest.with_extension("lock")
240}
241
242fn private_resumable_download_dest(cache_dir: &Path, shared_dest: &Path) -> PathBuf {
243    use std::sync::atomic::{AtomicU64, Ordering};
244    static SUFFIX: AtomicU64 = AtomicU64::new(0);
245    let stem = shared_dest
246        .file_stem()
247        .and_then(|s| s.to_str())
248        .unwrap_or("bundle");
249    let n = SUFFIX.fetch_add(1, Ordering::Relaxed);
250    cache_dir.join(format!("{stem}.{}.{n}.private.bundle", std::process::id()))
251}
252
253/// Remove `dest` and everything [`download_resumable`] keeps for it.
254///
255/// # Errors
256///
257/// A removal failure other than the file not existing.
258pub fn discard_download(dest: &Path) -> std::io::Result<()> {
259    let (part, state) = download_paths(dest);
260    for path in [dest, part.as_path(), state.as_path()] {
261        match std::fs::remove_file(path) {
262            Ok(()) => {}
263            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
264            Err(e) => return Err(e),
265        }
266    }
267    Ok(())
268}
269
270/// Download `url` to `dest`, continuing an earlier interrupted download.
271///
272/// Bytes arrive in `<dest>.part`, with what is known about them in
273/// `<dest>.json`; the file is renamed to `dest` once complete. When a
274/// previous run left a partial file for the same URL, only the rest is asked
275/// for (`Range: bytes=<n>-`, with `If-Range` so a file that changed on the
276/// server starts over). A connection that drops mid-download is retried a few
277/// times from where it stopped. When `dest` is already complete for `url`,
278/// nothing is downloaded.
279///
280/// # Parameters
281///
282/// - `client`: sends the requests ([`HttpClient::send`]).
283/// - `url`: what to download.
284/// - `dest`: where the finished file goes; its directory must exist. When
285///   several processes share a cache directory, callers must use
286///   [`claim_resumable_download`] first so only one process uses a given
287///   `dest` at a time.
288/// - `progress`: receives [`ResumableEvent`]s.
289/// - `cancel`: checked before each request and between body chunks.
290///
291/// # Errors
292///
293/// [`ResumableError::Status`] when the server refuses the download,
294/// [`ResumableError::Exhausted`] when the connection keeps dropping,
295/// [`ResumableError::Cancelled`] when `cancel` fires, and I/O errors from the
296/// local files. The partial file is kept so a later call can resume.
297pub fn download_resumable(
298    client: &dyn HttpClient,
299    url: &str,
300    dest: &Path,
301    progress: &mut dyn Progress,
302    cancel: &dyn Cancel,
303) -> Result<DownloadOutcome> {
304    let (part, state_path) = download_paths(dest);
305    let mut state = load_state(&state_path).unwrap_or_default();
306    if state.url != url {
307        discard_download(dest)?;
308        state = DownloadState {
309            url: url.to_owned(),
310            ..DownloadState::default()
311        };
312    }
313    if state.complete {
314        if let (Some(total), Ok(meta)) = (state.total, std::fs::metadata(dest)) {
315            if meta.len() == total {
316                return Ok(DownloadOutcome {
317                    bytes: total,
318                    resumed_from: total,
319                });
320            }
321        }
322        discard_download(dest)?;
323        state = DownloadState {
324            url: url.to_owned(),
325            ..DownloadState::default()
326        };
327    }
328
329    let resumed_from = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
330    let mut started = false;
331    let mut attempt = 0;
332    let mut last_failure = String::new();
333    loop {
334        if cancel.is_cancelled() {
335            return Err(ResumableError::Cancelled);
336        }
337        if attempt > 0 {
338            let done = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
339            if attempt >= MAX_ATTEMPTS {
340                return Err(ResumableError::Exhausted {
341                    url: url.to_owned(),
342                    attempts: attempt,
343                    last: last_failure,
344                });
345            }
346            progress.resumable_transfer(&ResumableEvent::Reconnecting { done, attempt });
347        }
348        attempt += 1;
349        match download_attempt(
350            client,
351            url,
352            &part,
353            &mut state,
354            &state_path,
355            progress,
356            cancel,
357            |p, s| {
358                if !started {
359                    started = true;
360                    p.start_with_unit(
361                        ProgressPhase::TransferringBundle,
362                        ProgressUnit::Bytes,
363                        s.total,
364                    );
365                    p.set(s.resumed_from);
366                    p.resumable_transfer(&ResumableEvent::Started {
367                        direction: TransferDirection::Download,
368                        url: url.to_owned(),
369                        total: s.total,
370                        resumed_from: s.resumed_from,
371                    });
372                }
373            },
374        ) {
375            Ok(AttemptOutcome::Complete(bytes)) => {
376                std::fs::rename(&part, dest)?;
377                state.complete = true;
378                state.total = Some(bytes);
379                save_state(&state_path, &state)?;
380                progress.set(bytes);
381                progress.finish();
382                progress.resumable_transfer(&ResumableEvent::Finished {
383                    done: bytes,
384                    resumed_from: resumed_from.min(bytes),
385                });
386                return Ok(DownloadOutcome {
387                    bytes,
388                    resumed_from: resumed_from.min(bytes),
389                });
390            }
391            Ok(AttemptOutcome::Dropped(why)) => last_failure = why,
392            Err(e) => return Err(e),
393        }
394    }
395}
396
397/// Where a download attempt starts, reported once when the first one answers.
398struct AttemptStart {
399    total: Option<u64>,
400    resumed_from: u64,
401}
402
403enum AttemptOutcome {
404    /// Every byte is in the partial file; the value is its size.
405    Complete(u64),
406    /// The connection dropped before the end.
407    Dropped(String),
408}
409
410/// One request of a download: ask for the bytes not yet on disk and append
411/// them to `part`.
412#[allow(clippy::too_many_arguments)]
413fn download_attempt(
414    client: &dyn HttpClient,
415    url: &str,
416    part: &Path,
417    state: &mut DownloadState,
418    state_path: &Path,
419    progress: &mut dyn Progress,
420    cancel: &dyn Cancel,
421    mut on_start: impl FnMut(&mut dyn Progress, &AttemptStart),
422) -> Result<AttemptOutcome> {
423    let have = std::fs::metadata(part).map(|m| m.len()).unwrap_or(0);
424    let mut request = HttpRequest::new(HttpMethod::Get, url).long_running();
425    if have > 0 {
426        request = request.header("Range", format!("bytes={have}-"));
427        if let Some(validator) = &state.validator {
428            request = request.header("If-Range", validator.clone());
429        }
430    }
431    let response = match client.send(&request) {
432        Ok(r) => r,
433        Err(e) if matches!(e, crate::error::Error::Auth { .. }) => {
434            return Err(ResumableError::Http(Box::new(e)));
435        }
436        Err(e) => return Ok(AttemptOutcome::Dropped(e.to_string())),
437    };
438    let (mut offset, total) =
439        match response.status {
440            206 if have > 0 => {
441                let (start, total) = parse_content_range(response.header("content-range"))
442                    .ok_or_else(|| ResumableError::Protocol {
443                        url: url.to_owned(),
444                        detail: "206 without a usable Content-Range".to_owned(),
445                    })?;
446                if start != have {
447                    return Err(ResumableError::Protocol {
448                        url: url.to_owned(),
449                        detail: format!("asked for bytes from {have}, got bytes from {start}"),
450                    });
451                }
452                (have, total)
453            }
454            200 | 206 => {
455                // A full answer: the server ignored the range or the file changed.
456                let total = response
457                    .header("content-length")
458                    .and_then(|v| v.trim().parse().ok());
459                (0, total)
460            }
461            416 if have > 0 && state.total == Some(have) => {
462                on_start(
463                    progress,
464                    &AttemptStart {
465                        total: state.total,
466                        resumed_from: have,
467                    },
468                );
469                return Ok(AttemptOutcome::Complete(have));
470            }
471            416 => {
472                // The partial file is longer than the server's: start over.
473                File::create(part)?;
474                return Ok(AttemptOutcome::Dropped(
475                    "the server's file is shorter than the partial download".to_owned(),
476                ));
477            }
478            status => {
479                return Err(ResumableError::Status {
480                    url: url.to_owned(),
481                    status,
482                })
483            }
484        };
485    state.total = total;
486    state.validator = validator(&response);
487    save_state(state_path, state)?;
488    on_start(
489        progress,
490        &AttemptStart {
491            total,
492            resumed_from: offset,
493        },
494    );
495
496    let mut file = if offset == 0 {
497        File::create(part)?
498    } else {
499        OpenOptions::new().append(true).open(part)?
500    };
501    let mut body = response.body;
502    let mut buf = vec![0u8; READ_CHUNK];
503    loop {
504        if cancel.is_cancelled() {
505            file.flush()?;
506            return Err(ResumableError::Cancelled);
507        }
508        let n = match body.read(&mut buf) {
509            Ok(0) => break,
510            Ok(n) => n,
511            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
512            Err(e) => {
513                file.flush()?;
514                return Ok(AttemptOutcome::Dropped(e.to_string()));
515            }
516        };
517        file.write_all(&buf[..n])?;
518        offset += n as u64;
519        progress.set(offset);
520        progress.resumable_transfer(&ResumableEvent::Advanced {
521            done: offset,
522            total,
523        });
524    }
525    file.flush()?;
526    file.sync_all()?;
527    match total {
528        Some(total) if offset < total => Ok(AttemptOutcome::Dropped(format!(
529            "the connection closed after {offset} of {total} bytes"
530        ))),
531        _ => Ok(AttemptOutcome::Complete(offset)),
532    }
533}
534
535/// The value to send back as `If-Range`: a strong `ETag`, else `Last-Modified`.
536fn validator(response: &HttpResponse) -> Option<String> {
537    response
538        .header("etag")
539        .filter(|v| !v.starts_with("W/"))
540        .or_else(|| response.header("last-modified"))
541        .map(str::to_owned)
542}
543
544/// Parse `bytes <start>-<end>/<total>` into the start and (known) total.
545fn parse_content_range(value: Option<&str>) -> Option<(u64, Option<u64>)> {
546    let rest = value?.trim().strip_prefix("bytes ")?;
547    let (range, total) = rest.split_once('/')?;
548    let (start, _end) = range.split_once('-')?;
549    let start = start.trim().parse().ok()?;
550    let total = match total.trim() {
551        "*" => None,
552        t => Some(t.parse().ok()?),
553    };
554    Some((start, total))
555}
556
557/// An upload to a TUS endpoint.
558///
559/// [`TusUpload::create`] asks the endpoint for a new upload of a known length
560/// and returns its URL; [`TusUpload::send_file`] sends a file to it, starting
561/// at whatever offset the server already has, so calling it again after an
562/// interruption (with the same upload URL) only sends the rest.
563#[derive(Debug, Clone, PartialEq, Eq)]
564pub struct TusUpload {
565    /// Absolute URL of the upload.
566    pub url: String,
567    /// Total bytes of the upload.
568    pub length: u64,
569}
570
571impl TusUpload {
572    /// Create an upload of `length` bytes at `endpoint` (TUS creation
573    /// extension).
574    ///
575    /// # Errors
576    ///
577    /// [`ResumableError::Status`] when the server refuses, and
578    /// [`ResumableError::Protocol`] when it answers without a `Location`.
579    pub fn create(client: &dyn HttpClient, endpoint: &str, length: u64) -> Result<Self> {
580        let request = HttpRequest::new(HttpMethod::Post, endpoint)
581            .header("Tus-Resumable", TUS_VERSION)
582            .header("Upload-Length", length.to_string());
583        let response = client.send(&request)?;
584        if response.status != 201 {
585            return Err(ResumableError::Status {
586                url: endpoint.to_owned(),
587                status: response.status,
588            });
589        }
590        let location = response
591            .header("location")
592            .ok_or_else(|| ResumableError::Protocol {
593                url: endpoint.to_owned(),
594                detail: "no Location for the new upload".to_owned(),
595            })?;
596        let url = resolve_url(endpoint, location).ok_or_else(|| ResumableError::Protocol {
597            url: endpoint.to_owned(),
598            detail: format!("bad Location {location:?}"),
599        })?;
600        Ok(Self { url, length })
601    }
602
603    /// How many bytes the server has, or `None` when it no longer knows the
604    /// upload (expired or already used).
605    ///
606    /// # Errors
607    ///
608    /// [`ResumableError::Status`] for other refusals, and
609    /// [`ResumableError::Protocol`] for an answer without `Upload-Offset`.
610    pub fn offset(&self, client: &dyn HttpClient) -> Result<Option<u64>> {
611        let request =
612            HttpRequest::new(HttpMethod::Head, &self.url).header("Tus-Resumable", TUS_VERSION);
613        let response = client.send(&request)?;
614        match response.status {
615            404 | 410 => Ok(None),
616            s if (200..300).contains(&s) => upload_offset(&response, &self.url).map(Some),
617            status => Err(ResumableError::Status {
618                url: self.url.clone(),
619                status,
620            }),
621        }
622    }
623
624    /// Send `file` (exactly [`Self::length`] bytes) to the upload, starting at
625    /// the server's offset and resynchronizing after each failed request.
626    ///
627    /// # Errors
628    ///
629    /// [`ResumableError::Status`] when the server no longer knows the upload
630    /// (`404`/`410`) or refuses it, [`ResumableError::Exhausted`] when requests
631    /// keep failing, and I/O errors reading `file`.
632    pub fn send_file(
633        &self,
634        client: &dyn HttpClient,
635        file: &Path,
636        progress: &mut dyn Progress,
637    ) -> Result<()> {
638        let gone = |status| ResumableError::Status {
639            url: self.url.clone(),
640            status,
641        };
642        let mut offset = self.offset(client)?.ok_or_else(|| gone(404))?;
643        let resumed_from = offset;
644        progress.start_with_unit(
645            ProgressPhase::TransferringBundle,
646            ProgressUnit::Bytes,
647            Some(self.length),
648        );
649        progress.set(offset);
650        progress.resumable_transfer(&ResumableEvent::Started {
651            direction: TransferDirection::Upload,
652            url: self.url.clone(),
653            total: Some(self.length),
654            resumed_from,
655        });
656        let mut input = File::open(file)?;
657        let mut buf = vec![0u8; PATCH_CHUNK];
658        let mut failures = 0;
659        while offset < self.length {
660            let n = usize::try_from(self.length - offset)
661                .map_or(PATCH_CHUNK, |left| left.min(PATCH_CHUNK));
662            input.seek(SeekFrom::Start(offset))?;
663            input.read_exact(&mut buf[..n])?;
664            let request = HttpRequest::new(HttpMethod::Patch, &self.url)
665                .header("Tus-Resumable", TUS_VERSION)
666                .header("Upload-Offset", offset.to_string())
667                .header("Content-Type", TUS_PATCH_CONTENT_TYPE)
668                .body(&buf[..n])
669                .long_running();
670            let failure = match client.send(&request) {
671                Ok(r) if r.status == 204 || r.status == 200 => {
672                    offset = upload_offset(&r, &self.url)?;
673                    failures = 0;
674                    progress.set(offset);
675                    progress.resumable_transfer(&ResumableEvent::Advanced {
676                        done: offset,
677                        total: Some(self.length),
678                    });
679                    continue;
680                }
681                Ok(r) if r.status == 404 || r.status == 410 => return Err(gone(r.status)),
682                Ok(r) if r.status == 409 => format!("offset {offset} was out of date"),
683                Ok(r) if (400..500).contains(&r.status) => return Err(gone(r.status)),
684                Ok(r) => format!("HTTP {}", r.status),
685                Err(e) if matches!(e, crate::error::Error::Auth { .. }) => {
686                    return Err(ResumableError::Http(Box::new(e)));
687                }
688                Err(e) => e.to_string(),
689            };
690            failures += 1;
691            if failures >= MAX_ATTEMPTS {
692                return Err(ResumableError::Exhausted {
693                    url: self.url.clone(),
694                    attempts: failures,
695                    last: failure,
696                });
697            }
698            progress.resumable_transfer(&ResumableEvent::Reconnecting {
699                done: offset,
700                attempt: failures,
701            });
702            // Ask the server where it got to; it may have kept part of the chunk.
703            if let Ok(Some(server)) = self.offset(client) {
704                offset = server;
705            }
706        }
707        progress.set(self.length);
708        progress.finish();
709        progress.resumable_transfer(&ResumableEvent::Finished {
710            done: self.length,
711            resumed_from,
712        });
713        Ok(())
714    }
715}
716
717fn upload_offset(response: &HttpResponse, url: &str) -> Result<u64> {
718    response
719        .header("upload-offset")
720        .and_then(|v| v.trim().parse().ok())
721        .ok_or_else(|| ResumableError::Protocol {
722            url: url.to_owned(),
723            detail: "no Upload-Offset".to_owned(),
724        })
725}
726
727/// Resolve `reference` (absolute, or relative to `base`) to an absolute URL.
728#[must_use]
729pub fn resolve_url(base: &str, reference: &str) -> Option<String> {
730    if let Ok(absolute) = url::Url::parse(reference) {
731        return Some(absolute.to_string());
732    }
733    url::Url::parse(base)
734        .ok()?
735        .join(reference)
736        .ok()
737        .map(|u| u.to_string())
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743    use crate::fetch::NoProgress;
744    use crate::progress::{FlagCancel, NeverCancel};
745    use std::collections::VecDeque;
746    use std::sync::Mutex;
747
748    /// Serves one file from memory, honoring `Range`, and can cut a body short.
749    struct FileServer {
750        data: Vec<u8>,
751        etag: String,
752        /// Bytes to send before dropping, one entry per GET (then full bodies).
753        cuts: Mutex<VecDeque<usize>>,
754        ranges: Mutex<Vec<Option<String>>>,
755    }
756
757    struct Cut {
758        data: std::io::Cursor<Vec<u8>>,
759        fail_after: bool,
760    }
761
762    impl Read for Cut {
763        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
764            let n = self.data.read(buf)?;
765            if n == 0 && self.fail_after {
766                return Err(std::io::Error::new(
767                    std::io::ErrorKind::ConnectionReset,
768                    "reset",
769                ));
770            }
771            Ok(n)
772        }
773    }
774
775    impl HttpClient for FileServer {
776        fn get(&self, _: &str, _: Option<&str>) -> crate::error::Result<Vec<u8>> {
777            unreachable!()
778        }
779        fn post(
780            &self,
781            _: &str,
782            _: &str,
783            _: &str,
784            _: &[u8],
785            _: Option<&str>,
786        ) -> crate::error::Result<Vec<u8>> {
787            unreachable!()
788        }
789        fn send(&self, request: &HttpRequest<'_>) -> crate::error::Result<HttpResponse> {
790            let range = request
791                .headers
792                .iter()
793                .find(|(n, _)| *n == "Range")
794                .map(|(_, v)| v.clone());
795            let if_range = request
796                .headers
797                .iter()
798                .find(|(n, _)| *n == "If-Range")
799                .map(|(_, v)| v.clone());
800            self.ranges
801                .lock()
802                .unwrap_or_else(|e| e.into_inner())
803                .push(range.clone());
804            let start = range
805                .as_deref()
806                .and_then(|r| r.strip_prefix("bytes="))
807                .and_then(|r| r.strip_suffix('-'))
808                .and_then(|r| r.parse::<usize>().ok())
809                .filter(|_| if_range.as_deref().is_none_or(|v| v == self.etag));
810            let total = self.data.len();
811            let (status, mut headers, body) = match start {
812                Some(s) if s >= total => (416, vec![], vec![]),
813                Some(s) => (
814                    206,
815                    vec![(
816                        "content-range".to_owned(),
817                        format!("bytes {s}-{}/{total}", total - 1),
818                    )],
819                    self.data[s..].to_vec(),
820                ),
821                None => (
822                    200,
823                    vec![("content-length".to_owned(), total.to_string())],
824                    self.data.clone(),
825                ),
826            };
827            headers.push(("etag".to_owned(), self.etag.clone()));
828            let cut = self
829                .cuts
830                .lock()
831                .unwrap_or_else(|e| e.into_inner())
832                .pop_front();
833            let (body, fail_after) = match cut {
834                Some(n) if n < body.len() => (body[..n].to_vec(), true),
835                _ => (body, false),
836            };
837            Ok(HttpResponse {
838                status,
839                headers,
840                body: Box::new(Cut {
841                    data: std::io::Cursor::new(body),
842                    fail_after,
843                }),
844            })
845        }
846    }
847
848    #[derive(Default)]
849    struct Events(Vec<ResumableEvent>);
850    impl crate::progress::ProgressSink for Events {}
851    impl Progress for Events {
852        fn resumable_transfer(&mut self, event: &ResumableEvent) {
853            if !matches!(event, ResumableEvent::Advanced { .. }) {
854                self.0.push(event.clone());
855            }
856        }
857    }
858
859    fn server(cuts: &[usize]) -> FileServer {
860        FileServer {
861            data: (0..200_000u32).map(|i| (i % 251) as u8).collect(),
862            etag: "\"v1\"".to_owned(),
863            cuts: Mutex::new(cuts.iter().copied().collect()),
864            ranges: Mutex::new(Vec::new()),
865        }
866    }
867
868    fn tempdir() -> tempfile::TempDir {
869        match tempfile::tempdir() {
870            Ok(d) => d,
871            Err(e) => panic!("tempdir: {e}"),
872        }
873    }
874
875    #[test]
876    fn dropped_downloads_resume_in_the_same_run() {
877        let dir = tempdir();
878        let dest = dir.path().join("clone.bundle");
879        let srv = server(&[70_000, 10_000]);
880        let mut events = Events::default();
881        let outcome = download_resumable(&srv, "http://h/b", &dest, &mut events, &NeverCancel);
882        assert_eq!(
883            outcome.ok(),
884            Some(DownloadOutcome {
885                bytes: 200_000,
886                resumed_from: 0
887            })
888        );
889        assert_eq!(std::fs::read(&dest).ok(), Some(srv.data.clone()));
890        let ranges = srv.ranges.lock().unwrap_or_else(|e| e.into_inner()).clone();
891        assert_eq!(
892            ranges,
893            [
894                None,
895                Some("bytes=70000-".to_owned()),
896                Some("bytes=80000-".to_owned())
897            ]
898        );
899        assert!(matches!(
900            events.0.as_slice(),
901            [
902                ResumableEvent::Started {
903                    resumed_from: 0,
904                    total: Some(200_000),
905                    ..
906                },
907                ResumableEvent::Reconnecting {
908                    done: 70_000,
909                    attempt: 1
910                },
911                ResumableEvent::Reconnecting {
912                    done: 80_000,
913                    attempt: 2
914                },
915                ResumableEvent::Finished {
916                    done: 200_000,
917                    resumed_from: 0
918                },
919            ]
920        ));
921    }
922
923    #[test]
924    fn a_later_run_resumes_a_partial_download() {
925        let dir = tempdir();
926        let dest = dir.path().join("clone.bundle");
927        // The first run's connection keeps dropping until it gives up.
928        let failing = server(&[50_000, 0, 0, 0, 0, 0]);
929        assert!(matches!(
930            download_resumable(&failing, "http://h/b", &dest, &mut NoProgress, &NeverCancel),
931            Err(ResumableError::Exhausted { .. })
932        ));
933        assert!(!dest.exists());
934
935        let srv = server(&[]);
936        let mut events = Events::default();
937        let outcome = download_resumable(&srv, "http://h/b", &dest, &mut events, &NeverCancel);
938        assert_eq!(outcome.ok().map(|o| o.resumed_from), Some(50_000));
939        assert_eq!(std::fs::read(&dest).ok(), Some(srv.data.clone()));
940        assert!(matches!(
941            events.0.first(),
942            Some(ResumableEvent::Started {
943                resumed_from: 50_000,
944                ..
945            })
946        ));
947
948        // Complete already: nothing is requested.
949        let again = server(&[]);
950        let outcome =
951            download_resumable(&again, "http://h/b", &dest, &mut NoProgress, &NeverCancel);
952        assert_eq!(outcome.ok().map(|o| o.resumed_from), Some(200_000));
953        assert!(again
954            .ranges
955            .lock()
956            .unwrap_or_else(|e| e.into_inner())
957            .is_empty());
958    }
959
960    #[test]
961    fn a_changed_file_or_url_starts_over() {
962        let dir = tempdir();
963        let dest = dir.path().join("clone.bundle");
964        let failing = server(&[50_000, 0, 0, 0, 0, 0]);
965        let _ = download_resumable(&failing, "http://h/b", &dest, &mut NoProgress, &NeverCancel);
966
967        let mut changed = server(&[]);
968        changed.etag = "\"v2\"".to_owned();
969        let outcome =
970            download_resumable(&changed, "http://h/b", &dest, &mut NoProgress, &NeverCancel);
971        assert_eq!(outcome.ok().map(|o| o.bytes), Some(200_000));
972        assert_eq!(std::fs::read(&dest).ok(), Some(changed.data.clone()));
973
974        let other = server(&[]);
975        let outcome = download_resumable(
976            &other,
977            "http://h/other",
978            &dest,
979            &mut NoProgress,
980            &NeverCancel,
981        );
982        assert_eq!(outcome.ok().map(|o| o.resumed_from), Some(0));
983        assert_eq!(
984            other
985                .ranges
986                .lock()
987                .unwrap_or_else(|e| e.into_inner())
988                .as_slice(),
989            [None]
990        );
991    }
992
993    /// Cancels once `after` bytes of the download are on disk.
994    struct CancelAfter {
995        flag: FlagCancel,
996        after: u64,
997    }
998    impl crate::progress::ProgressSink for CancelAfter {}
999    impl Progress for CancelAfter {
1000        fn resumable_transfer(&mut self, event: &ResumableEvent) {
1001            if let ResumableEvent::Advanced { done, .. } = event {
1002                if *done >= self.after {
1003                    self.flag.cancel();
1004                }
1005            }
1006        }
1007    }
1008
1009    #[test]
1010    fn a_cancelled_download_stops_mid_body_and_resumes_next_time() {
1011        let dir = tempdir();
1012        let dest = dir.path().join("clone.bundle");
1013        let srv = server(&[]);
1014        let flag = FlagCancel::new();
1015        let mut progress = CancelAfter {
1016            flag: flag.clone(),
1017            after: 1,
1018        };
1019        assert!(matches!(
1020            download_resumable(&srv, "http://h/b", &dest, &mut progress, &flag),
1021            Err(ResumableError::Cancelled)
1022        ));
1023        assert!(!dest.exists());
1024        let (part, _) = download_paths(&dest);
1025        let kept = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
1026        assert!(
1027            kept > 0 && kept < 200_000,
1028            "one chunk is kept, got {kept} bytes"
1029        );
1030
1031        let outcome = download_resumable(&srv, "http://h/b", &dest, &mut NoProgress, &NeverCancel);
1032        assert_eq!(outcome.ok().map(|o| o.resumed_from), Some(kept));
1033        assert_eq!(std::fs::read(&dest).ok(), Some(srv.data.clone()));
1034    }
1035
1036    #[test]
1037    fn claim_resumable_download_serializes_the_shared_slot() {
1038        let dir = tempdir();
1039        let shared = dir.path().join("abc.bundle");
1040        let first = claim_resumable_download(dir.path(), &shared).expect("first claim");
1041        let (first_dest, first_lock) = match first {
1042            ResumableDownloadDest::Shared { dest, lock } => (dest, lock),
1043            ResumableDownloadDest::Private(_) => panic!("first claim should be shared"),
1044        };
1045        assert_eq!(first_dest, shared);
1046        let second = claim_resumable_download(dir.path(), &shared).expect("second claim");
1047        assert!(
1048            matches!(second, ResumableDownloadDest::Private(_)),
1049            "second claim must not touch the shared partial files"
1050        );
1051        let other = std::fs::File::open(shared_download_lock_path(&shared)).expect("lock file");
1052        assert!(
1053            matches!(other.try_lock(), Err(std::fs::TryLockError::WouldBlock)),
1054            "lock file stays held until the first guard drops"
1055        );
1056        drop(first_lock);
1057    }
1058
1059    #[test]
1060    fn auth_failure_does_not_retry_the_download() {
1061        use crate::error::{AuthFailure, Error};
1062
1063        struct AuthClient;
1064        impl HttpClient for AuthClient {
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, _: &HttpRequest<'_>) -> crate::error::Result<HttpResponse> {
1079                Err(Error::Auth {
1080                    url: "http://h/b".to_owned(),
1081                    reason: AuthFailure::Rejected,
1082                })
1083            }
1084        }
1085
1086        let dir = tempdir();
1087        let dest = dir.path().join("clone.bundle");
1088        let outcome = download_resumable(
1089            &AuthClient,
1090            "http://h/b",
1091            &dest,
1092            &mut NoProgress,
1093            &NeverCancel,
1094        );
1095        assert!(
1096            matches!(outcome, Err(ResumableError::Http(_))),
1097            "auth errors must fail immediately: {outcome:?}"
1098        );
1099        assert!(
1100            !matches!(outcome, Err(ResumableError::Exhausted { .. })),
1101            "auth must not be retried like a dropped connection"
1102        );
1103    }
1104
1105    #[test]
1106    fn an_already_cancelled_download_sends_no_request() {
1107        let dir = tempdir();
1108        let dest = dir.path().join("clone.bundle");
1109        let srv = server(&[]);
1110        let flag = FlagCancel::new();
1111        flag.cancel();
1112        assert!(matches!(
1113            download_resumable(&srv, "http://h/b", &dest, &mut NoProgress, &flag),
1114            Err(ResumableError::Cancelled)
1115        ));
1116        assert!(srv
1117            .ranges
1118            .lock()
1119            .unwrap_or_else(|e| e.into_inner())
1120            .is_empty());
1121    }
1122
1123    #[test]
1124    fn content_ranges_parse() {
1125        assert_eq!(
1126            parse_content_range(Some("bytes 10-99/100")),
1127            Some((10, Some(100)))
1128        );
1129        assert_eq!(parse_content_range(Some("bytes 10-99/*")), Some((10, None)));
1130        assert_eq!(parse_content_range(Some("items 1-2/3")), None);
1131        assert_eq!(parse_content_range(None), None);
1132    }
1133
1134    #[test]
1135    fn urls_resolve_against_the_endpoint() {
1136        assert_eq!(
1137            resolve_url("http://h:1/repo/push", "/repo/push/abc").as_deref(),
1138            Some("http://h:1/repo/push/abc")
1139        );
1140        assert_eq!(
1141            resolve_url("http://h/repo/push", "https://other/x").as_deref(),
1142            Some("https://other/x")
1143        );
1144    }
1145
1146    /// A TUS server in memory that drops some PATCHes after keeping part of them.
1147    struct TusServer {
1148        stored: Mutex<Vec<u8>>,
1149        /// Per PATCH: bytes to keep before failing (`None` = accept all).
1150        patch_cuts: Mutex<VecDeque<Option<usize>>>,
1151        length: Mutex<u64>,
1152    }
1153
1154    impl HttpClient for TusServer {
1155        fn get(&self, _: &str, _: Option<&str>) -> crate::error::Result<Vec<u8>> {
1156            unreachable!()
1157        }
1158        fn post(
1159            &self,
1160            _: &str,
1161            _: &str,
1162            _: &str,
1163            _: &[u8],
1164            _: Option<&str>,
1165        ) -> crate::error::Result<Vec<u8>> {
1166            unreachable!()
1167        }
1168        fn send(&self, request: &HttpRequest<'_>) -> crate::error::Result<HttpResponse> {
1169            let header = |name: &str| {
1170                request
1171                    .headers
1172                    .iter()
1173                    .find(|(n, _)| n.eq_ignore_ascii_case(name))
1174                    .map(|(_, v)| v.clone())
1175            };
1176            let mut stored = self.stored.lock().unwrap_or_else(|e| e.into_inner());
1177            let reply = |status: u16, headers: Vec<(String, String)>| HttpResponse {
1178                status,
1179                headers,
1180                body: Box::new(std::io::empty()),
1181            };
1182            let offset = |n: usize| vec![("upload-offset".to_owned(), n.to_string())];
1183            Ok(match request.method {
1184                HttpMethod::Post => {
1185                    *self.length.lock().unwrap_or_else(|e| e.into_inner()) =
1186                        header("Upload-Length")
1187                            .and_then(|v| v.parse().ok())
1188                            .unwrap_or(0);
1189                    reply(201, vec![("location".to_owned(), "/push/u1".to_owned())])
1190                }
1191                HttpMethod::Head => reply(200, offset(stored.len())),
1192                HttpMethod::Patch => {
1193                    let at: usize = header("Upload-Offset")
1194                        .and_then(|v| v.parse().ok())
1195                        .unwrap_or(usize::MAX);
1196                    if at != stored.len() {
1197                        return Ok(reply(409, vec![]));
1198                    }
1199                    let cut = self
1200                        .patch_cuts
1201                        .lock()
1202                        .unwrap_or_else(|e| e.into_inner())
1203                        .pop_front()
1204                        .flatten();
1205                    match cut {
1206                        Some(keep) => {
1207                            stored.extend_from_slice(&request.body[..keep]);
1208                            return Err(crate::error::Error::Message("reset".to_owned()));
1209                        }
1210                        None => {
1211                            stored.extend_from_slice(request.body);
1212                            reply(204, offset(stored.len()))
1213                        }
1214                    }
1215                }
1216                HttpMethod::Get | HttpMethod::Put | HttpMethod::Delete => reply(405, vec![]),
1217            })
1218        }
1219    }
1220
1221    #[test]
1222    fn tus_uploads_resume_from_the_server_offset() {
1223        let dir = tempdir();
1224        let file = dir.path().join("push.bundle");
1225        let data: Vec<u8> = (0..(PATCH_CHUNK * 2 + 1000))
1226            .map(|i| (i % 253) as u8)
1227            .collect();
1228        assert!(std::fs::write(&file, &data).is_ok());
1229        let srv = TusServer {
1230            stored: Mutex::new(Vec::new()),
1231            patch_cuts: Mutex::new([None, Some(1000)].into_iter().collect()),
1232            length: Mutex::new(0),
1233        };
1234        let upload = match TusUpload::create(&srv, "http://h/push", data.len() as u64) {
1235            Ok(u) => u,
1236            Err(e) => panic!("create: {e}"),
1237        };
1238        assert_eq!(upload.url, "http://h/push/u1");
1239        let mut events = Events::default();
1240        assert!(upload.send_file(&srv, &file, &mut events).is_ok());
1241        assert_eq!(*srv.stored.lock().unwrap_or_else(|e| e.into_inner()), data);
1242        assert!(matches!(
1243            events.0.as_slice(),
1244            [
1245                ResumableEvent::Started {
1246                    resumed_from: 0,
1247                    ..
1248                },
1249                ResumableEvent::Reconnecting { attempt: 1, .. },
1250                ResumableEvent::Finished { .. },
1251            ]
1252        ));
1253        assert_eq!(upload.offset(&srv).ok().flatten(), Some(data.len() as u64));
1254    }
1255}