1use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10
11use futures::StreamExt;
12use libfw_core::compress::{CompressionFormat, decompressor};
13use libfw_core::{HEADER_COMPRESS, MIN_PARALLEL_DOWNLOAD_BYTES};
14use reqwest::header::{CONTENT_LENGTH, ETAG, IF_RANGE, RANGE};
15use reqwest::StatusCode;
16use serde::{Deserialize, Serialize};
17use tokio::io::{AsyncSeekExt, AsyncWriteExt};
18
19use super::{NativeClient, NativeEvent, validate_relative};
20use crate::LibfwError;
21use crate::tune::{TransferKind, now_ms};
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
29struct ResumeState {
30 etag: String,
31 offset: u64,
32}
33
34fn resume_path(dest: &Path) -> PathBuf {
36 let mut name = dest.as_os_str().to_owned();
37 name.push(".libfw-resume.json");
38 PathBuf::from(name)
39}
40
41fn load_resume(dest: &Path) -> Option<ResumeState> {
42 let text = std::fs::read_to_string(resume_path(dest)).ok()?;
43 serde_json::from_str(&text).ok()
44}
45
46fn save_resume(dest: &Path, etag: &str, offset: u64) -> Result<(), LibfwError> {
47 let state = ResumeState {
48 etag: etag.to_string(),
49 offset,
50 };
51 let text = serde_json::to_string(&state)
52 .map_err(|e| LibfwError::Storage(format!("resume state: {e}")))?;
53 if let Some(parent) = dest.parent() {
54 std::fs::create_dir_all(parent)
55 .map_err(|e| LibfwError::Storage(format!("create dir {}: {e}", parent.display())))?;
56 }
57 std::fs::write(resume_path(dest), text)
58 .map_err(|e| LibfwError::Storage(format!("write resume state: {e}")))
59}
60
61const RESUME_SAVE_EVERY: u64 = 4 * 1024 * 1024;
64
65fn parallel_chunks(from: u64, size: u64, chunk_size: u64) -> Vec<(u64, u64)> {
67 let chunk_size = if chunk_size == 0 {
68 libfw_core::CHUNK_SIZE
69 } else {
70 chunk_size
71 };
72 let mut chunks = Vec::new();
73 let mut offset = from.min(size);
74 while offset < size {
75 let end = (offset + chunk_size).min(size);
76 chunks.push((offset, end));
77 offset = end;
78 }
79 chunks
80}
81
82fn should_chunked(size: u64, resume_offset: u64) -> bool {
91 size >= MIN_PARALLEL_DOWNLOAD_BYTES
92 && size.saturating_sub(resume_offset) >= MIN_PARALLEL_DOWNLOAD_BYTES
93}
94
95fn batch_span(remaining: u64, chunk_size: u64, window: usize) -> u64 {
97 let chunk_size = chunk_size.max(1);
98 let window = window.max(1) as u64;
99 chunk_size
100 .saturating_mul(window)
101 .max(chunk_size)
102 .min(remaining.max(1))
103}
104
105impl NativeClient {
106 pub async fn download_file(
115 &self,
116 remote: &str,
117 dest: impl AsRef<Path>,
118 ) -> Result<u64, LibfwError> {
119 validate_relative(remote)?;
120 let dest = dest.as_ref();
121 let params = self.prepare_transfer(TransferKind::Download).await?;
122
123 let mut attempt = self.download_once(remote, dest, params.compress_level, false);
124 let mut outcome = attempt.await;
125 if let Err(e) = &outcome {
126 if super::is_restart_err(e) {
127 self.log(format!(
128 "`{remote}` changed or shrank on the server; restarting from byte 0"
129 ));
130 attempt = self.download_once(remote, dest, params.compress_level, true);
131 outcome = attempt.await;
132 }
133 }
134 self.finish_transfer(outcome.is_ok());
135 outcome
136 }
137
138 async fn download_once(
140 &self,
141 remote: &str,
142 dest: &Path,
143 level: i32,
144 force_restart: bool,
145 ) -> Result<u64, LibfwError> {
146 let (etag, size) = self.fetch_meta(remote).await?;
147
148 let resume = load_resume(dest).filter(|r| !force_restart && r.etag == etag);
151 let local_len = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
152 let start = match resume {
153 Some(state) if state.offset <= size && local_len >= state.offset => state.offset,
154 _ => 0,
155 };
156 if start > 0 {
157 self.log(format!("resuming `{remote}` at byte {start} of {size}"));
158 }
159
160 self.emit(NativeEvent::FileStart {
161 path: remote.to_string(),
162 size,
163 });
164
165 if start == size {
166 self.emit(NativeEvent::FileDone {
168 path: remote.to_string(),
169 bytes: 0,
170 });
171 return Ok(0);
172 }
173
174 if let Some(parent) = dest.parent() {
175 tokio::fs::create_dir_all(parent)
176 .await
177 .map_err(|e| LibfwError::Storage(format!("create dir {}: {e}", parent.display())))?;
178 }
179 let mut file = tokio::fs::OpenOptions::new()
180 .create(true)
181 .read(true)
182 .write(true)
183 .truncate(false)
184 .open(dest)
185 .await
186 .map_err(|e| LibfwError::Storage(format!("open {}: {e}", dest.display())))?;
187
188 let moved = if should_chunked(size, start) {
192 let mut cursor = start;
193 let mut moved = 0u64;
194 while cursor < size {
195 let window = self
198 .with_tune(|t| t.params().download_window.max(1))
199 .unwrap_or(self.config().download_window.max(1));
200 let chunk_size = self
201 .with_tune(|t| t.params().chunk_size.max(1))
202 .unwrap_or(self.config().chunk_size.max(1));
203 let end = cursor.saturating_add(batch_span(size - cursor, chunk_size, window));
204 let got = self
205 .download_batch(
206 remote, &etag, &mut file, cursor, end, size, moved, window, chunk_size,
207 level, dest,
208 )
209 .await?;
210 if got == 0 {
211 break;
212 }
213 cursor = cursor.saturating_add(got);
214 moved = moved.saturating_add(got);
215 let _ = save_resume(dest, &etag, cursor);
218 }
219 moved
220 } else {
221 self.download_sequential(remote, &etag, &mut file, start, size, level, dest)
222 .await?
223 };
224
225 file.set_len(size)
228 .await
229 .map_err(|e| LibfwError::Storage(format!("truncate {}: {e}", dest.display())))?;
230 file.flush()
231 .await
232 .map_err(|e| LibfwError::Storage(format!("flush {}: {e}", dest.display())))?;
233
234 let computed = start.saturating_add(moved);
235 if computed != size {
236 return Err(LibfwError::Protocol(format!(
237 "download of `{remote}` stopped at {computed} bytes, expected {size}"
238 )));
239 }
240 let _ = save_resume(dest, &etag, size);
242 self.emit(NativeEvent::FileDone {
243 path: remote.to_string(),
244 bytes: moved,
245 });
246 Ok(moved)
247 }
248
249 pub(super) async fn fetch_meta(&self, remote: &str) -> Result<(String, u64), LibfwError> {
251 let url = self.file_url(remote);
252 let resp = self
253 .http()
254 .head(&url)
255 .headers(self.headers(false, None))
256 .send()
257 .await
258 .map_err(|e| LibfwError::Network(e.to_string()))?;
259 let status = resp.status().as_u16();
260 if status != 200 {
261 return Err(LibfwError::Http { status, url });
262 }
263 let etag = header_str(&resp, ETAG.as_str());
264 let size = header_str(&resp, CONTENT_LENGTH.as_str())
265 .parse::<u64>()
266 .ok()
267 .unwrap_or(0);
268 Ok((etag, size))
269 }
270
271 #[allow(clippy::too_many_arguments)]
277 async fn download_batch(
278 &self,
279 remote: &str,
280 etag: &str,
281 file: &mut tokio::fs::File,
282 from: u64,
283 to: u64,
284 total: u64,
285 done_base: u64,
286 window: usize,
287 chunk_size: u64,
288 level: i32,
289 dest: &Path,
290 ) -> Result<u64, LibfwError> {
291 let chunks = parallel_chunks(from, to, chunk_size);
292 let remote_owned = remote.to_string();
293 let etag_owned = etag.to_string();
294 let mut stream = futures::stream::iter(chunks.into_iter().map(|(s, e)| {
295 let remote = remote_owned.clone();
296 let etag = etag_owned.clone();
297 async move {
298 let (data, rtt) = self
299 .get_range_with_retry(&remote, &etag, s, e, level)
300 .await?;
301 Ok::<_, LibfwError>((s, data, rtt))
302 }
303 }))
304 .buffer_unordered(window);
305
306 let mut pending: BTreeMap<u64, Vec<u8>> = BTreeMap::new();
307 let mut cursor = from;
308 let mut moved = 0u64;
309 let mut last_saved = from;
310
311 while let Some(result) = stream.next().await {
312 let (chunk_start, data, rtt) = match result {
313 Ok(part) => part,
314 Err(e) => {
315 self.tune_tick(done_base.saturating_add(moved), None, true);
318 return Err(e);
319 }
320 };
321 pending.insert(chunk_start, data);
322 while let Some(data) = pending.remove(&cursor) {
324 file.seek(std::io::SeekFrom::Start(cursor))
325 .await
326 .map_err(|e| LibfwError::Storage(format!("seek: {e}")))?;
327 file.write_all(&data)
328 .await
329 .map_err(|e| LibfwError::Storage(format!("write: {e}")))?;
330 cursor = cursor.saturating_add(data.len() as u64);
331 moved = moved.saturating_add(data.len() as u64);
332 self.emit(NativeEvent::Progress {
333 done: done_base.saturating_add(moved),
334 total,
335 });
336 if cursor >= last_saved.saturating_add(RESUME_SAVE_EVERY) {
337 last_saved = cursor;
338 let _ = save_resume(dest, etag, cursor);
339 }
340 self.tune_tick(done_base.saturating_add(moved), Some(rtt), false);
341 }
342 }
343 Ok(moved)
344 }
345
346 #[allow(clippy::too_many_arguments)]
349 async fn download_sequential(
350 &self,
351 remote: &str,
352 etag: &str,
353 file: &mut tokio::fs::File,
354 start: u64,
355 size: u64,
356 level: i32,
357 dest: &Path,
358 ) -> Result<u64, LibfwError> {
359 let (resp, rtt) = self
360 .range_request(remote, etag, start, size, level)
361 .await?;
362 let format = compression_format(&resp);
363 let mut dec = decompressor(format);
364 let mut cursor = start;
365 let mut moved = 0u64;
366 let mut last_saved = 0u64;
367 let mut body = resp.bytes_stream();
368 let mut out = Vec::new();
369
370 while let Some(chunk) = body.next().await {
371 let chunk = chunk.map_err(|e| LibfwError::Network(e.to_string()))?;
372 out.clear();
373 dec.decompress(&chunk, &mut out)
374 .map_err(|e| LibfwError::Decompress(e.to_string()))?;
375 if out.is_empty() {
376 continue;
377 }
378 file.seek(std::io::SeekFrom::Start(cursor))
379 .await
380 .map_err(|e| LibfwError::Storage(format!("seek: {e}")))?;
381 file.write_all(&out)
382 .await
383 .map_err(|e| LibfwError::Storage(format!("write: {e}")))?;
384 cursor = cursor.saturating_add(out.len() as u64);
385 moved = moved.saturating_add(out.len() as u64);
386 if cursor >= last_saved.saturating_add(RESUME_SAVE_EVERY) {
387 last_saved = cursor;
388 let _ = save_resume(dest, etag, cursor);
389 }
390 self.emit(NativeEvent::Progress {
391 done: moved,
392 total: size,
393 });
394 self.tune_tick(moved, Some(rtt), false);
395 }
396
397 out.clear();
398 dec.finish(&mut out)
399 .map_err(|e| LibfwError::Decompress(e.to_string()))?;
400 if !out.is_empty() {
401 file.seek(std::io::SeekFrom::Start(cursor))
402 .await
403 .map_err(|e| LibfwError::Storage(format!("seek: {e}")))?;
404 file.write_all(&out)
405 .await
406 .map_err(|e| LibfwError::Storage(format!("write: {e}")))?;
407 moved = moved.saturating_add(out.len() as u64);
408 }
409 Ok(moved)
410 }
411
412 async fn get_range_with_retry(
414 &self,
415 remote: &str,
416 etag: &str,
417 start: u64,
418 end: u64,
419 level: i32,
420 ) -> Result<(Vec<u8>, f64), LibfwError> {
421 let mut attempt = 0u32;
422 loop {
423 match self.range_to_vec(remote, etag, start, end, level).await {
424 Ok((data, rtt)) => {
425 if data.len() as u64 != end - start {
426 return Err(LibfwError::Protocol(format!(
427 "chunk {start}..{end} of `{remote}` yielded {} bytes, expected {}",
428 data.len(),
429 end - start
430 )));
431 }
432 return Ok((data, rtt));
433 }
434 Err(e) if super::is_restart_err(&e) => return Err(e),
436 Err(e) => {
437 if attempt >= self.config().max_retries {
438 return Err(e);
439 }
440 attempt += 1;
441 self.log(format!(
442 "retrying chunk {start}..{end} of `{remote}` (attempt {attempt}): {e}"
443 ));
444 tokio::time::sleep(self.backoff(attempt)).await;
445 }
446 }
447 }
448 }
449
450 async fn range_to_vec(
452 &self,
453 remote: &str,
454 etag: &str,
455 start: u64,
456 end: u64,
457 level: i32,
458 ) -> Result<(Vec<u8>, f64), LibfwError> {
459 let (resp, rtt) = self.range_request(remote, etag, start, end, level).await?;
460 let format = compression_format(&resp);
461 let mut dec = decompressor(format);
462 let body = resp
463 .bytes()
464 .await
465 .map_err(|e| LibfwError::Network(e.to_string()))?;
466 let mut out = Vec::new();
467 dec.decompress(&body, &mut out)
468 .map_err(|e| LibfwError::Decompress(e.to_string()))?;
469 dec.finish(&mut out)
470 .map_err(|e| LibfwError::Decompress(e.to_string()))?;
471 Ok((out, rtt))
472 }
473
474 async fn range_request(
476 &self,
477 remote: &str,
478 etag: &str,
479 start: u64,
480 end: u64,
481 level: i32,
482 ) -> Result<(reqwest::Response, f64), LibfwError> {
483 let url = self.file_url(remote);
484 let last = end.saturating_sub(1);
485 let mut request = self
486 .http()
487 .get(&url)
488 .headers(self.headers(self.config().compress, Some(level)))
489 .header(RANGE, format!("bytes={start}-{last}"));
490 if !etag.is_empty() {
491 request = request.header(IF_RANGE, etag);
492 }
493 let t0 = now_ms();
494 let resp = request
495 .send()
496 .await
497 .map_err(|e| LibfwError::Network(e.to_string()))?;
498 let rtt = now_ms() - t0;
499 match resp.status() {
500 StatusCode::PARTIAL_CONTENT => Ok((resp, rtt)),
501 other => Err(LibfwError::Http {
504 status: other.as_u16(),
505 url,
506 }),
507 }
508 }
509}
510
511fn compression_format(resp: &reqwest::Response) -> CompressionFormat {
513 resp.headers()
514 .get(HEADER_COMPRESS)
515 .and_then(|v| v.to_str().ok())
516 .and_then(CompressionFormat::parse_header)
517 .unwrap_or(CompressionFormat::None)
518}
519
520fn header_str(resp: &reqwest::Response, name: &str) -> String {
522 resp.headers()
523 .get(name)
524 .and_then(|v| v.to_str().ok())
525 .unwrap_or_default()
526 .to_string()
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 #[test]
534 fn parallel_chunks_cover_the_tail_exactly() {
535 assert_eq!(
536 parallel_chunks(0, 10, 4),
537 vec![(0, 4), (4, 8), (8, 10)]
538 );
539 assert_eq!(parallel_chunks(5, 10, 4), vec![(5, 9), (9, 10)]);
540 assert!(parallel_chunks(10, 10, 4).is_empty());
541 let chunks = parallel_chunks(0, 10, 0);
543 assert_eq!(chunks, vec![(0, 10)]);
544 }
545
546 #[test]
547 fn should_chunked_is_window_independent_and_ignores_small_tails() {
548 let big = MIN_PARALLEL_DOWNLOAD_BYTES * 2;
549 assert!(should_chunked(big, 0));
550 assert!(!should_chunked(1024, 0), "small files stay sequential");
554 assert!(
555 !should_chunked(MIN_PARALLEL_DOWNLOAD_BYTES, MIN_PARALLEL_DOWNLOAD_BYTES - 1),
556 "the remaining tail must also be large"
557 );
558 }
559
560 #[test]
561 fn batch_span_is_bounded_and_never_zero() {
562 assert_eq!(batch_span(10 * 1024 * 1024, 256 * 1024, 4), 1024 * 1024);
564 assert_eq!(batch_span(300 * 1024, 256 * 1024, 4), 300 * 1024);
565 assert_eq!(batch_span(1024, 0, 0), 1);
567 }
568
569 #[test]
570 fn resume_sidecar_round_trips() {
571 let dir = tempfile::tempdir().unwrap();
572 let dest = dir.path().join("file.bin");
573 assert!(load_resume(&dest).is_none());
574 save_resume(&dest, "\"abc\"", 1234).unwrap();
575 let state = load_resume(&dest).unwrap();
576 assert_eq!(state.etag, "\"abc\"");
577 assert_eq!(state.offset, 1234);
578 assert!(resume_path(&dest).exists());
579 }
580}