1use blake3::Hasher;
2use eyre::Result;
3use rayon::prelude::*;
4use reqwest::Client;
5use serde::{Deserialize, Serialize};
6use std::{
7 collections::BTreeMap,
8 io::Read,
9 path::{Path, PathBuf},
10};
11use tracing::info;
12use url::Url;
13
14fn is_zero(value: &u64) -> bool {
15 *value == 0
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SnapshotManifest {
37 pub block: u64,
39 pub chain_id: u64,
41 pub storage_version: u64,
43 pub timestamp: u64,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub base_url: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub reth_version: Option<String>,
53 pub components: BTreeMap<String, ComponentManifest>,
55 #[serde(default, flatten)]
60 pub extensions: BTreeMap<String, serde_json::Value>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(untagged)]
66pub enum ComponentManifest {
67 Single(SingleArchive),
69 Chunked(ChunkedArchive),
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct SingleArchive {
76 pub file: String,
81 pub size: u64,
83 #[serde(default, skip_serializing_if = "is_zero")]
88 pub decompressed_size: u64,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub blake3: Option<String>,
92 #[serde(default)]
96 pub output_files: Vec<OutputFileChecksum>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct ChunkedArchive {
102 pub blocks_per_file: u64,
104 pub total_blocks: u64,
106 #[serde(default)]
109 pub chunk_sizes: Vec<u64>,
110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub chunk_decompressed_sizes: Vec<u64>,
116 #[serde(default, skip_serializing_if = "Vec::is_empty")]
128 pub chunk_files: Vec<String>,
129 #[serde(default)]
133 pub chunk_output_files: Vec<Vec<OutputFileChecksum>>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct OutputFileChecksum {
139 pub path: String,
141 pub size: u64,
143 pub blake3: String,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct SnapshotArchive {
150 pub url: String,
151 pub file_name: String,
152 pub size: u64,
153 pub blake3: Option<String>,
154 pub output_files: Vec<OutputFileChecksum>,
155}
156
157impl SnapshotArchive {
158 pub fn output_size(&self) -> u64 {
160 self.output_files.iter().map(|file| file.size).sum()
161 }
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum ComponentSelection {
167 All,
169 Distance(u64),
172 Since(u64),
175 None,
178}
179
180impl std::fmt::Display for ComponentSelection {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 match self {
183 Self::All => write!(f, "All"),
184 Self::Distance(d) => write!(f, "Last {d} blocks"),
185 Self::Since(block) => write!(f, "Since block {block}"),
186 Self::None => write!(f, "None"),
187 }
188 }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
193pub enum SnapshotComponentType {
194 State,
196 Headers,
198 Transactions,
200 TransactionSenders,
202 Receipts,
204 AccountChangesets,
206 StorageChangesets,
208 RocksdbIndices,
210}
211
212impl SnapshotComponentType {
213 pub const ALL: [Self; 8] = [
215 Self::State,
216 Self::Headers,
217 Self::Transactions,
218 Self::TransactionSenders,
219 Self::Receipts,
220 Self::AccountChangesets,
221 Self::StorageChangesets,
222 Self::RocksdbIndices,
223 ];
224
225 pub const fn key(&self) -> &'static str {
227 match self {
228 Self::State => "state",
229 Self::Headers => "headers",
230 Self::Transactions => "transactions",
231 Self::TransactionSenders => "transaction_senders",
232 Self::Receipts => "receipts",
233 Self::AccountChangesets => "account_changesets",
234 Self::StorageChangesets => "storage_changesets",
235 Self::RocksdbIndices => "rocksdb_indices",
236 }
237 }
238
239 pub const fn display_name(&self) -> &'static str {
241 match self {
242 Self::State => "State (mdbx)",
243 Self::Headers => "Headers",
244 Self::Transactions => "Transactions",
245 Self::TransactionSenders => "Transaction Senders",
246 Self::Receipts => "Receipts",
247 Self::AccountChangesets => "Account Changesets",
248 Self::StorageChangesets => "Storage Changesets",
249 Self::RocksdbIndices => "RocksDB Indices",
250 }
251 }
252
253 pub const fn is_required(&self) -> bool {
257 matches!(self, Self::State | Self::Headers)
258 }
259
260 pub const fn is_chunked(&self) -> bool {
262 !matches!(self, Self::State | Self::RocksdbIndices)
263 }
264}
265
266impl SnapshotManifest {
267 fn base_url_or_empty(&self) -> &str {
268 self.base_url.as_deref().unwrap_or("")
269 }
270
271 pub fn component(&self, ty: SnapshotComponentType) -> Option<&ComponentManifest> {
273 self.components.get(ty.key())
274 }
275
276 pub fn total_size(&self, types: &[SnapshotComponentType]) -> u64 {
278 types.iter().filter_map(|ty| self.component(*ty).map(|c| c.total_size())).sum()
279 }
280
281 pub fn archive_urls(&self, ty: SnapshotComponentType) -> Vec<String> {
283 let Some(component) = self.component(ty) else {
284 return vec![];
285 };
286
287 match component {
288 ComponentManifest::Single(single) => {
289 vec![resolve_archive_url(self.base_url_or_empty(), &single.file)]
290 }
291 ComponentManifest::Chunked(chunked) => {
292 let key = ty.key();
293 let num_chunks = chunked.num_chunks();
294 (0..num_chunks)
295 .map(|i| {
296 resolve_archive_url(
297 self.base_url_or_empty(),
298 &chunked.chunk_relative_path(key, i),
299 )
300 })
301 .collect()
302 }
303 }
304 }
305
306 pub fn archive_urls_for_distance(
309 &self,
310 ty: SnapshotComponentType,
311 distance: Option<u64>,
312 ) -> Vec<String> {
313 let Some(component) = self.component(ty) else {
314 return vec![];
315 };
316
317 match component {
318 ComponentManifest::Single(single) => {
319 vec![resolve_archive_url(self.base_url_or_empty(), &single.file)]
320 }
321 ComponentManifest::Chunked(chunked) => {
322 let key = ty.key();
323 let num_chunks = chunked.num_chunks();
324
325 let start_chunk = match distance {
327 Some(dist) => num_chunks.saturating_sub(chunked.tail_chunks_for_distance(dist)),
329 None => 0, };
331
332 (start_chunk..num_chunks)
333 .map(|i| {
334 resolve_archive_url(
335 self.base_url_or_empty(),
336 &chunked.chunk_relative_path(key, i),
337 )
338 })
339 .collect()
340 }
341 }
342 }
343
344 pub fn snapshot_archives_for_distance(
346 &self,
347 ty: SnapshotComponentType,
348 distance: Option<u64>,
349 ) -> Vec<SnapshotArchive> {
350 let Some(component) = self.component(ty) else {
351 return vec![];
352 };
353
354 match component {
355 ComponentManifest::Single(single) => {
356 vec![SnapshotArchive {
357 url: resolve_archive_url(self.base_url_or_empty(), &single.file),
358 file_name: single.file.clone(),
359 size: single.size,
360 blake3: single.blake3.clone(),
361 output_files: single.output_files.clone(),
362 }]
363 }
364 ComponentManifest::Chunked(chunked) => {
365 let key = ty.key();
366 let num_chunks = chunked.num_chunks();
367
368 let start_chunk = match distance {
369 Some(dist) => num_chunks.saturating_sub(chunked.tail_chunks_for_distance(dist)),
370 None => 0,
371 };
372
373 (start_chunk..num_chunks)
374 .map(|i| {
375 let file_name = chunked.chunk_relative_path(key, i);
376 let size = chunked.chunk_sizes.get(i as usize).copied().unwrap_or_default();
377 let output_files =
378 chunked.chunk_output_files.get(i as usize).cloned().unwrap_or_default();
379
380 SnapshotArchive {
381 url: resolve_archive_url(self.base_url_or_empty(), &file_name),
382 file_name,
383 size,
384 blake3: None,
385 output_files,
386 }
387 })
388 .collect()
389 }
390 }
391 }
392
393 pub fn size_for_distance(&self, ty: SnapshotComponentType, distance: Option<u64>) -> u64 {
398 let Some(component) = self.component(ty) else {
399 return 0;
400 };
401 match component {
402 ComponentManifest::Single(s) => s.size,
403 ComponentManifest::Chunked(chunked) => {
404 if chunked.chunk_sizes.is_empty() {
405 return 0;
406 }
407 let num_chunks = chunked.chunk_sizes.len() as u64;
408 let start_chunk = match distance {
409 Some(dist) => num_chunks.saturating_sub(chunked.tail_chunks_for_distance(dist)),
410 None => 0,
411 };
412 chunked.chunk_sizes[start_chunk as usize..].iter().sum()
413 }
414 }
415 }
416
417 pub fn output_size_for_distance(
419 &self,
420 ty: SnapshotComponentType,
421 distance: Option<u64>,
422 ) -> u64 {
423 let Some(component) = self.component(ty) else {
424 return 0;
425 };
426
427 match component {
428 ComponentManifest::Single(single) => single.output_size(),
429 ComponentManifest::Chunked(chunked) => {
430 let num_chunks = chunked.num_chunks();
431 let start_chunk = match distance {
432 Some(dist) => num_chunks.saturating_sub(chunked.tail_chunks_for_distance(dist)),
433 None => 0,
434 };
435
436 (start_chunk..num_chunks)
437 .map(|index| chunked.chunk_output_size(index as usize))
438 .sum()
439 }
440 }
441 }
442
443 pub fn chunks_for_distance(&self, ty: SnapshotComponentType, distance: Option<u64>) -> u64 {
445 let Some(ComponentManifest::Chunked(chunked)) = self.component(ty) else {
446 return if self.component(ty).is_some() { 1 } else { 0 };
447 };
448 match distance {
449 Some(dist) => chunked.tail_chunks_for_distance(dist),
450 None => chunked.num_chunks(),
451 }
452 }
453}
454
455impl ComponentManifest {
456 pub fn total_size(&self) -> u64 {
458 match self {
459 Self::Single(s) => s.size,
460 Self::Chunked(c) => c.chunk_sizes.iter().sum(),
461 }
462 }
463
464 pub fn total_output_size(&self) -> u64 {
466 match self {
467 Self::Single(single) => single.output_size(),
468 Self::Chunked(chunked) => chunked.total_output_size(),
469 }
470 }
471}
472
473impl ChunkedArchive {
474 pub fn num_chunks(&self) -> u64 {
476 self.total_blocks.div_ceil(self.blocks_per_file)
477 }
478
479 pub fn tail_chunks_for_distance(&self, distance: u64) -> u64 {
482 let needed = distance.min(self.total_blocks);
483 if needed == 0 {
484 return 0;
485 }
486
487 let first_chunk = (self.total_blocks - needed) / self.blocks_per_file;
489 self.num_chunks() - first_chunk
490 }
491
492 pub fn chunk_files_are_consistent(&self) -> bool {
494 self.chunk_files.is_empty() || self.chunk_files.len() as u64 == self.num_chunks()
495 }
496
497 pub fn chunk_relative_path(&self, key: &str, index: u64) -> String {
502 if self.chunk_files.len() as u64 == self.num_chunks() &&
503 let Some(path) = self.chunk_files.get(index as usize)
504 {
505 return path.clone();
506 }
507 let start = index * self.blocks_per_file;
508 let end = (index + 1) * self.blocks_per_file - 1;
509 format!("{key}-{start}-{end}.tar.zst")
510 }
511
512 pub fn chunk_output_size(&self, index: usize) -> u64 {
514 self.chunk_decompressed_sizes.get(index).copied().unwrap_or_else(|| {
515 self.chunk_output_files
516 .get(index)
517 .map(|files| files.iter().map(|file| file.size).sum())
518 .unwrap_or(0)
519 })
520 }
521
522 pub fn total_output_size(&self) -> u64 {
524 if !self.chunk_decompressed_sizes.is_empty() {
525 self.chunk_decompressed_sizes.iter().sum()
526 } else {
527 self.chunk_output_files
528 .iter()
529 .map(|files| files.iter().map(|file| file.size).sum::<u64>())
530 .sum()
531 }
532 }
533}
534
535fn resolve_archive_url(base_url: &str, relative_path: &str) -> String {
541 let relative_path = relative_path.trim_start_matches('/');
542
543 if base_url.is_empty() {
544 return relative_path.to_string();
545 }
546
547 let Ok(mut base) = Url::parse(base_url) else {
548 return format!("{}/{}", base_url.trim_end_matches('/'), relative_path);
549 };
550
551 let path = base.path();
553 if !path.ends_with('/') {
554 let mut with_slash = path.to_string();
555 with_slash.push('/');
556 base.set_path(&with_slash);
557 }
558
559 match base.join(relative_path) {
560 Ok(joined) => joined.to_string(),
561 Err(_) => format!("{}/{}", base_url.trim_end_matches('/'), relative_path),
562 }
563}
564
565impl SingleArchive {
566 pub fn output_size(&self) -> u64 {
568 if self.decompressed_size != 0 {
569 self.decompressed_size
570 } else {
571 self.output_files.iter().map(|file| file.size).sum()
572 }
573 }
574}
575
576pub async fn fetch_manifest(manifest_url: &str) -> Result<SnapshotManifest> {
578 let client = Client::new();
579 let manifest: SnapshotManifest =
580 client.get(manifest_url).send().await?.error_for_status()?.json().await?;
581 Ok(manifest)
582}
583
584pub fn generate_manifest(
586 source_datadir: &Path,
587 output_dir: &Path,
588 base_url: Option<&str>,
589 block: u64,
590 chain_id: u64,
591 blocks_per_file: u64,
592) -> Result<SnapshotManifest> {
593 std::fs::create_dir_all(output_dir)?;
594
595 let mut components = BTreeMap::new();
596
597 for ty in &[
599 SnapshotComponentType::Headers,
600 SnapshotComponentType::Transactions,
601 SnapshotComponentType::TransactionSenders,
602 SnapshotComponentType::Receipts,
603 SnapshotComponentType::AccountChangesets,
604 SnapshotComponentType::StorageChangesets,
605 ] {
606 let key = ty.key();
607 let num_chunks = block.div_ceil(blocks_per_file);
608 let mut planned_chunks = Vec::with_capacity(num_chunks as usize);
609 let mut found_any = false;
610
611 for i in 0..num_chunks {
612 let start = i * blocks_per_file;
613 let end = (i + 1) * blocks_per_file - 1;
614 let source_files = source_files_for_chunk(source_datadir, *ty, start, end)?;
615
616 if source_files.is_empty() {
617 if found_any {
618 eyre::bail!("Missing source files for {} chunk {}-{}", key, start, end);
619 }
620 continue;
621 }
622
623 found_any = true;
624 planned_chunks.push(PlannedChunk {
625 chunk_idx: i,
626 archive_path: output_dir.join(chunk_filename(key, start, end)),
627 source_files,
628 });
629 }
630
631 if found_any {
632 let mut packaged_chunks = planned_chunks
633 .into_par_iter()
634 .map(|planned| -> Result<PackagedChunk> {
635 let output_files =
636 write_chunk_archive(&planned.archive_path, &planned.source_files)?;
637 let size = std::fs::metadata(&planned.archive_path)?.len();
638 Ok(PackagedChunk { chunk_idx: planned.chunk_idx, size, output_files })
639 })
640 .collect::<Vec<_>>()
641 .into_iter()
642 .collect::<Result<Vec<_>>>()?;
643
644 packaged_chunks.sort_unstable_by_key(|chunk| chunk.chunk_idx);
645 let chunk_sizes = packaged_chunks.iter().map(|chunk| chunk.size).collect::<Vec<_>>();
646 let chunk_output_files =
647 packaged_chunks.into_iter().map(|chunk| chunk.output_files).collect::<Vec<_>>();
648 let total_size: u64 = chunk_sizes.iter().sum();
649 info!(target: "reth::cli",
650 component = ty.display_name(),
651 chunks = chunk_sizes.len(),
652 total_blocks = block,
653 size = %super::DownloadProgress::format_size(total_size),
654 "Found chunked component"
655 );
656 components.insert(
657 key.to_string(),
658 ComponentManifest::Chunked(ChunkedArchive {
659 blocks_per_file,
660 total_blocks: block,
661 chunk_sizes,
662 chunk_decompressed_sizes: chunk_output_files
663 .iter()
664 .map(|files| files.iter().map(|file| file.size).sum())
665 .collect(),
666 chunk_files: vec![],
667 chunk_output_files,
668 }),
669 );
670 }
671 }
672
673 let (state_size, state_output_files) = package_single_component(
674 output_dir,
675 "state.tar.zst",
676 &state_source_files(source_datadir)?,
677 )?;
678 components.insert(
679 SnapshotComponentType::State.key().to_string(),
680 ComponentManifest::Single(SingleArchive {
681 file: "state.tar.zst".to_string(),
682 size: state_size,
683 decompressed_size: state_output_files.iter().map(|file| file.size).sum(),
684 blake3: None,
685 output_files: state_output_files,
686 }),
687 );
688
689 let rocksdb_files = rocksdb_source_files(source_datadir)?;
690 if !rocksdb_files.is_empty() {
691 let (rocksdb_size, rocksdb_output_files) =
692 package_single_component(output_dir, "rocksdb_indices.tar.zst", &rocksdb_files)?;
693 components.insert(
694 SnapshotComponentType::RocksdbIndices.key().to_string(),
695 ComponentManifest::Single(SingleArchive {
696 file: "rocksdb_indices.tar.zst".to_string(),
697 size: rocksdb_size,
698 decompressed_size: rocksdb_output_files.iter().map(|file| file.size).sum(),
699 blake3: None,
700 output_files: rocksdb_output_files,
701 }),
702 );
703 }
704
705 let timestamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs();
706
707 Ok(SnapshotManifest {
708 block,
709 chain_id,
710 storage_version: 2,
711 timestamp,
712 base_url: base_url.map(str::to_owned),
713 reth_version: Some(reth_node_core::version::version_metadata().short_version.to_string()),
714 components,
715 extensions: Default::default(),
716 })
717}
718
719pub fn chunk_filename(component_key: &str, start: u64, end: u64) -> String {
721 format!("{component_key}-{start}-{end}.tar.zst")
722}
723
724#[derive(Debug)]
725struct PlannedChunk {
726 chunk_idx: u64,
727 archive_path: PathBuf,
728 source_files: Vec<PathBuf>,
729}
730
731#[derive(Debug)]
732struct PackagedChunk {
733 chunk_idx: u64,
734 size: u64,
735 output_files: Vec<OutputFileChecksum>,
736}
737
738#[derive(Debug)]
739struct PlannedFile {
740 source_path: PathBuf,
741 relative_path: PathBuf,
742}
743
744fn source_files_for_chunk(
745 source_datadir: &Path,
746 component: SnapshotComponentType,
747 start: u64,
748 end: u64,
749) -> Result<Vec<PathBuf>> {
750 let Some(segment_name) = static_segment_name(component) else {
751 return Ok(Vec::new());
752 };
753
754 let static_files_dir = source_datadir.join("static_files");
755 let static_files_dir =
756 if static_files_dir.exists() { static_files_dir } else { source_datadir.to_path_buf() };
757 let prefix = format!("static_file_{segment_name}_{start}_{end}");
758
759 let mut files = Vec::new();
760 for entry in std::fs::read_dir(&static_files_dir)? {
761 let entry = entry?;
762 if !entry.file_type()?.is_file() {
763 continue;
764 }
765 if entry.file_name().to_string_lossy().starts_with(&prefix) {
766 files.push(entry.path());
767 }
768 }
769
770 files.sort_unstable();
771 Ok(files)
772}
773
774fn static_segment_name(component: SnapshotComponentType) -> Option<&'static str> {
775 match component {
776 SnapshotComponentType::Headers => Some("headers"),
777 SnapshotComponentType::Transactions => Some("transactions"),
778 SnapshotComponentType::TransactionSenders => Some("transaction-senders"),
779 SnapshotComponentType::Receipts => Some("receipts"),
780 SnapshotComponentType::AccountChangesets => Some("account-change-sets"),
781 SnapshotComponentType::StorageChangesets => Some("storage-change-sets"),
782 SnapshotComponentType::State | SnapshotComponentType::RocksdbIndices => None,
783 }
784}
785
786fn state_source_files(source_datadir: &Path) -> Result<Vec<PlannedFile>> {
787 let db_dir = source_datadir.join("db");
788 if db_dir.exists() {
789 return collect_files_recursive(&db_dir, Path::new("db"));
790 }
791
792 if looks_like_db_dir(source_datadir)? {
793 return collect_files_recursive(source_datadir, Path::new("db"));
794 }
795
796 eyre::bail!("Could not find source state DB directory under {}", source_datadir.display());
797}
798
799fn rocksdb_source_files(source_datadir: &Path) -> Result<Vec<PlannedFile>> {
800 let rocksdb_dir = source_datadir.join("rocksdb");
801 if !rocksdb_dir.exists() {
802 return Ok(Vec::new());
803 }
804
805 collect_files_recursive(&rocksdb_dir, Path::new("rocksdb"))
806}
807
808fn looks_like_db_dir(path: &Path) -> Result<bool> {
809 let entries = match std::fs::read_dir(path) {
810 Ok(entries) => entries,
811 Err(_) => return Ok(false),
812 };
813
814 for entry in entries {
815 let entry = entry?;
816 if !entry.file_type()?.is_file() {
817 continue;
818 }
819 let name = entry.file_name();
820 let name = name.to_string_lossy();
821 if name == "mdbx.dat" || name == "lock.mdb" || name == "data.mdb" {
822 return Ok(true);
823 }
824 }
825
826 Ok(false)
827}
828
829fn collect_files_recursive(root: &Path, output_prefix: &Path) -> Result<Vec<PlannedFile>> {
830 let mut files = Vec::new();
831 collect_files_recursive_inner(root, root, output_prefix, &mut files)?;
832 files.sort_unstable_by(|a, b| a.relative_path.cmp(&b.relative_path));
833 Ok(files)
834}
835
836fn collect_files_recursive_inner(
837 root: &Path,
838 dir: &Path,
839 output_prefix: &Path,
840 files: &mut Vec<PlannedFile>,
841) -> Result<()> {
842 for entry in std::fs::read_dir(dir)? {
843 let entry = entry?;
844 let path = entry.path();
845 let file_type = entry.file_type()?;
846 if file_type.is_dir() {
847 collect_files_recursive_inner(root, &path, output_prefix, files)?;
848 continue;
849 }
850 if !file_type.is_file() {
851 continue;
852 }
853
854 let relative = path.strip_prefix(root)?.to_path_buf();
855 files.push(PlannedFile { source_path: path, relative_path: output_prefix.join(relative) });
856 }
857
858 Ok(())
859}
860
861fn package_single_component(
862 output_dir: &Path,
863 archive_file_name: &str,
864 files: &[PlannedFile],
865) -> Result<(u64, Vec<OutputFileChecksum>)> {
866 if files.is_empty() {
867 eyre::bail!("Cannot package empty single archive: {}", archive_file_name);
868 }
869
870 let archive_path = output_dir.join(archive_file_name);
871 let output_files = write_archive_from_planned_files(&archive_path, files)?;
872 let size = std::fs::metadata(&archive_path)?.len();
873 Ok((size, output_files))
874}
875
876fn write_chunk_archive(path: &Path, source_files: &[PathBuf]) -> Result<Vec<OutputFileChecksum>> {
877 let planned_files = source_files
878 .iter()
879 .map(|source_path| {
880 let file_name = source_path.file_name().ok_or_else(|| {
881 eyre::eyre!("Invalid source file path: {}", source_path.display())
882 })?;
883 Ok::<_, eyre::Error>(PlannedFile {
884 source_path: source_path.clone(),
885 relative_path: PathBuf::from("static_files").join(file_name),
886 })
887 })
888 .collect::<Result<Vec<_>>>()?;
889
890 write_archive_from_planned_files(path, &planned_files)
891}
892
893fn write_archive_from_planned_files(
894 path: &Path,
895 files: &[PlannedFile],
896) -> Result<Vec<OutputFileChecksum>> {
897 let file = std::fs::File::create(path)?;
898 let mut encoder = zstd::Encoder::new(file, 0)?;
899 encoder.include_checksum(true)?;
902 let mut builder = tar::Builder::new(encoder);
903
904 let mut output_files = Vec::with_capacity(files.len());
905 for planned in files {
906 let mut header = tar::Header::new_gnu();
907 header.set_size(std::fs::metadata(&planned.source_path)?.len());
908 header.set_mode(0o644);
909 header.set_cksum();
910
911 let source_file = std::fs::File::open(&planned.source_path)?;
912 let mut reader = HashingReader::new(source_file);
913 builder.append_data(&mut header, &planned.relative_path, &mut reader)?;
914
915 output_files.push(OutputFileChecksum {
916 path: planned.relative_path.to_string_lossy().to_string(),
917 size: reader.bytes_read,
918 blake3: reader.finalize(),
919 });
920 }
921
922 builder.finish()?;
923 let encoder = builder.into_inner()?;
924 encoder.finish()?;
925
926 Ok(output_files)
927}
928
929struct HashingReader<R> {
930 inner: R,
931 hasher: Hasher,
932 bytes_read: u64,
933}
934
935impl<R: Read> HashingReader<R> {
936 fn new(inner: R) -> Self {
937 Self { inner, hasher: Hasher::new(), bytes_read: 0 }
938 }
939
940 fn finalize(self) -> String {
941 self.hasher.finalize().to_hex().to_string()
942 }
943}
944
945impl<R: Read> Read for HashingReader<R> {
946 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
947 let n = self.inner.read(buf)?;
948 if n > 0 {
949 self.bytes_read += n as u64;
950 self.hasher.update(&buf[..n]);
951 }
952 Ok(n)
953 }
954}
955
956#[cfg(test)]
957mod tests {
958 use super::*;
959 use tempfile::tempdir;
960
961 fn test_manifest() -> SnapshotManifest {
962 let mut components = BTreeMap::new();
963 components.insert(
964 "state".to_string(),
965 ComponentManifest::Single(SingleArchive {
966 file: "state.tar.zst".to_string(),
967 size: 100,
968 decompressed_size: 0,
969 blake3: None,
970 output_files: vec![],
971 }),
972 );
973 components.insert(
974 "transactions".to_string(),
975 ComponentManifest::Chunked(ChunkedArchive {
976 blocks_per_file: 500_000,
977 total_blocks: 1_500_000,
978 chunk_sizes: vec![80_000, 100_000, 120_000],
979 chunk_decompressed_sizes: vec![],
980 chunk_files: vec![],
981 chunk_output_files: vec![vec![], vec![], vec![]],
982 }),
983 );
984 components.insert(
985 "headers".to_string(),
986 ComponentManifest::Chunked(ChunkedArchive {
987 blocks_per_file: 500_000,
988 total_blocks: 1_500_000,
989 chunk_sizes: vec![40_000, 50_000, 60_000],
990 chunk_decompressed_sizes: vec![],
991 chunk_files: vec![],
992 chunk_output_files: vec![vec![], vec![], vec![]],
993 }),
994 );
995 SnapshotManifest {
996 block: 1_500_000,
997 chain_id: 1,
998 storage_version: 2,
999 timestamp: 0,
1000 base_url: Some("https://example.com".to_string()),
1001 reth_version: None,
1002 components,
1003 extensions: Default::default(),
1004 }
1005 }
1006
1007 #[test]
1008 fn manifest_preserves_extensions() {
1009 let manifest: SnapshotManifest = serde_json::from_str(
1010 r#"{
1011 "block": 1,
1012 "chain_id": 1,
1013 "storage_version": 2,
1014 "timestamp": 0,
1015 "components": {},
1016 "consensus": { "archive": "consensus.tar.zst" }
1017 }"#,
1018 )
1019 .unwrap();
1020
1021 assert_eq!(
1022 manifest.extensions.get("consensus"),
1023 Some(&serde_json::json!({ "archive": "consensus.tar.zst" }))
1024 );
1025 }
1026
1027 #[test]
1028 fn archive_urls_for_distance_all() {
1029 let m = test_manifest();
1030 let urls = m.archive_urls_for_distance(SnapshotComponentType::Transactions, None);
1031 assert_eq!(urls.len(), 3);
1032 assert_eq!(urls[0], "https://example.com/transactions-0-499999.tar.zst");
1033 assert_eq!(urls[2], "https://example.com/transactions-1000000-1499999.tar.zst");
1034 }
1035
1036 #[test]
1037 fn archive_urls_for_distance_partial() {
1038 let m = test_manifest();
1039 let urls = m.archive_urls_for_distance(SnapshotComponentType::Transactions, Some(600_000));
1041 assert_eq!(urls.len(), 2);
1042 assert_eq!(urls[0], "https://example.com/transactions-500000-999999.tar.zst");
1043 assert_eq!(urls[1], "https://example.com/transactions-1000000-1499999.tar.zst");
1044 }
1045
1046 #[test]
1047 fn archive_urls_for_distance_single_component() {
1048 let m = test_manifest();
1049 let urls = m.archive_urls_for_distance(SnapshotComponentType::State, Some(100));
1051 assert_eq!(urls.len(), 1);
1052 assert_eq!(urls[0], "https://example.com/state.tar.zst");
1053 }
1054
1055 #[test]
1056 fn archive_urls_for_distance_rocksdb_indices_single_component() {
1057 let mut components = BTreeMap::new();
1058 components.insert(
1059 "rocksdb_indices".to_string(),
1060 ComponentManifest::Single(SingleArchive {
1061 file: "rocksdb_indices.tar.zst".to_string(),
1062 size: 777,
1063 decompressed_size: 0,
1064 blake3: None,
1065 output_files: vec![],
1066 }),
1067 );
1068 let m = SnapshotManifest {
1069 block: 1,
1070 chain_id: 1,
1071 storage_version: 2,
1072 timestamp: 0,
1073 base_url: Some("https://example.com".to_string()),
1074 reth_version: None,
1075 components,
1076 extensions: Default::default(),
1077 };
1078
1079 let urls = m.archive_urls_for_distance(SnapshotComponentType::RocksdbIndices, Some(10));
1080 assert_eq!(urls.len(), 1);
1081 assert_eq!(urls[0], "https://example.com/rocksdb_indices.tar.zst");
1082 assert_eq!(m.size_for_distance(SnapshotComponentType::RocksdbIndices, Some(10)), 777);
1083 }
1084
1085 #[test]
1086 fn archive_urls_for_distance_missing_component() {
1087 let m = test_manifest();
1088 let urls = m.archive_urls_for_distance(SnapshotComponentType::Receipts, None);
1089 assert!(urls.is_empty());
1090 }
1091
1092 #[test]
1093 fn chunks_for_distance_all() {
1094 let m = test_manifest();
1095 assert_eq!(m.chunks_for_distance(SnapshotComponentType::Transactions, None), 3);
1096 }
1097
1098 #[test]
1099 fn chunks_for_distance_partial() {
1100 let m = test_manifest();
1101 assert_eq!(m.chunks_for_distance(SnapshotComponentType::Transactions, Some(600_000)), 2);
1102 assert_eq!(m.chunks_for_distance(SnapshotComponentType::Transactions, Some(100_000)), 1);
1103 }
1104
1105 #[test]
1106 fn chunks_for_distance_single() {
1107 let m = test_manifest();
1108 assert_eq!(m.chunks_for_distance(SnapshotComponentType::State, None), 1);
1109 assert_eq!(m.chunks_for_distance(SnapshotComponentType::State, Some(100)), 1);
1110 }
1111
1112 fn partial_tail_manifest() -> SnapshotManifest {
1116 let mut components = BTreeMap::new();
1117 components.insert(
1118 "transactions".to_string(),
1119 ComponentManifest::Chunked(ChunkedArchive {
1120 blocks_per_file: 500_000,
1121 total_blocks: 1_005_000,
1122 chunk_sizes: vec![10, 20, 30],
1123 chunk_decompressed_sizes: vec![100, 200, 300],
1124 chunk_files: vec![],
1125 chunk_output_files: vec![vec![], vec![], vec![]],
1126 }),
1127 );
1128 SnapshotManifest {
1129 block: 1_005_000,
1130 chain_id: 1,
1131 storage_version: 2,
1132 timestamp: 0,
1133 base_url: Some("https://example.com".to_string()),
1134 reth_version: None,
1135 components,
1136 extensions: Default::default(),
1137 }
1138 }
1139
1140 #[test]
1141 fn tail_chunks_for_distance_accounts_for_partial_final_chunk() {
1142 let chunked = ChunkedArchive {
1143 blocks_per_file: 500_000,
1144 total_blocks: 1_005_000,
1145 chunk_sizes: vec![10, 20, 30],
1146 chunk_decompressed_sizes: vec![],
1147 chunk_files: vec![],
1148 chunk_output_files: vec![],
1149 };
1150 assert_eq!(chunked.tail_chunks_for_distance(10_064), 2);
1153 assert_eq!(chunked.tail_chunks_for_distance(5_000), 1);
1155 assert_eq!(chunked.tail_chunks_for_distance(5_001), 2);
1157 assert_eq!(chunked.tail_chunks_for_distance(1), 1);
1158 assert_eq!(chunked.tail_chunks_for_distance(1_005_000), 3);
1160 assert_eq!(chunked.tail_chunks_for_distance(u64::MAX), 3);
1161 }
1162
1163 #[test]
1164 fn partial_final_chunk_downloads_enough_history() {
1165 let m = partial_tail_manifest();
1166 assert_eq!(m.chunks_for_distance(SnapshotComponentType::Transactions, Some(10_064)), 2);
1170
1171 let urls = m.archive_urls_for_distance(SnapshotComponentType::Transactions, Some(10_064));
1172 assert_eq!(urls.len(), 2);
1173 assert_eq!(urls[0], "https://example.com/transactions-500000-999999.tar.zst");
1174 assert_eq!(urls[1], "https://example.com/transactions-1000000-1499999.tar.zst");
1175
1176 let archives =
1177 m.snapshot_archives_for_distance(SnapshotComponentType::Transactions, Some(10_064));
1178 assert_eq!(archives.len(), 2);
1179
1180 assert_eq!(m.size_for_distance(SnapshotComponentType::Transactions, Some(10_064)), 20 + 30);
1182 assert_eq!(
1183 m.output_size_for_distance(SnapshotComponentType::Transactions, Some(10_064)),
1184 200 + 300
1185 );
1186
1187 assert_eq!(m.chunks_for_distance(SnapshotComponentType::Transactions, Some(5_000)), 1);
1189 }
1190
1191 #[test]
1192 fn chunks_for_distance_missing() {
1193 let m = test_manifest();
1194 assert_eq!(m.chunks_for_distance(SnapshotComponentType::Receipts, None), 0);
1195 }
1196
1197 #[test]
1198 fn component_selection_display() {
1199 assert_eq!(ComponentSelection::All.to_string(), "All");
1200 assert_eq!(ComponentSelection::Distance(10_064).to_string(), "Last 10064 blocks");
1201 assert_eq!(ComponentSelection::Since(15_537_394).to_string(), "Since block 15537394");
1202 assert_eq!(ComponentSelection::None.to_string(), "None");
1203 }
1204
1205 #[test]
1206 fn archive_urls_aligned_to_blocks_per_file() {
1207 let mut components = BTreeMap::new();
1210 components.insert(
1211 "storage_changesets".to_string(),
1212 ComponentManifest::Chunked(ChunkedArchive {
1213 blocks_per_file: 500_000,
1214 total_blocks: 24_396_822,
1215 chunk_sizes: vec![100; 49], chunk_decompressed_sizes: vec![],
1217 chunk_files: vec![],
1218 chunk_output_files: vec![vec![]; 49],
1219 }),
1220 );
1221 let m = SnapshotManifest {
1222 block: 24_396_822,
1223 chain_id: 1,
1224 storage_version: 2,
1225 timestamp: 0,
1226 base_url: Some("https://example.com".to_string()),
1227 reth_version: None,
1228 components,
1229 extensions: Default::default(),
1230 };
1231 let urls = m.archive_urls(SnapshotComponentType::StorageChangesets);
1232 assert_eq!(urls.len(), 49);
1233 assert_eq!(urls[0], "https://example.com/storage_changesets-0-499999.tar.zst");
1235 assert_eq!(urls[48], "https://example.com/storage_changesets-24000000-24499999.tar.zst");
1237 }
1238
1239 #[test]
1240 fn size_for_distance_sums_tail_chunks() {
1241 let m = test_manifest();
1242 assert_eq!(m.size_for_distance(SnapshotComponentType::Transactions, None), 300_000);
1245 assert_eq!(
1247 m.size_for_distance(SnapshotComponentType::Transactions, Some(500_000)),
1248 120_000
1249 );
1250 assert_eq!(
1252 m.size_for_distance(SnapshotComponentType::Transactions, Some(600_000)),
1253 220_000
1254 );
1255 assert_eq!(m.size_for_distance(SnapshotComponentType::State, Some(100)), 100);
1257 assert_eq!(m.size_for_distance(SnapshotComponentType::Receipts, None), 0);
1259 }
1260
1261 #[test]
1262 fn output_size_for_distance_uses_manifest_or_output_files() {
1263 let m = test_manifest();
1264 assert_eq!(m.output_size_for_distance(SnapshotComponentType::Transactions, None), 0);
1265
1266 let mut components = BTreeMap::new();
1267 components.insert(
1268 "state".to_string(),
1269 ComponentManifest::Single(SingleArchive {
1270 file: "state.tar.zst".to_string(),
1271 size: 100,
1272 decompressed_size: 1_000,
1273 blake3: None,
1274 output_files: vec![OutputFileChecksum {
1275 path: "db/mdbx.dat".to_string(),
1276 size: 1_000,
1277 blake3: "h0".to_string(),
1278 }],
1279 }),
1280 );
1281 components.insert(
1282 "transactions".to_string(),
1283 ComponentManifest::Chunked(ChunkedArchive {
1284 blocks_per_file: 500_000,
1285 total_blocks: 1_000_000,
1286 chunk_sizes: vec![80_000, 120_000],
1287 chunk_decompressed_sizes: vec![111, 222],
1288 chunk_files: vec![],
1289 chunk_output_files: vec![
1290 vec![OutputFileChecksum {
1291 path: "static_files/static_file_transactions_0_499999.bin".to_string(),
1292 size: 111,
1293 blake3: "h0".to_string(),
1294 }],
1295 vec![OutputFileChecksum {
1296 path: "static_files/static_file_transactions_500000_999999.bin".to_string(),
1297 size: 222,
1298 blake3: "h1".to_string(),
1299 }],
1300 ],
1301 }),
1302 );
1303 let manifest = SnapshotManifest {
1304 block: 1_000_000,
1305 chain_id: 1,
1306 storage_version: 2,
1307 timestamp: 0,
1308 base_url: Some("https://example.com".to_string()),
1309 reth_version: None,
1310 components,
1311 extensions: Default::default(),
1312 };
1313
1314 assert_eq!(manifest.output_size_for_distance(SnapshotComponentType::State, None), 1_000);
1315 assert_eq!(
1316 manifest.output_size_for_distance(SnapshotComponentType::Transactions, None),
1317 333
1318 );
1319 assert_eq!(
1320 manifest.output_size_for_distance(SnapshotComponentType::Transactions, Some(500_000)),
1321 222
1322 );
1323 }
1324
1325 #[test]
1326 fn archive_descriptors_include_checksum_metadata() {
1327 let mut components = BTreeMap::new();
1328 components.insert(
1329 "state".to_string(),
1330 ComponentManifest::Single(SingleArchive {
1331 file: "state.tar.zst".to_string(),
1332 size: 100,
1333 decompressed_size: 1_000,
1334 blake3: Some("abc123".to_string()),
1335 output_files: vec![OutputFileChecksum {
1336 path: "db/mdbx.dat".to_string(),
1337 size: 1000,
1338 blake3: "s0".to_string(),
1339 }],
1340 }),
1341 );
1342 components.insert(
1343 "transactions".to_string(),
1344 ComponentManifest::Chunked(ChunkedArchive {
1345 blocks_per_file: 500_000,
1346 total_blocks: 1_000_000,
1347 chunk_sizes: vec![80_000, 120_000],
1348 chunk_decompressed_sizes: vec![111, 222],
1349 chunk_files: vec![],
1350 chunk_output_files: vec![
1351 vec![OutputFileChecksum {
1352 path: "static_files/static_file_transactions_0_499999.bin".to_string(),
1353 size: 111,
1354 blake3: "h0".to_string(),
1355 }],
1356 vec![OutputFileChecksum {
1357 path: "static_files/static_file_transactions_500000_999999.bin".to_string(),
1358 size: 222,
1359 blake3: "h1".to_string(),
1360 }],
1361 ],
1362 }),
1363 );
1364
1365 let m = SnapshotManifest {
1366 block: 1_000_000,
1367 chain_id: 1,
1368 storage_version: 2,
1369 timestamp: 0,
1370 base_url: Some("https://example.com".to_string()),
1371 reth_version: None,
1372 components,
1373 extensions: Default::default(),
1374 };
1375
1376 let state = m.snapshot_archives_for_distance(SnapshotComponentType::State, None);
1377 assert_eq!(state.len(), 1);
1378 assert_eq!(state[0].file_name, "state.tar.zst");
1379 assert_eq!(state[0].blake3.as_deref(), Some("abc123"));
1380 assert_eq!(state[0].output_files.len(), 1);
1381
1382 let tx = m.snapshot_archives_for_distance(SnapshotComponentType::Transactions, None);
1383 assert_eq!(tx.len(), 2);
1384 assert_eq!(tx[0].blake3, None);
1385 assert_eq!(tx[1].blake3, None);
1386 assert_eq!(tx[0].output_files[0].size, 111);
1387 }
1388
1389 #[test]
1390 fn generate_manifest_includes_state_single_archive() {
1391 let source = tempdir().unwrap();
1392 let output = tempdir().unwrap();
1393 let db_dir = source.path().join("db");
1394 std::fs::create_dir_all(&db_dir).unwrap();
1395 std::fs::write(db_dir.join("mdbx.dat"), b"state-data").unwrap();
1396
1397 let manifest =
1398 generate_manifest(source.path(), output.path(), None, 0, 1, 500_000).unwrap();
1399
1400 let state = manifest.component(SnapshotComponentType::State).unwrap();
1401 let ComponentManifest::Single(state) = state else {
1402 panic!("state should be a single archive")
1403 };
1404 assert_eq!(state.file, "state.tar.zst");
1405 assert!(state.decompressed_size > 0);
1406 assert!(!state.output_files.is_empty());
1407 assert_eq!(state.output_files[0].path, "db/mdbx.dat");
1408 assert!(output.path().join("state.tar.zst").exists());
1409 }
1410
1411 #[test]
1412 fn generate_manifest_includes_rocksdb_single_archive_when_present() {
1413 let source = tempdir().unwrap();
1414 let output = tempdir().unwrap();
1415 let db_dir = source.path().join("db");
1416 std::fs::create_dir_all(&db_dir).unwrap();
1417 std::fs::write(db_dir.join("mdbx.dat"), b"state-data").unwrap();
1418 let rocksdb_dir = source.path().join("rocksdb");
1419 std::fs::create_dir_all(&rocksdb_dir).unwrap();
1420 std::fs::write(rocksdb_dir.join("CURRENT"), b"MANIFEST-000001").unwrap();
1421
1422 let manifest =
1423 generate_manifest(source.path(), output.path(), None, 0, 1, 500_000).unwrap();
1424
1425 let rocksdb = manifest.component(SnapshotComponentType::RocksdbIndices).unwrap();
1426 let ComponentManifest::Single(rocksdb) = rocksdb else {
1427 panic!("rocksdb indices should be a single archive")
1428 };
1429 assert_eq!(rocksdb.file, "rocksdb_indices.tar.zst");
1430 assert!(rocksdb.decompressed_size > 0);
1431 assert!(!rocksdb.output_files.is_empty());
1432 assert_eq!(rocksdb.output_files[0].path, "rocksdb/CURRENT");
1433 assert!(output.path().join("rocksdb_indices.tar.zst").exists());
1434 }
1435
1436 #[test]
1437 fn resolve_archive_url_joins_nested_paths_under_base() {
1438 assert_eq!(
1439 resolve_archive_url(
1440 "https://example.com/mainnet",
1441 "static_files/headers-0-499999.tar.zst"
1442 ),
1443 "https://example.com/mainnet/static_files/headers-0-499999.tar.zst"
1444 );
1445 assert_eq!(
1446 resolve_archive_url("https://example.com/mainnet/", "1700000/state.tar.zst"),
1447 "https://example.com/mainnet/1700000/state.tar.zst"
1448 );
1449 assert_eq!(
1450 resolve_archive_url("https://example.com/mainnet", "headers-0-499999.tar.zst"),
1451 "https://example.com/mainnet/headers-0-499999.tar.zst"
1452 );
1453 }
1454
1455 #[test]
1456 fn chunk_files_resolve_relative_to_root_base_url() {
1457 let mut components = BTreeMap::new();
1458 components.insert(
1459 "headers".to_string(),
1460 ComponentManifest::Chunked(ChunkedArchive {
1461 blocks_per_file: 500_000,
1462 total_blocks: 1_000_000,
1463 chunk_sizes: vec![40_000, 50_000],
1464 chunk_decompressed_sizes: vec![],
1465 chunk_files: vec![
1466 "static_files/headers-0-499999.tar.zst".to_string(),
1467 "1700000/headers-500000-999999.tar.zst".to_string(),
1468 ],
1469 chunk_output_files: vec![vec![], vec![]],
1470 }),
1471 );
1472 components.insert(
1473 "state".to_string(),
1474 ComponentManifest::Single(SingleArchive {
1475 file: "1700000/state.tar.zst".to_string(),
1476 size: 100,
1477 decompressed_size: 0,
1478 blake3: None,
1479 output_files: vec![],
1480 }),
1481 );
1482
1483 let m = SnapshotManifest {
1484 block: 1_000_000,
1485 chain_id: 1,
1486 storage_version: 2,
1487 timestamp: 1_700_000,
1488 base_url: Some("https://example.com/mainnet".to_string()),
1489 reth_version: None,
1490 components,
1491 extensions: Default::default(),
1492 };
1493
1494 let urls = m.archive_urls(SnapshotComponentType::Headers);
1495 assert_eq!(urls.len(), 2, "exactly 2 header chunk URLs");
1496 assert_eq!(
1497 urls[0], "https://example.com/mainnet/static_files/headers-0-499999.tar.zst",
1498 "finalized chunk stays under static_files/"
1499 );
1500 assert_eq!(
1501 urls[1], "https://example.com/mainnet/1700000/headers-500000-999999.tar.zst",
1502 "tip chunk resolves under the run timestamp directory"
1503 );
1504
1505 let state = m.snapshot_archives_for_distance(SnapshotComponentType::State, None);
1506 assert_eq!(state.len(), 1, "exactly one state archive");
1507 assert_eq!(
1508 state[0].url, "https://example.com/mainnet/1700000/state.tar.zst",
1509 "single archive file path joins under root base_url without ../"
1510 );
1511 assert_eq!(state[0].file_name, "1700000/state.tar.zst");
1512
1513 let headers =
1514 m.snapshot_archives_for_distance(SnapshotComponentType::Headers, Some(500_000));
1515 assert_eq!(headers.len(), 1, "distance selection returns only the tip chunk");
1516 assert_eq!(
1517 headers[0].file_name, "1700000/headers-500000-999999.tar.zst",
1518 "file_name keeps the relative path from chunk_files"
1519 );
1520 assert_eq!(
1521 headers[0].url,
1522 "https://example.com/mainnet/1700000/headers-500000-999999.tar.zst"
1523 );
1524 }
1525
1526 #[test]
1527 fn chunk_files_absent_keeps_default_chunk_names() {
1528 let m = test_manifest();
1529 let archives = m.snapshot_archives_for_distance(SnapshotComponentType::Transactions, None);
1530 assert_eq!(archives.len(), 3);
1531 assert_eq!(archives[0].file_name, "transactions-0-499999.tar.zst");
1532 assert_eq!(archives[0].url, "https://example.com/transactions-0-499999.tar.zst");
1533 }
1534
1535 #[test]
1536 fn resolve_archive_url_normalizes_parent_dirs_and_leading_slashes() {
1537 assert_eq!(
1538 resolve_archive_url("https://example.com/static_files", "../1700000/state.tar.zst"),
1539 "https://example.com/1700000/state.tar.zst",
1540 "../ under a static_files base resolves to the run directory"
1541 );
1542 assert_eq!(
1543 resolve_archive_url(
1544 "https://example.com/mainnet",
1545 "/static_files/headers-0-499999.tar.zst"
1546 ),
1547 "https://example.com/mainnet/static_files/headers-0-499999.tar.zst",
1548 "leading slash is stripped so the base path prefix is preserved"
1549 );
1550 }
1551
1552 #[test]
1553 fn mismatched_chunk_files_length_falls_back_to_default_names() {
1554 let mut components = BTreeMap::new();
1555 components.insert(
1556 "headers".to_string(),
1557 ComponentManifest::Chunked(ChunkedArchive {
1558 blocks_per_file: 500_000,
1559 total_blocks: 1_000_000,
1560 chunk_sizes: vec![40_000, 50_000],
1561 chunk_decompressed_sizes: vec![],
1562 chunk_files: vec!["static_files/headers-0-499999.tar.zst".to_string()],
1564 chunk_output_files: vec![vec![], vec![]],
1565 }),
1566 );
1567 let m = SnapshotManifest {
1568 block: 1_000_000,
1569 chain_id: 1,
1570 storage_version: 2,
1571 timestamp: 0,
1572 base_url: Some("https://example.com/mainnet".to_string()),
1573 reth_version: None,
1574 components,
1575 extensions: Default::default(),
1576 };
1577
1578 let ComponentManifest::Chunked(chunked) =
1579 m.component(SnapshotComponentType::Headers).unwrap()
1580 else {
1581 panic!("headers should be chunked");
1582 };
1583 assert!(!chunked.chunk_files_are_consistent());
1584
1585 let urls = m.archive_urls(SnapshotComponentType::Headers);
1586 assert_eq!(urls.len(), 2);
1587 assert_eq!(
1588 urls[0], "https://example.com/mainnet/headers-0-499999.tar.zst",
1589 "mismatched chunk_files must not partially apply"
1590 );
1591 assert_eq!(urls[1], "https://example.com/mainnet/headers-500000-999999.tar.zst");
1592 }
1593}