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