Skip to main content

reth_cli_commands/download/
extract.rs

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/// Supported compression formats for snapshots
37#[derive(Debug, Clone, Copy)]
38pub(crate) enum CompressionFormat {
39    /// LZ4-compressed tar archive.
40    Lz4,
41    /// Zstandard-compressed tar archive.
42    Zstd,
43}
44
45impl CompressionFormat {
46    /// Detect compression format from file extension
47    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
64/// Extracts a compressed tar archive to the target directory with progress tracking.
65fn 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
90/// Extracts a compressed tar archive without progress tracking.
91pub(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
149/// Returns the path within the static files directory, accepting tar's optional `./` prefix.
150pub(crate) fn static_file_relative_path(path: &Path) -> Option<&Path> {
151    path.strip_prefix(".").unwrap_or(path).strip_prefix("static_files").ok()
152}
153
154/// Extracts static files beneath their configured root without allowing archive paths or links
155/// to escape it.
156fn 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    // Check each existing ancestor before creating directories or replacing an output.
177    let mut current = root.clone();
178    for part in relative_path.components() {
179        current.push(part);
180        match std::fs::symlink_metadata(&current) {
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    // A non-static entry could otherwise plant a symlink at a nested custom root before
205    // extraction or retry cleanup uses it.
206    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
336/// Extracts a snapshot from a local file.
337fn 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
367/// Streams a remote archive directly into the extractor without writing to disk.
368///
369/// On failure, retries from scratch up to [`MAX_DOWNLOAD_RETRIES`] times.
370pub(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
477/// Resolves a `file://` archive URL to its local path.
478fn 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
490/// Fetches the snapshot from a remote URL with resume support, then extracts it.
491fn 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
537/// Downloads and extracts a snapshot, blocking until finished.
538///
539/// Supports `file://` URLs for local files and HTTP(S) URLs for remote downloads.
540/// When `resumable` is true, downloads to a `.part` file first with HTTP Range resume
541/// support. Otherwise streams directly into the extractor.
542fn 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
592/// Downloads and extracts a snapshot archive asynchronously.
593///
594/// Download progress is reported using a local progress bar.
595/// When `resumable` is true, uses two-phase download with `.part` files.
596pub(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}