1use 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
29pub const TUS_VERSION: &str = "1.0.0";
31
32pub const TUS_PATCH_CONTENT_TYPE: &str = "application/offset+octet-stream";
34
35const PATCH_CHUNK: usize = 4 * 1024 * 1024;
37
38const READ_CHUNK: usize = 64 * 1024;
40
41const MAX_ATTEMPTS: u32 = 5;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum TransferDirection {
47 Download,
49 Upload,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum ResumableEvent {
57 Started {
60 direction: TransferDirection,
62 url: String,
64 total: Option<u64>,
66 resumed_from: u64,
68 },
69 Advanced {
71 done: u64,
73 total: Option<u64>,
75 },
76 Reconnecting {
78 done: u64,
80 attempt: u32,
82 },
83 Finished {
85 done: u64,
87 resumed_from: u64,
89 },
90}
91
92#[derive(Debug, thiserror::Error)]
94pub enum ResumableError {
95 #[error("HTTP {status} from {url}")]
97 Status {
98 url: String,
100 status: u16,
102 },
103 #[error("unexpected response from {url}: {detail}")]
105 Protocol {
106 url: String,
108 detail: String,
110 },
111 #[error("the transfer of {url} kept failing after {attempts} attempts: {last}")]
113 Exhausted {
114 url: String,
116 attempts: u32,
118 last: String,
120 },
121 #[error(transparent)]
123 Http(Box<crate::error::Error>),
124 #[error(transparent)]
126 Io(#[from] std::io::Error),
127 #[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
139pub type Result<T> = std::result::Result<T, ResumableError>;
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub struct DownloadOutcome {
145 pub bytes: u64,
147 pub resumed_from: u64,
149}
150
151#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
153struct DownloadState {
154 url: String,
156 validator: Option<String>,
159 total: Option<u64>,
161 complete: bool,
163}
164
165fn 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
189pub fn discard_download(dest: &Path) -> std::io::Result<()> {
195 let (part, state) = download_paths(dest);
196 for path in [dest, part.as_path(), state.as_path()] {
197 match std::fs::remove_file(path) {
198 Ok(()) => {}
199 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
200 Err(e) => return Err(e),
201 }
202 }
203 Ok(())
204}
205
206pub fn download_resumable(
231 client: &dyn HttpClient,
232 url: &str,
233 dest: &Path,
234 progress: &mut dyn Progress,
235 cancel: &dyn Cancel,
236) -> Result<DownloadOutcome> {
237 let (part, state_path) = download_paths(dest);
238 let mut state = load_state(&state_path).unwrap_or_default();
239 if state.url != url {
240 discard_download(dest)?;
241 state = DownloadState {
242 url: url.to_owned(),
243 ..DownloadState::default()
244 };
245 }
246 if state.complete {
247 if let (Some(total), Ok(meta)) = (state.total, std::fs::metadata(dest)) {
248 if meta.len() == total {
249 return Ok(DownloadOutcome {
250 bytes: total,
251 resumed_from: total,
252 });
253 }
254 }
255 discard_download(dest)?;
256 state = DownloadState {
257 url: url.to_owned(),
258 ..DownloadState::default()
259 };
260 }
261
262 let resumed_from = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
263 let mut started = false;
264 let mut attempt = 0;
265 let mut last_failure = String::new();
266 loop {
267 if cancel.is_cancelled() {
268 return Err(ResumableError::Cancelled);
269 }
270 if attempt > 0 {
271 let done = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
272 if attempt >= MAX_ATTEMPTS {
273 return Err(ResumableError::Exhausted {
274 url: url.to_owned(),
275 attempts: attempt,
276 last: last_failure,
277 });
278 }
279 progress.resumable_transfer(&ResumableEvent::Reconnecting { done, attempt });
280 }
281 attempt += 1;
282 match download_attempt(
283 client,
284 url,
285 &part,
286 &mut state,
287 &state_path,
288 progress,
289 cancel,
290 |p, s| {
291 if !started {
292 started = true;
293 p.start_with_unit(
294 ProgressPhase::TransferringBundle,
295 ProgressUnit::Bytes,
296 s.total,
297 );
298 p.set(s.resumed_from);
299 p.resumable_transfer(&ResumableEvent::Started {
300 direction: TransferDirection::Download,
301 url: url.to_owned(),
302 total: s.total,
303 resumed_from: s.resumed_from,
304 });
305 }
306 },
307 ) {
308 Ok(AttemptOutcome::Complete(bytes)) => {
309 std::fs::rename(&part, dest)?;
310 state.complete = true;
311 state.total = Some(bytes);
312 save_state(&state_path, &state)?;
313 progress.set(bytes);
314 progress.finish();
315 progress.resumable_transfer(&ResumableEvent::Finished {
316 done: bytes,
317 resumed_from: resumed_from.min(bytes),
318 });
319 return Ok(DownloadOutcome {
320 bytes,
321 resumed_from: resumed_from.min(bytes),
322 });
323 }
324 Ok(AttemptOutcome::Dropped(why)) => last_failure = why,
325 Err(e) => return Err(e),
326 }
327 }
328}
329
330struct AttemptStart {
332 total: Option<u64>,
333 resumed_from: u64,
334}
335
336enum AttemptOutcome {
337 Complete(u64),
339 Dropped(String),
341}
342
343#[allow(clippy::too_many_arguments)]
346fn download_attempt(
347 client: &dyn HttpClient,
348 url: &str,
349 part: &Path,
350 state: &mut DownloadState,
351 state_path: &Path,
352 progress: &mut dyn Progress,
353 cancel: &dyn Cancel,
354 mut on_start: impl FnMut(&mut dyn Progress, &AttemptStart),
355) -> Result<AttemptOutcome> {
356 let have = std::fs::metadata(part).map(|m| m.len()).unwrap_or(0);
357 let mut request = HttpRequest::new(HttpMethod::Get, url).long_running();
358 if have > 0 {
359 request = request.header("Range", format!("bytes={have}-"));
360 if let Some(validator) = &state.validator {
361 request = request.header("If-Range", validator.clone());
362 }
363 }
364 let response = match client.send(&request) {
365 Ok(r) => r,
366 Err(e) if matches!(e, crate::error::Error::Auth { .. }) => {
367 return Err(ResumableError::Http(Box::new(e)));
368 }
369 Err(e) => return Ok(AttemptOutcome::Dropped(e.to_string())),
370 };
371 let (mut offset, total) =
372 match response.status {
373 206 if have > 0 => {
374 let (start, total) = parse_content_range(response.header("content-range"))
375 .ok_or_else(|| ResumableError::Protocol {
376 url: url.to_owned(),
377 detail: "206 without a usable Content-Range".to_owned(),
378 })?;
379 if start != have {
380 return Err(ResumableError::Protocol {
381 url: url.to_owned(),
382 detail: format!("asked for bytes from {have}, got bytes from {start}"),
383 });
384 }
385 (have, total)
386 }
387 200 | 206 => {
388 let total = response
390 .header("content-length")
391 .and_then(|v| v.trim().parse().ok());
392 (0, total)
393 }
394 416 if have > 0 && state.total == Some(have) => {
395 on_start(
396 progress,
397 &AttemptStart {
398 total: state.total,
399 resumed_from: have,
400 },
401 );
402 return Ok(AttemptOutcome::Complete(have));
403 }
404 416 => {
405 File::create(part)?;
407 return Ok(AttemptOutcome::Dropped(
408 "the server's file is shorter than the partial download".to_owned(),
409 ));
410 }
411 status => {
412 return Err(ResumableError::Status {
413 url: url.to_owned(),
414 status,
415 })
416 }
417 };
418 state.total = total;
419 state.validator = validator(&response);
420 save_state(state_path, state)?;
421 on_start(
422 progress,
423 &AttemptStart {
424 total,
425 resumed_from: offset,
426 },
427 );
428
429 let mut file = if offset == 0 {
430 File::create(part)?
431 } else {
432 OpenOptions::new().append(true).open(part)?
433 };
434 let mut body = response.body;
435 let mut buf = vec![0u8; READ_CHUNK];
436 loop {
437 if cancel.is_cancelled() {
438 file.flush()?;
439 return Err(ResumableError::Cancelled);
440 }
441 let n = match body.read(&mut buf) {
442 Ok(0) => break,
443 Ok(n) => n,
444 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
445 Err(e) => {
446 file.flush()?;
447 return Ok(AttemptOutcome::Dropped(e.to_string()));
448 }
449 };
450 file.write_all(&buf[..n])?;
451 offset += n as u64;
452 progress.set(offset);
453 progress.resumable_transfer(&ResumableEvent::Advanced {
454 done: offset,
455 total,
456 });
457 }
458 file.flush()?;
459 file.sync_all()?;
460 match total {
461 Some(total) if offset < total => Ok(AttemptOutcome::Dropped(format!(
462 "the connection closed after {offset} of {total} bytes"
463 ))),
464 _ => Ok(AttemptOutcome::Complete(offset)),
465 }
466}
467
468fn validator(response: &HttpResponse) -> Option<String> {
470 response
471 .header("etag")
472 .filter(|v| !v.starts_with("W/"))
473 .or_else(|| response.header("last-modified"))
474 .map(str::to_owned)
475}
476
477fn parse_content_range(value: Option<&str>) -> Option<(u64, Option<u64>)> {
479 let rest = value?.trim().strip_prefix("bytes ")?;
480 let (range, total) = rest.split_once('/')?;
481 let (start, _end) = range.split_once('-')?;
482 let start = start.trim().parse().ok()?;
483 let total = match total.trim() {
484 "*" => None,
485 t => Some(t.parse().ok()?),
486 };
487 Some((start, total))
488}
489
490#[derive(Debug, Clone, PartialEq, Eq)]
497pub struct TusUpload {
498 pub url: String,
500 pub length: u64,
502}
503
504impl TusUpload {
505 pub fn create(client: &dyn HttpClient, endpoint: &str, length: u64) -> Result<Self> {
513 let request = HttpRequest::new(HttpMethod::Post, endpoint)
514 .header("Tus-Resumable", TUS_VERSION)
515 .header("Upload-Length", length.to_string());
516 let response = client.send(&request)?;
517 if response.status != 201 {
518 return Err(ResumableError::Status {
519 url: endpoint.to_owned(),
520 status: response.status,
521 });
522 }
523 let location = response
524 .header("location")
525 .ok_or_else(|| ResumableError::Protocol {
526 url: endpoint.to_owned(),
527 detail: "no Location for the new upload".to_owned(),
528 })?;
529 let url = resolve_url(endpoint, location).ok_or_else(|| ResumableError::Protocol {
530 url: endpoint.to_owned(),
531 detail: format!("bad Location {location:?}"),
532 })?;
533 Ok(Self { url, length })
534 }
535
536 pub fn offset(&self, client: &dyn HttpClient) -> Result<Option<u64>> {
544 let request =
545 HttpRequest::new(HttpMethod::Head, &self.url).header("Tus-Resumable", TUS_VERSION);
546 let response = client.send(&request)?;
547 match response.status {
548 404 | 410 => Ok(None),
549 s if (200..300).contains(&s) => upload_offset(&response, &self.url).map(Some),
550 status => Err(ResumableError::Status {
551 url: self.url.clone(),
552 status,
553 }),
554 }
555 }
556
557 pub fn send_file(
566 &self,
567 client: &dyn HttpClient,
568 file: &Path,
569 progress: &mut dyn Progress,
570 ) -> Result<()> {
571 let gone = |status| ResumableError::Status {
572 url: self.url.clone(),
573 status,
574 };
575 let mut offset = self.offset(client)?.ok_or_else(|| gone(404))?;
576 let resumed_from = offset;
577 progress.start_with_unit(
578 ProgressPhase::TransferringBundle,
579 ProgressUnit::Bytes,
580 Some(self.length),
581 );
582 progress.set(offset);
583 progress.resumable_transfer(&ResumableEvent::Started {
584 direction: TransferDirection::Upload,
585 url: self.url.clone(),
586 total: Some(self.length),
587 resumed_from,
588 });
589 let mut input = File::open(file)?;
590 let mut buf = vec![0u8; PATCH_CHUNK];
591 let mut failures = 0;
592 while offset < self.length {
593 let n = usize::try_from(self.length - offset)
594 .map_or(PATCH_CHUNK, |left| left.min(PATCH_CHUNK));
595 input.seek(SeekFrom::Start(offset))?;
596 input.read_exact(&mut buf[..n])?;
597 let request = HttpRequest::new(HttpMethod::Patch, &self.url)
598 .header("Tus-Resumable", TUS_VERSION)
599 .header("Upload-Offset", offset.to_string())
600 .header("Content-Type", TUS_PATCH_CONTENT_TYPE)
601 .body(&buf[..n])
602 .long_running();
603 let failure = match client.send(&request) {
604 Ok(r) if r.status == 204 || r.status == 200 => {
605 offset = upload_offset(&r, &self.url)?;
606 failures = 0;
607 progress.set(offset);
608 progress.resumable_transfer(&ResumableEvent::Advanced {
609 done: offset,
610 total: Some(self.length),
611 });
612 continue;
613 }
614 Ok(r) if r.status == 404 || r.status == 410 => return Err(gone(r.status)),
615 Ok(r) if r.status == 409 => format!("offset {offset} was out of date"),
616 Ok(r) if (400..500).contains(&r.status) => return Err(gone(r.status)),
617 Ok(r) => format!("HTTP {}", r.status),
618 Err(e) if matches!(e, crate::error::Error::Auth { .. }) => {
619 return Err(ResumableError::Http(Box::new(e)));
620 }
621 Err(e) => e.to_string(),
622 };
623 failures += 1;
624 if failures >= MAX_ATTEMPTS {
625 return Err(ResumableError::Exhausted {
626 url: self.url.clone(),
627 attempts: failures,
628 last: failure,
629 });
630 }
631 progress.resumable_transfer(&ResumableEvent::Reconnecting {
632 done: offset,
633 attempt: failures,
634 });
635 if let Ok(Some(server)) = self.offset(client) {
637 offset = server;
638 }
639 }
640 progress.set(self.length);
641 progress.finish();
642 progress.resumable_transfer(&ResumableEvent::Finished {
643 done: self.length,
644 resumed_from,
645 });
646 Ok(())
647 }
648}
649
650fn upload_offset(response: &HttpResponse, url: &str) -> Result<u64> {
651 response
652 .header("upload-offset")
653 .and_then(|v| v.trim().parse().ok())
654 .ok_or_else(|| ResumableError::Protocol {
655 url: url.to_owned(),
656 detail: "no Upload-Offset".to_owned(),
657 })
658}
659
660#[must_use]
662pub fn resolve_url(base: &str, reference: &str) -> Option<String> {
663 if let Ok(absolute) = url::Url::parse(reference) {
664 return Some(absolute.to_string());
665 }
666 url::Url::parse(base)
667 .ok()?
668 .join(reference)
669 .ok()
670 .map(|u| u.to_string())
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676 use crate::fetch::NoProgress;
677 use crate::progress::{FlagCancel, NeverCancel};
678 use std::collections::VecDeque;
679 use std::sync::Mutex;
680
681 struct FileServer {
683 data: Vec<u8>,
684 etag: String,
685 cuts: Mutex<VecDeque<usize>>,
687 ranges: Mutex<Vec<Option<String>>>,
688 }
689
690 struct Cut {
691 data: std::io::Cursor<Vec<u8>>,
692 fail_after: bool,
693 }
694
695 impl Read for Cut {
696 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
697 let n = self.data.read(buf)?;
698 if n == 0 && self.fail_after {
699 return Err(std::io::Error::new(
700 std::io::ErrorKind::ConnectionReset,
701 "reset",
702 ));
703 }
704 Ok(n)
705 }
706 }
707
708 impl HttpClient for FileServer {
709 fn get(&self, _: &str, _: Option<&str>) -> crate::error::Result<Vec<u8>> {
710 unreachable!()
711 }
712 fn post(
713 &self,
714 _: &str,
715 _: &str,
716 _: &str,
717 _: &[u8],
718 _: Option<&str>,
719 ) -> crate::error::Result<Vec<u8>> {
720 unreachable!()
721 }
722 fn send(&self, request: &HttpRequest<'_>) -> crate::error::Result<HttpResponse> {
723 let range = request
724 .headers
725 .iter()
726 .find(|(n, _)| *n == "Range")
727 .map(|(_, v)| v.clone());
728 let if_range = request
729 .headers
730 .iter()
731 .find(|(n, _)| *n == "If-Range")
732 .map(|(_, v)| v.clone());
733 self.ranges
734 .lock()
735 .unwrap_or_else(|e| e.into_inner())
736 .push(range.clone());
737 let start = range
738 .as_deref()
739 .and_then(|r| r.strip_prefix("bytes="))
740 .and_then(|r| r.strip_suffix('-'))
741 .and_then(|r| r.parse::<usize>().ok())
742 .filter(|_| if_range.as_deref().is_none_or(|v| v == self.etag));
743 let total = self.data.len();
744 let (status, mut headers, body) = match start {
745 Some(s) if s >= total => (416, vec![], vec![]),
746 Some(s) => (
747 206,
748 vec![(
749 "content-range".to_owned(),
750 format!("bytes {s}-{}/{total}", total - 1),
751 )],
752 self.data[s..].to_vec(),
753 ),
754 None => (
755 200,
756 vec![("content-length".to_owned(), total.to_string())],
757 self.data.clone(),
758 ),
759 };
760 headers.push(("etag".to_owned(), self.etag.clone()));
761 let cut = self
762 .cuts
763 .lock()
764 .unwrap_or_else(|e| e.into_inner())
765 .pop_front();
766 let (body, fail_after) = match cut {
767 Some(n) if n < body.len() => (body[..n].to_vec(), true),
768 _ => (body, false),
769 };
770 Ok(HttpResponse {
771 status,
772 headers,
773 body: Box::new(Cut {
774 data: std::io::Cursor::new(body),
775 fail_after,
776 }),
777 })
778 }
779 }
780
781 #[derive(Default)]
782 struct Events(Vec<ResumableEvent>);
783 impl crate::progress::ProgressSink for Events {}
784 impl Progress for Events {
785 fn resumable_transfer(&mut self, event: &ResumableEvent) {
786 if !matches!(event, ResumableEvent::Advanced { .. }) {
787 self.0.push(event.clone());
788 }
789 }
790 }
791
792 fn server(cuts: &[usize]) -> FileServer {
793 FileServer {
794 data: (0..200_000u32).map(|i| (i % 251) as u8).collect(),
795 etag: "\"v1\"".to_owned(),
796 cuts: Mutex::new(cuts.iter().copied().collect()),
797 ranges: Mutex::new(Vec::new()),
798 }
799 }
800
801 fn tempdir() -> tempfile::TempDir {
802 match tempfile::tempdir() {
803 Ok(d) => d,
804 Err(e) => panic!("tempdir: {e}"),
805 }
806 }
807
808 #[test]
809 fn dropped_downloads_resume_in_the_same_run() {
810 let dir = tempdir();
811 let dest = dir.path().join("clone.bundle");
812 let srv = server(&[70_000, 10_000]);
813 let mut events = Events::default();
814 let outcome = download_resumable(&srv, "http://h/b", &dest, &mut events, &NeverCancel);
815 assert_eq!(
816 outcome.ok(),
817 Some(DownloadOutcome {
818 bytes: 200_000,
819 resumed_from: 0
820 })
821 );
822 assert_eq!(std::fs::read(&dest).ok(), Some(srv.data.clone()));
823 let ranges = srv.ranges.lock().unwrap_or_else(|e| e.into_inner()).clone();
824 assert_eq!(
825 ranges,
826 [
827 None,
828 Some("bytes=70000-".to_owned()),
829 Some("bytes=80000-".to_owned())
830 ]
831 );
832 assert!(matches!(
833 events.0.as_slice(),
834 [
835 ResumableEvent::Started {
836 resumed_from: 0,
837 total: Some(200_000),
838 ..
839 },
840 ResumableEvent::Reconnecting {
841 done: 70_000,
842 attempt: 1
843 },
844 ResumableEvent::Reconnecting {
845 done: 80_000,
846 attempt: 2
847 },
848 ResumableEvent::Finished {
849 done: 200_000,
850 resumed_from: 0
851 },
852 ]
853 ));
854 }
855
856 #[test]
857 fn a_later_run_resumes_a_partial_download() {
858 let dir = tempdir();
859 let dest = dir.path().join("clone.bundle");
860 let failing = server(&[50_000, 0, 0, 0, 0, 0]);
862 assert!(matches!(
863 download_resumable(&failing, "http://h/b", &dest, &mut NoProgress, &NeverCancel),
864 Err(ResumableError::Exhausted { .. })
865 ));
866 assert!(!dest.exists());
867
868 let srv = server(&[]);
869 let mut events = Events::default();
870 let outcome = download_resumable(&srv, "http://h/b", &dest, &mut events, &NeverCancel);
871 assert_eq!(outcome.ok().map(|o| o.resumed_from), Some(50_000));
872 assert_eq!(std::fs::read(&dest).ok(), Some(srv.data.clone()));
873 assert!(matches!(
874 events.0.first(),
875 Some(ResumableEvent::Started {
876 resumed_from: 50_000,
877 ..
878 })
879 ));
880
881 let again = server(&[]);
883 let outcome =
884 download_resumable(&again, "http://h/b", &dest, &mut NoProgress, &NeverCancel);
885 assert_eq!(outcome.ok().map(|o| o.resumed_from), Some(200_000));
886 assert!(again
887 .ranges
888 .lock()
889 .unwrap_or_else(|e| e.into_inner())
890 .is_empty());
891 }
892
893 #[test]
894 fn a_changed_file_or_url_starts_over() {
895 let dir = tempdir();
896 let dest = dir.path().join("clone.bundle");
897 let failing = server(&[50_000, 0, 0, 0, 0, 0]);
898 let _ = download_resumable(&failing, "http://h/b", &dest, &mut NoProgress, &NeverCancel);
899
900 let mut changed = server(&[]);
901 changed.etag = "\"v2\"".to_owned();
902 let outcome =
903 download_resumable(&changed, "http://h/b", &dest, &mut NoProgress, &NeverCancel);
904 assert_eq!(outcome.ok().map(|o| o.bytes), Some(200_000));
905 assert_eq!(std::fs::read(&dest).ok(), Some(changed.data.clone()));
906
907 let other = server(&[]);
908 let outcome = download_resumable(
909 &other,
910 "http://h/other",
911 &dest,
912 &mut NoProgress,
913 &NeverCancel,
914 );
915 assert_eq!(outcome.ok().map(|o| o.resumed_from), Some(0));
916 assert_eq!(
917 other
918 .ranges
919 .lock()
920 .unwrap_or_else(|e| e.into_inner())
921 .as_slice(),
922 [None]
923 );
924 }
925
926 struct CancelAfter {
928 flag: FlagCancel,
929 after: u64,
930 }
931 impl crate::progress::ProgressSink for CancelAfter {}
932 impl Progress for CancelAfter {
933 fn resumable_transfer(&mut self, event: &ResumableEvent) {
934 if let ResumableEvent::Advanced { done, .. } = event {
935 if *done >= self.after {
936 self.flag.cancel();
937 }
938 }
939 }
940 }
941
942 #[test]
943 fn a_cancelled_download_stops_mid_body_and_resumes_next_time() {
944 let dir = tempdir();
945 let dest = dir.path().join("clone.bundle");
946 let srv = server(&[]);
947 let flag = FlagCancel::new();
948 let mut progress = CancelAfter {
949 flag: flag.clone(),
950 after: 1,
951 };
952 assert!(matches!(
953 download_resumable(&srv, "http://h/b", &dest, &mut progress, &flag),
954 Err(ResumableError::Cancelled)
955 ));
956 assert!(!dest.exists());
957 let (part, _) = download_paths(&dest);
958 let kept = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
959 assert!(
960 kept > 0 && kept < 200_000,
961 "one chunk is kept, got {kept} bytes"
962 );
963
964 let outcome = download_resumable(&srv, "http://h/b", &dest, &mut NoProgress, &NeverCancel);
965 assert_eq!(outcome.ok().map(|o| o.resumed_from), Some(kept));
966 assert_eq!(std::fs::read(&dest).ok(), Some(srv.data.clone()));
967 }
968
969 #[test]
970 fn auth_failure_does_not_retry_the_download() {
971 use crate::error::{AuthFailure, Error};
972
973 struct AuthClient;
974 impl HttpClient for AuthClient {
975 fn get(&self, _: &str, _: Option<&str>) -> crate::error::Result<Vec<u8>> {
976 unreachable!()
977 }
978 fn post(
979 &self,
980 _: &str,
981 _: &str,
982 _: &str,
983 _: &[u8],
984 _: Option<&str>,
985 ) -> crate::error::Result<Vec<u8>> {
986 unreachable!()
987 }
988 fn send(&self, _: &HttpRequest<'_>) -> crate::error::Result<HttpResponse> {
989 Err(Error::Auth {
990 url: "http://h/b".to_owned(),
991 reason: AuthFailure::Rejected,
992 })
993 }
994 }
995
996 let dir = tempdir();
997 let dest = dir.path().join("clone.bundle");
998 let outcome = download_resumable(
999 &AuthClient,
1000 "http://h/b",
1001 &dest,
1002 &mut NoProgress,
1003 &NeverCancel,
1004 );
1005 assert!(
1006 matches!(outcome, Err(ResumableError::Http(_))),
1007 "auth errors must fail immediately: {outcome:?}"
1008 );
1009 assert!(
1010 !matches!(outcome, Err(ResumableError::Exhausted { .. })),
1011 "auth must not be retried like a dropped connection"
1012 );
1013 }
1014
1015 #[test]
1016 fn an_already_cancelled_download_sends_no_request() {
1017 let dir = tempdir();
1018 let dest = dir.path().join("clone.bundle");
1019 let srv = server(&[]);
1020 let flag = FlagCancel::new();
1021 flag.cancel();
1022 assert!(matches!(
1023 download_resumable(&srv, "http://h/b", &dest, &mut NoProgress, &flag),
1024 Err(ResumableError::Cancelled)
1025 ));
1026 assert!(srv
1027 .ranges
1028 .lock()
1029 .unwrap_or_else(|e| e.into_inner())
1030 .is_empty());
1031 }
1032
1033 #[test]
1034 fn content_ranges_parse() {
1035 assert_eq!(
1036 parse_content_range(Some("bytes 10-99/100")),
1037 Some((10, Some(100)))
1038 );
1039 assert_eq!(parse_content_range(Some("bytes 10-99/*")), Some((10, None)));
1040 assert_eq!(parse_content_range(Some("items 1-2/3")), None);
1041 assert_eq!(parse_content_range(None), None);
1042 }
1043
1044 #[test]
1045 fn urls_resolve_against_the_endpoint() {
1046 assert_eq!(
1047 resolve_url("http://h:1/repo/push", "/repo/push/abc").as_deref(),
1048 Some("http://h:1/repo/push/abc")
1049 );
1050 assert_eq!(
1051 resolve_url("http://h/repo/push", "https://other/x").as_deref(),
1052 Some("https://other/x")
1053 );
1054 }
1055
1056 struct TusServer {
1058 stored: Mutex<Vec<u8>>,
1059 patch_cuts: Mutex<VecDeque<Option<usize>>>,
1061 length: Mutex<u64>,
1062 }
1063
1064 impl HttpClient for TusServer {
1065 fn get(&self, _: &str, _: Option<&str>) -> crate::error::Result<Vec<u8>> {
1066 unreachable!()
1067 }
1068 fn post(
1069 &self,
1070 _: &str,
1071 _: &str,
1072 _: &str,
1073 _: &[u8],
1074 _: Option<&str>,
1075 ) -> crate::error::Result<Vec<u8>> {
1076 unreachable!()
1077 }
1078 fn send(&self, request: &HttpRequest<'_>) -> crate::error::Result<HttpResponse> {
1079 let header = |name: &str| {
1080 request
1081 .headers
1082 .iter()
1083 .find(|(n, _)| n.eq_ignore_ascii_case(name))
1084 .map(|(_, v)| v.clone())
1085 };
1086 let mut stored = self.stored.lock().unwrap_or_else(|e| e.into_inner());
1087 let reply = |status: u16, headers: Vec<(String, String)>| HttpResponse {
1088 status,
1089 headers,
1090 body: Box::new(std::io::empty()),
1091 };
1092 let offset = |n: usize| vec![("upload-offset".to_owned(), n.to_string())];
1093 Ok(match request.method {
1094 HttpMethod::Post => {
1095 *self.length.lock().unwrap_or_else(|e| e.into_inner()) =
1096 header("Upload-Length")
1097 .and_then(|v| v.parse().ok())
1098 .unwrap_or(0);
1099 reply(201, vec![("location".to_owned(), "/push/u1".to_owned())])
1100 }
1101 HttpMethod::Head => reply(200, offset(stored.len())),
1102 HttpMethod::Patch => {
1103 let at: usize = header("Upload-Offset")
1104 .and_then(|v| v.parse().ok())
1105 .unwrap_or(usize::MAX);
1106 if at != stored.len() {
1107 return Ok(reply(409, vec![]));
1108 }
1109 let cut = self
1110 .patch_cuts
1111 .lock()
1112 .unwrap_or_else(|e| e.into_inner())
1113 .pop_front()
1114 .flatten();
1115 match cut {
1116 Some(keep) => {
1117 stored.extend_from_slice(&request.body[..keep]);
1118 return Err(crate::error::Error::Message("reset".to_owned()));
1119 }
1120 None => {
1121 stored.extend_from_slice(request.body);
1122 reply(204, offset(stored.len()))
1123 }
1124 }
1125 }
1126 HttpMethod::Get | HttpMethod::Put | HttpMethod::Delete => reply(405, vec![]),
1127 })
1128 }
1129 }
1130
1131 #[test]
1132 fn tus_uploads_resume_from_the_server_offset() {
1133 let dir = tempdir();
1134 let file = dir.path().join("push.bundle");
1135 let data: Vec<u8> = (0..(PATCH_CHUNK * 2 + 1000))
1136 .map(|i| (i % 253) as u8)
1137 .collect();
1138 assert!(std::fs::write(&file, &data).is_ok());
1139 let srv = TusServer {
1140 stored: Mutex::new(Vec::new()),
1141 patch_cuts: Mutex::new([None, Some(1000)].into_iter().collect()),
1142 length: Mutex::new(0),
1143 };
1144 let upload = match TusUpload::create(&srv, "http://h/push", data.len() as u64) {
1145 Ok(u) => u,
1146 Err(e) => panic!("create: {e}"),
1147 };
1148 assert_eq!(upload.url, "http://h/push/u1");
1149 let mut events = Events::default();
1150 assert!(upload.send_file(&srv, &file, &mut events).is_ok());
1151 assert_eq!(*srv.stored.lock().unwrap_or_else(|e| e.into_inner()), data);
1152 assert!(matches!(
1153 events.0.as_slice(),
1154 [
1155 ResumableEvent::Started {
1156 resumed_from: 0,
1157 ..
1158 },
1159 ResumableEvent::Reconnecting { attempt: 1, .. },
1160 ResumableEvent::Finished { .. },
1161 ]
1162 ));
1163 assert_eq!(upload.offset(&srv).ok().flatten(), Some(data.len() as u64));
1164 }
1165}