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