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