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};
28use crate::transport_path::redact_url_for_display;
29
30pub const TUS_VERSION: &str = "1.0.0";
32
33pub const TUS_PATCH_CONTENT_TYPE: &str = "application/offset+octet-stream";
35
36const PATCH_CHUNK: usize = 4 * 1024 * 1024;
38
39const READ_CHUNK: usize = 64 * 1024;
41
42const MAX_ATTEMPTS: u32 = 5;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum TransferDirection {
48 Download,
50 Upload,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum ResumableEvent {
58 Started {
61 direction: TransferDirection,
63 url: String,
65 total: Option<u64>,
67 resumed_from: u64,
69 },
70 Advanced {
72 done: u64,
74 total: Option<u64>,
76 },
77 Reconnecting {
79 done: u64,
81 attempt: u32,
83 },
84 Finished {
86 done: u64,
88 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#[derive(Debug, thiserror::Error)]
113pub enum ResumableError {
114 #[error("{}", resumable_status_message(.url, *status))]
116 Status {
117 url: String,
119 status: u16,
121 },
122 #[error("{}", resumable_protocol_message(.url, .detail))]
124 Protocol {
125 url: String,
127 detail: String,
129 },
130 #[error("{}", resumable_exhausted_message(.url, *attempts, .last))]
132 Exhausted {
133 url: String,
135 attempts: u32,
137 last: String,
139 },
140 #[error(transparent)]
142 Http(Box<crate::error::Error>),
143 #[error(transparent)]
145 Io(#[from] std::io::Error),
146 #[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
158pub type Result<T> = std::result::Result<T, ResumableError>;
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub struct DownloadOutcome {
164 pub bytes: u64,
166 pub resumed_from: u64,
168}
169
170#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
172struct DownloadState {
173 url: String,
175 validator: Option<String>,
178 total: Option<u64>,
180 complete: bool,
182}
183
184fn 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#[derive(Debug)]
210pub enum ResumableDownloadDest {
211 Shared {
213 dest: PathBuf,
215 lock: File,
217 },
218 Private(PathBuf),
220}
221
222pub 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
272pub 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
289pub 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
416struct AttemptStart {
418 total: Option<u64>,
419 resumed_from: u64,
420}
421
422enum AttemptOutcome {
423 Complete(u64),
425 Dropped(String),
427}
428
429#[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 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 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
554fn 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
563fn 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#[derive(Debug, Clone, PartialEq, Eq)]
583pub(crate) struct TusUpload {
584 pub url: String,
586 pub length: u64,
588}
589
590impl TusUpload {
591 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 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 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 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#[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 struct FileServer {
769 data: Vec<u8>,
770 etag: String,
771 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 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 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 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 struct TusServer {
1167 stored: Mutex<Vec<u8>>,
1168 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}