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