1use super::{
2 fetch::{ArchiveFetcher, DownloadedArchive},
3 progress::{
4 ArchiveExtractionProgress, ArchiveExtractionProgressHandle, DownloadProgress,
5 DownloadRequestLimiter, ProgressReader, SharedProgressReader,
6 },
7 session::DownloadSession,
8 MAX_DOWNLOAD_RETRIES, RETRY_BACKOFF_SECS,
9};
10use eyre::{Result, WrapErr};
11use lz4::Decoder;
12use reqwest::blocking::Client as BlockingClient;
13use reth_cli_util::cancellation::CancellationToken;
14use reth_fs_util as fs;
15use std::{
16 io::Read,
17 path::{Component, Path, PathBuf},
18 sync::{
19 atomic::{AtomicBool, Ordering},
20 Arc,
21 },
22 thread,
23 time::{Duration, Instant},
24};
25use tar::Archive;
26use tokio::task;
27use tracing::{info, warn};
28use url::Url;
29use zstd::stream::read::Decoder as ZstdDecoder;
30
31const EXTENSION_TAR_LZ4: &str = ".tar.lz4";
32const EXTENSION_TAR_ZSTD: &str = ".tar.zst";
33const STREAMING_EXTRACTION_PROGRESS_MIN_FILE_SIZE: u64 = 64 * 1024 * 1024;
34const EXTRACTION_PROGRESS_POLL_INTERVAL: Duration = Duration::from_millis(100);
35
36#[derive(Debug, Clone, Copy)]
38pub(crate) enum CompressionFormat {
39 Lz4,
41 Zstd,
43}
44
45impl CompressionFormat {
46 pub(crate) fn from_url(url: &str) -> Result<Self> {
48 let path =
49 Url::parse(url).map(|u| u.path().to_string()).unwrap_or_else(|_| url.to_string());
50
51 if path.ends_with(EXTENSION_TAR_LZ4) {
52 Ok(Self::Lz4)
53 } else if path.ends_with(EXTENSION_TAR_ZSTD) {
54 Ok(Self::Zstd)
55 } else {
56 Err(eyre::eyre!(
57 "Unsupported file format. Expected .tar.lz4 or .tar.zst, got: {}",
58 path
59 ))
60 }
61 }
62}
63
64fn extract_archive<R: Read>(
66 reader: R,
67 total_size: u64,
68 format: CompressionFormat,
69 target_dir: &Path,
70 static_files_dir: Option<&Path>,
71 cancel_token: CancellationToken,
72) -> Result<()> {
73 let progress_reader = ProgressReader::new(reader, total_size, cancel_token);
74
75 match format {
76 CompressionFormat::Lz4 => {
77 let decoder = Decoder::new(progress_reader)?;
78 unpack_archive(Archive::new(decoder), target_dir, static_files_dir, None)?;
79 }
80 CompressionFormat::Zstd => {
81 let decoder = ZstdDecoder::new(progress_reader)?;
82 unpack_archive(Archive::new(decoder), target_dir, static_files_dir, None)?;
83 }
84 }
85
86 println!();
87 Ok(())
88}
89
90pub(crate) fn extract_archive_raw<R: Read>(
92 reader: R,
93 format: CompressionFormat,
94 target_dir: &Path,
95 static_files_dir: Option<&Path>,
96 progress: Option<&mut ArchiveExtractionProgress>,
97) -> Result<()> {
98 match format {
99 CompressionFormat::Lz4 => {
100 unpack_archive(
101 Archive::new(Decoder::new(reader)?),
102 target_dir,
103 static_files_dir,
104 progress,
105 )?;
106 }
107 CompressionFormat::Zstd => {
108 unpack_archive(
109 Archive::new(ZstdDecoder::new(reader)?),
110 target_dir,
111 static_files_dir,
112 progress,
113 )?;
114 }
115 }
116
117 Ok(())
118}
119
120fn unpack_archive<R: Read>(
121 mut archive: Archive<R>,
122 target_dir: &Path,
123 static_files_dir: Option<&Path>,
124 mut progress: Option<&mut ArchiveExtractionProgress>,
125) -> Result<()> {
126 if static_files_dir.is_none() && progress.is_none() {
127 archive.unpack(target_dir)?;
128 return Ok(())
129 }
130 let entries = archive.entries().wrap_err_with(|| {
131 format!("failed to read archive entries for `{}`", target_dir.display())
132 })?;
133
134 for entry in entries {
135 let mut entry = entry.wrap_err_with(|| {
136 format!("failed to read archive entry for `{}`", target_dir.display())
137 })?;
138 extract_entry_with_progress(
139 &mut entry,
140 target_dir,
141 static_files_dir,
142 progress.as_deref_mut(),
143 )?;
144 }
145
146 Ok(())
147}
148
149pub(crate) fn static_file_relative_path(path: &Path) -> Option<&Path> {
151 path.strip_prefix(".").unwrap_or(path).strip_prefix("static_files").ok()
152}
153
154fn unpack_static_file<R: Read>(
157 entry: &mut tar::Entry<'_, R>,
158 static_files_dir: &Path,
159 relative_path: &Path,
160) -> Result<()> {
161 eyre::ensure!(
162 relative_path
163 .components()
164 .all(|part| matches!(part, Component::Normal(_) | Component::CurDir)),
165 "Invalid static file archive path: {}",
166 relative_path.display()
167 );
168 let entry_type = entry.header().entry_type();
169 eyre::ensure!(
170 entry_type.is_file() || entry_type.is_dir(),
171 "Unsupported static file archive entry"
172 );
173 fs::create_dir_all(static_files_dir)?;
174 let root = static_files_dir.to_path_buf();
175 let dest = root.join(relative_path);
176 let mut current = root.clone();
178 for part in relative_path.components() {
179 current.push(part);
180 match std::fs::symlink_metadata(¤t) {
181 Ok(metadata) => eyre::ensure!(
182 !metadata.file_type().is_symlink(),
183 "Static file archive path contains a symlink"
184 ),
185 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
186 Err(err) => return Err(err.into()),
187 }
188 }
189 if relative_path.as_os_str().is_empty() {
190 return Ok(())
191 }
192 if let Some(parent) = dest.parent() {
193 fs::create_dir_all(parent)?;
194 }
195 entry.unpack(dest)?;
196 Ok(())
197}
198
199fn unpack_entry<R: Read>(
200 entry: &mut tar::Entry<'_, R>,
201 target_dir: &Path,
202 static_files_dir: Option<&Path>,
203) -> Result<()> {
204 eyre::ensure!(
207 static_files_dir.is_none() || !entry.header().entry_type().is_symlink(),
208 "Archive symlinks are unsupported with a custom static files directory"
209 );
210 let path = entry.path()?.into_owned();
211 if let Some(static_files_dir) = static_files_dir &&
212 let Some(relative_path) = static_file_relative_path(&path)
213 {
214 unpack_static_file(entry, static_files_dir, relative_path)
215 } else {
216 entry.unpack_in(target_dir)?;
217 Ok(())
218 }
219}
220
221fn extract_entry_with_progress<R: Read>(
222 entry: &mut tar::Entry<'_, R>,
223 target_dir: &Path,
224 static_files_dir: Option<&Path>,
225 progress: Option<&mut ArchiveExtractionProgress>,
226) -> Result<()> {
227 let size = entry.header().entry_size().unwrap_or(0);
228 let entry_type = entry.header().entry_type();
229
230 if !entry_type.is_file() || size == 0 {
231 unpack_entry(entry, target_dir, static_files_dir).wrap_err_with(|| {
232 format!("failed to extract archive into `{}`", target_dir.display())
233 })?;
234 return Ok(())
235 }
236
237 if size < STREAMING_EXTRACTION_PROGRESS_MIN_FILE_SIZE {
238 unpack_entry(entry, target_dir, static_files_dir).wrap_err_with(|| {
239 format!("failed to extract archive into `{}`", target_dir.display())
240 })?;
241 if let Some(progress) = progress {
242 progress.record_extracted(size);
243 }
244 return Ok(())
245 }
246
247 let Some(progress_handle) = progress.as_ref().and_then(|progress| progress.handle()) else {
248 unpack_entry(entry, target_dir, static_files_dir).wrap_err_with(|| {
249 format!("failed to extract archive into `{}`", target_dir.display())
250 })?;
251 return Ok(())
252 };
253
254 let Some(entry_path) = entry_destination_path(entry, target_dir, static_files_dir)? else {
255 unpack_entry(entry, target_dir, static_files_dir).wrap_err_with(|| {
256 format!("failed to extract archive into `{}`", target_dir.display())
257 })?;
258 return Ok(())
259 };
260
261 let stop = Arc::new(AtomicBool::new(false));
262 let monitor = spawn_extraction_progress_monitor(entry_path, progress_handle, Arc::clone(&stop));
263 let unpack_result = unpack_entry(entry, target_dir, static_files_dir)
264 .wrap_err_with(|| format!("failed to extract archive into `{}`", target_dir.display()));
265 stop.store(true, Ordering::Relaxed);
266
267 let monitor_result = monitor.join();
268 unpack_result?;
269
270 monitor_result.map_err(|_| eyre::eyre!("extraction progress monitor panicked"))?;
271 Ok(())
272}
273
274fn entry_destination_path<R: Read>(
275 entry: &tar::Entry<'_, R>,
276 target_dir: &Path,
277 static_files_dir: Option<&Path>,
278) -> Result<Option<PathBuf>> {
279 let mut file_dst = target_dir.to_path_buf();
280 let path = entry.path().wrap_err("invalid path in archive entry")?;
281 let path = if let Some(static_files_dir) = static_files_dir &&
282 let Some(relative_path) = static_file_relative_path(&path)
283 {
284 file_dst = static_files_dir.to_path_buf();
285 relative_path
286 } else {
287 path.as_ref()
288 };
289
290 for part in path.components() {
291 match part {
292 Component::Prefix(..) | Component::RootDir | Component::CurDir => continue,
293 Component::ParentDir => return Ok(None),
294 Component::Normal(part) => file_dst.push(part),
295 }
296 }
297
298 if file_dst == target_dir {
299 return Ok(None)
300 }
301
302 Ok(Some(file_dst))
303}
304
305fn spawn_extraction_progress_monitor(
306 entry_path: PathBuf,
307 progress: ArchiveExtractionProgressHandle,
308 stop: Arc<AtomicBool>,
309) -> thread::JoinHandle<()> {
310 thread::spawn(move || {
311 let mut extracted = 0_u64;
312
313 loop {
314 record_extracted_file_bytes(&entry_path, &progress, &mut extracted);
315 if stop.load(Ordering::Relaxed) {
316 break;
317 }
318 thread::sleep(EXTRACTION_PROGRESS_POLL_INTERVAL);
319 }
320 })
321}
322
323fn record_extracted_file_bytes(
324 entry_path: &Path,
325 progress: &ArchiveExtractionProgressHandle,
326 extracted: &mut u64,
327) {
328 let Ok(meta) = fs::metadata(entry_path) else { return };
329 let len = meta.len();
330 if len > *extracted {
331 progress.record_extracted(len - *extracted);
332 *extracted = len;
333 }
334}
335
336fn extract_from_file(
338 path: &Path,
339 format: CompressionFormat,
340 target_dir: &Path,
341 static_files_dir: Option<&Path>,
342) -> Result<()> {
343 let file = std::fs::File::open(path)?;
344 let total_size = file.metadata()?.len();
345 info!(target: "reth::cli",
346 file = %path.display(),
347 size = %DownloadProgress::format_size(total_size),
348 "Extracting local archive"
349 );
350 let start = Instant::now();
351 extract_archive(
352 file,
353 total_size,
354 format,
355 target_dir,
356 static_files_dir,
357 CancellationToken::new(),
358 )?;
359 info!(target: "reth::cli",
360 file = %path.display(),
361 elapsed = %DownloadProgress::format_duration(start.elapsed()),
362 "Local extraction complete"
363 );
364 Ok(())
365}
366
367pub(crate) fn streaming_download_and_extract(
371 url: &str,
372 format: CompressionFormat,
373 target_dir: &Path,
374 static_files_dir: Option<&Path>,
375 session: &DownloadSession,
376) -> Result<()> {
377 if let Some(path) = archive_file_url_path(url)? {
378 let size = path.metadata()?.len();
379 extract_from_file(&path, format, target_dir, static_files_dir)?;
380 session.record_archive_output_complete(size);
381 return Ok(())
382 }
383
384 let shared = session.progress();
385 let quiet = session.progress().is_some();
386 let mut last_error: Option<eyre::Error> = None;
387
388 for attempt in 1..=MAX_DOWNLOAD_RETRIES {
389 if attempt > 1 {
390 info!(target: "reth::cli",
391 url = %url,
392 attempt,
393 max = MAX_DOWNLOAD_RETRIES,
394 "Retrying streaming download from scratch"
395 );
396 }
397
398 let client = BlockingClient::builder().connect_timeout(Duration::from_secs(30)).build()?;
399 let _request_permit = session
400 .request_limiter()
401 .map(|limiter| limiter.acquire(session.progress(), session.cancel_token()))
402 .transpose()?;
403
404 let response = match client.get(url).send().and_then(|r| r.error_for_status()) {
405 Ok(r) => r,
406 Err(error) => {
407 let err = eyre::Error::from(error);
408 if attempt < MAX_DOWNLOAD_RETRIES {
409 warn!(target: "reth::cli",
410 url = %url,
411 attempt,
412 max = MAX_DOWNLOAD_RETRIES,
413 err = %err,
414 "Streaming request failed, retrying"
415 );
416 }
417 last_error = Some(err);
418 if attempt < MAX_DOWNLOAD_RETRIES {
419 std::thread::sleep(
420 session.retry_delay(Duration::from_secs(RETRY_BACKOFF_SECS)),
421 );
422 }
423 continue;
424 }
425 };
426
427 if !quiet && let Some(size) = response.content_length() {
428 info!(target: "reth::cli",
429 url = %url,
430 size = %DownloadProgress::format_size(size),
431 "Streaming archive"
432 );
433 }
434
435 let result = if let Some(progress) = shared {
436 let reader = SharedProgressReader { inner: response, progress: Arc::clone(progress) };
437 extract_archive_raw(reader, format, target_dir, static_files_dir, None)
438 } else {
439 let total_size = response.content_length().unwrap_or(0);
440 extract_archive(
441 response,
442 total_size,
443 format,
444 target_dir,
445 static_files_dir,
446 session.cancel_token().clone(),
447 )
448 };
449
450 match result {
451 Ok(()) => return Ok(()),
452 Err(error) => {
453 if attempt < MAX_DOWNLOAD_RETRIES {
454 warn!(target: "reth::cli",
455 url = %url,
456 attempt,
457 max = MAX_DOWNLOAD_RETRIES,
458 err = %error,
459 "Streaming extraction failed, retrying"
460 );
461 }
462 last_error = Some(error);
463 if attempt < MAX_DOWNLOAD_RETRIES {
464 std::thread::sleep(
465 session.retry_delay(Duration::from_secs(RETRY_BACKOFF_SECS)),
466 );
467 }
468 }
469 }
470 }
471
472 Err(last_error.unwrap_or_else(|| {
473 eyre::eyre!("Streaming download failed after {MAX_DOWNLOAD_RETRIES} attempts")
474 }))
475}
476
477fn archive_file_url_path(url: &str) -> Result<Option<PathBuf>> {
479 let Ok(parsed) = Url::parse(url) else { return Ok(None) };
480 if parsed.scheme() != "file" {
481 return Ok(None)
482 }
483
484 parsed
485 .to_file_path()
486 .map(Some)
487 .map_err(|_| eyre::eyre!("Invalid file:// archive URL path: {url}"))
488}
489
490fn download_and_extract(
492 url: &str,
493 format: CompressionFormat,
494 target_dir: &Path,
495 static_files_dir: Option<&Path>,
496 session: DownloadSession,
497) -> Result<()> {
498 let quiet = session.progress().is_some();
499 let fetcher = ArchiveFetcher::new(url.to_string(), target_dir, session.clone());
500 let DownloadedArchive { path: downloaded_path, size: total_size } = fetcher.download(None)?;
501
502 let file_name =
503 downloaded_path.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
504
505 if !quiet {
506 info!(target: "reth::cli",
507 file = %file_name,
508 size = %DownloadProgress::format_size(total_size),
509 "Extracting archive"
510 );
511 }
512 let file = fs::open(&downloaded_path)?;
513
514 if quiet {
515 extract_archive_raw(file, format, target_dir, static_files_dir, None)?;
516 } else {
517 extract_archive(
518 file,
519 total_size,
520 format,
521 target_dir,
522 static_files_dir,
523 session.cancel_token().clone(),
524 )?;
525 info!(target: "reth::cli",
526 file = %file_name,
527 "Extraction complete"
528 );
529 }
530
531 fetcher.cleanup_downloaded_files();
532 session.record_archive_output_complete(total_size);
533
534 Ok(())
535}
536
537fn blocking_download_and_extract(
543 url: &str,
544 target_dir: &Path,
545 static_files_dir: Option<&Path>,
546 resumable: bool,
547 request_limiter: Option<Arc<DownloadRequestLimiter>>,
548 cancel_token: CancellationToken,
549 retry_backoff: Option<Duration>,
550) -> Result<()> {
551 let format = CompressionFormat::from_url(url)?;
552
553 if let Ok(parsed_url) = Url::parse(url) &&
554 parsed_url.scheme() == "file"
555 {
556 let session = DownloadSession::new(None, request_limiter, cancel_token)
557 .with_retry_backoff(retry_backoff);
558 let file_path = parsed_url
559 .to_file_path()
560 .map_err(|_| eyre::eyre!("Invalid file:// URL path: {}", url))?;
561 let result = extract_from_file(&file_path, format, target_dir, static_files_dir);
562 if result.is_ok() {
563 session.record_archive_output_complete(file_path.metadata()?.len());
564 }
565 result
566 } else if let Some(request_limiter) = request_limiter {
567 download_and_extract(
568 url,
569 format,
570 target_dir,
571 static_files_dir,
572 DownloadSession::new(None, Some(request_limiter), cancel_token)
573 .with_retry_backoff(retry_backoff),
574 )
575 } else if resumable {
576 let session =
577 DownloadSession::new(None, Some(DownloadRequestLimiter::new(1)), cancel_token)
578 .with_retry_backoff(retry_backoff);
579 download_and_extract(url, format, target_dir, static_files_dir, session)
580 } else {
581 let session =
582 DownloadSession::new(None, None, cancel_token).with_retry_backoff(retry_backoff);
583 let result =
584 streaming_download_and_extract(url, format, target_dir, static_files_dir, &session);
585 if result.is_ok() {
586 session.record_archive_output_complete(0);
587 }
588 result
589 }
590}
591
592pub(crate) async fn stream_and_extract(
597 url: &str,
598 target_dir: &Path,
599 static_files_dir: Option<&Path>,
600 resumable: bool,
601 request_limiter: Option<Arc<DownloadRequestLimiter>>,
602 cancel_token: CancellationToken,
603 retry_backoff: Option<Duration>,
604) -> Result<()> {
605 let target_dir = target_dir.to_path_buf();
606 let static_files_dir = static_files_dir.map(Path::to_path_buf);
607 let url = url.to_string();
608 task::spawn_blocking(move || {
609 blocking_download_and_extract(
610 &url,
611 &target_dir,
612 static_files_dir.as_deref(),
613 resumable,
614 request_limiter,
615 cancel_token,
616 retry_backoff,
617 )
618 })
619 .await??;
620
621 Ok(())
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627
628 #[test]
629 fn custom_static_root_cannot_be_redirected_by_archive_symlinks() {
630 for custom_path in ["custom", "custom/nested"] {
631 let target = tempfile::tempdir().unwrap();
632 let outside = tempfile::tempdir().unwrap();
633 fs::create_dir_all(outside.path().join("nested")).unwrap();
634 for path in ["headers", "nested/headers"] {
635 fs::write(outside.path().join(path), b"keep").unwrap();
636 }
637 let custom = target.path().join(custom_path);
638 let mut archive = tar::Builder::new(Vec::new());
639 let mut link = tar::Header::new_gnu();
640 link.set_entry_type(tar::EntryType::Symlink);
641 link.set_size(0);
642 link.set_mode(0o777);
643 archive.append_link(&mut link, "custom", outside.path()).unwrap();
644 let mut file = tar::Header::new_gnu();
645 file.set_size(4);
646 file.set_mode(0o644);
647 file.set_cksum();
648 archive.append_data(&mut file, "static_files/headers", b"data".as_slice()).unwrap();
649 let tar = archive.into_inner().unwrap();
650 let err =
651 unpack_archive(Archive::new(tar.as_slice()), target.path(), Some(&custom), None)
652 .unwrap_err();
653 assert!(format!("{err:#}").contains("Archive symlinks are unsupported"));
654 let outputs = [super::super::manifest::OutputFileChecksum {
655 path: "static_files/headers".into(),
656 size: 4,
657 blake3: String::new(),
658 }];
659 super::super::verify::OutputVerifier::new(target.path(), Some(&custom))
660 .cleanup(&outputs);
661 for path in ["headers", "nested/headers"] {
662 assert_eq!(fs::read(outside.path().join(path)).unwrap(), b"keep");
663 }
664 }
665 }
666
667 #[test]
668 fn remap_static_files_in_both_compression_formats() {
669 let mut archive = tar::Builder::new(Vec::new());
670 let mut directory = tar::Header::new_gnu();
671 directory.set_entry_type(tar::EntryType::Directory);
672 directory.set_size(0);
673 directory.set_mode(0o755);
674 directory.set_cksum();
675 archive.append_data(&mut directory, "./static_files/", std::io::empty()).unwrap();
676 for path in ["./static_files/nested/headers", "db/data"] {
677 let mut header = tar::Header::new_gnu();
678 header.set_size(4);
679 header.set_mode(0o644);
680 header.set_cksum();
681 archive.append_data(&mut header, path, b"data".as_slice()).unwrap();
682 }
683 let tar = archive.into_inner().unwrap();
684 for format in [CompressionFormat::Lz4, CompressionFormat::Zstd] {
685 let bytes = match format {
686 CompressionFormat::Lz4 => {
687 let mut encoder = lz4::EncoderBuilder::new().build(Vec::new()).unwrap();
688 std::io::copy(&mut tar.as_slice(), &mut encoder).unwrap();
689 let (bytes, result) = encoder.finish();
690 result.unwrap();
691 bytes
692 }
693 CompressionFormat::Zstd => zstd::encode_all(tar.as_slice(), 0).unwrap(),
694 };
695 let target = tempfile::tempdir().unwrap();
696 let custom = tempfile::tempdir().unwrap();
697 extract_archive_raw(bytes.as_slice(), format, target.path(), Some(custom.path()), None)
698 .unwrap();
699 assert_eq!(fs::read(custom.path().join("nested/headers")).unwrap(), b"data");
700 assert_eq!(fs::read(target.path().join("db/data")).unwrap(), b"data");
701 assert!(!target.path().join("static_files").exists());
702 }
703 }
704
705 #[test]
706 fn test_compression_format_detection() {
707 assert!(matches!(
708 CompressionFormat::from_url("https://example.com/snapshot.tar.lz4"),
709 Ok(CompressionFormat::Lz4)
710 ));
711 assert!(matches!(
712 CompressionFormat::from_url("https://example.com/snapshot.tar.zst"),
713 Ok(CompressionFormat::Zstd)
714 ));
715 assert!(matches!(
716 CompressionFormat::from_url("file:///path/to/snapshot.tar.lz4"),
717 Ok(CompressionFormat::Lz4)
718 ));
719 assert!(matches!(
720 CompressionFormat::from_url("file:///path/to/snapshot.tar.zst"),
721 Ok(CompressionFormat::Zstd)
722 ));
723 assert!(CompressionFormat::from_url("https://example.com/snapshot.tar.gz").is_err());
724 }
725}