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 eyre::ensure!(blocks_per_file > 0, "blocks_per_file must be greater than zero");
594 std::fs::create_dir_all(output_dir)?;
595
596 let mut components = BTreeMap::new();
597
598 for ty in &[
600 SnapshotComponentType::Headers,
601 SnapshotComponentType::Transactions,
602 SnapshotComponentType::TransactionSenders,
603 SnapshotComponentType::Receipts,
604 SnapshotComponentType::AccountChangesets,
605 SnapshotComponentType::StorageChangesets,
606 ] {
607 let key = ty.key();
608 let num_chunks = block.div_ceil(blocks_per_file);
609 let mut planned_chunks = Vec::with_capacity(num_chunks as usize);
610 let mut found_any = false;
611
612 for i in 0..num_chunks {
613 let start = i * blocks_per_file;
614 let end = (i + 1) * blocks_per_file - 1;
615 let source_files = source_files_for_chunk(source_datadir, *ty, start, end)?;
616
617 if source_files.is_empty() {
618 if found_any {
619 eyre::bail!("Missing source files for {} chunk {}-{}", key, start, end);
620 }
621 continue;
622 }
623
624 found_any = true;
625 planned_chunks.push(PlannedChunk {
626 chunk_idx: i,
627 archive_path: output_dir.join(chunk_filename(key, start, end)),
628 source_files,
629 });
630 }
631
632 if found_any {
633 let mut packaged_chunks = planned_chunks
634 .into_par_iter()
635 .map(|planned| -> Result<PackagedChunk> {
636 let output_files =
637 write_chunk_archive(&planned.archive_path, &planned.source_files)?;
638 let size = std::fs::metadata(&planned.archive_path)?.len();
639 Ok(PackagedChunk { chunk_idx: planned.chunk_idx, size, output_files })
640 })
641 .collect::<Vec<_>>()
642 .into_iter()
643 .collect::<Result<Vec<_>>>()?;
644
645 packaged_chunks.sort_unstable_by_key(|chunk| chunk.chunk_idx);
646 let chunk_sizes = packaged_chunks.iter().map(|chunk| chunk.size).collect::<Vec<_>>();
647 let chunk_output_files =
648 packaged_chunks.into_iter().map(|chunk| chunk.output_files).collect::<Vec<_>>();
649 let total_size: u64 = chunk_sizes.iter().sum();
650 info!(target: "reth::cli",
651 component = ty.display_name(),
652 chunks = chunk_sizes.len(),
653 total_blocks = block,
654 size = %super::DownloadProgress::format_size(total_size),
655 "Found chunked component"
656 );
657 components.insert(
658 key.to_string(),
659 ComponentManifest::Chunked(ChunkedArchive {
660 blocks_per_file,
661 total_blocks: block,
662 chunk_sizes,
663 chunk_decompressed_sizes: chunk_output_files
664 .iter()
665 .map(|files| files.iter().map(|file| file.size).sum())
666 .collect(),
667 chunk_files: vec![],
668 chunk_output_files,
669 }),
670 );
671 }
672 }
673
674 let (state_size, state_output_files) = package_single_component(
675 output_dir,
676 "state.tar.zst",
677 &state_source_files(source_datadir)?,
678 )?;
679 components.insert(
680 SnapshotComponentType::State.key().to_string(),
681 ComponentManifest::Single(SingleArchive {
682 file: "state.tar.zst".to_string(),
683 size: state_size,
684 decompressed_size: state_output_files.iter().map(|file| file.size).sum(),
685 blake3: None,
686 output_files: state_output_files,
687 }),
688 );
689
690 let rocksdb_files = rocksdb_source_files(source_datadir)?;
691 if !rocksdb_files.is_empty() {
692 let (rocksdb_size, rocksdb_output_files) =
693 package_single_component(output_dir, "rocksdb_indices.tar.zst", &rocksdb_files)?;
694 components.insert(
695 SnapshotComponentType::RocksdbIndices.key().to_string(),
696 ComponentManifest::Single(SingleArchive {
697 file: "rocksdb_indices.tar.zst".to_string(),
698 size: rocksdb_size,
699 decompressed_size: rocksdb_output_files.iter().map(|file| file.size).sum(),
700 blake3: None,
701 output_files: rocksdb_output_files,
702 }),
703 );
704 }
705
706 let timestamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs();
707
708 Ok(SnapshotManifest {
709 block,
710 chain_id,
711 storage_version: 2,
712 timestamp,
713 base_url: base_url.map(str::to_owned),
714 reth_version: Some(reth_node_core::version::version_metadata().short_version.to_string()),
715 components,
716 extensions: Default::default(),
717 })
718}
719
720pub fn chunk_filename(component_key: &str, start: u64, end: u64) -> String {
722 format!("{component_key}-{start}-{end}.tar.zst")
723}
724
725#[derive(Debug)]
726struct PlannedChunk {
727 chunk_idx: u64,
728 archive_path: PathBuf,
729 source_files: Vec<PathBuf>,
730}
731
732#[derive(Debug)]
733struct PackagedChunk {
734 chunk_idx: u64,
735 size: u64,
736 output_files: Vec<OutputFileChecksum>,
737}
738
739#[derive(Debug)]
740struct PlannedFile {
741 source_path: PathBuf,
742 relative_path: PathBuf,
743}
744
745fn source_files_for_chunk(
746 source_datadir: &Path,
747 component: SnapshotComponentType,
748 start: u64,
749 end: u64,
750) -> Result<Vec<PathBuf>> {
751 let Some(segment_name) = static_segment_name(component) else {
752 return Ok(Vec::new());
753 };
754
755 let static_files_dir = source_datadir.join("static_files");
756 let static_files_dir =
757 if static_files_dir.exists() { static_files_dir } else { source_datadir.to_path_buf() };
758 let prefix = format!("static_file_{segment_name}_{start}_{end}");
759
760 let mut files = Vec::new();
761 for entry in std::fs::read_dir(&static_files_dir)? {
762 let entry = entry?;
763 if !entry.file_type()?.is_file() {
764 continue;
765 }
766 if entry.file_name().to_string_lossy().starts_with(&prefix) {
767 files.push(entry.path());
768 }
769 }
770
771 files.sort_unstable();
772 Ok(files)
773}
774
775fn static_segment_name(component: SnapshotComponentType) -> Option<&'static str> {
776 match component {
777 SnapshotComponentType::Headers => Some("headers"),
778 SnapshotComponentType::Transactions => Some("transactions"),
779 SnapshotComponentType::TransactionSenders => Some("transaction-senders"),
780 SnapshotComponentType::Receipts => Some("receipts"),
781 SnapshotComponentType::AccountChangesets => Some("account-change-sets"),
782 SnapshotComponentType::StorageChangesets => Some("storage-change-sets"),
783 SnapshotComponentType::State | SnapshotComponentType::RocksdbIndices => None,
784 }
785}
786
787fn state_source_files(source_datadir: &Path) -> Result<Vec<PlannedFile>> {
788 let db_dir = source_datadir.join("db");
789 if db_dir.exists() {
790 return collect_files_recursive(&db_dir, Path::new("db"));
791 }
792
793 if looks_like_db_dir(source_datadir)? {
794 return collect_files_recursive(source_datadir, Path::new("db"));
795 }
796
797 eyre::bail!("Could not find source state DB directory under {}", source_datadir.display());
798}
799
800fn rocksdb_source_files(source_datadir: &Path) -> Result<Vec<PlannedFile>> {
801 let rocksdb_dir = source_datadir.join("rocksdb");
802 if !rocksdb_dir.exists() {
803 return Ok(Vec::new());
804 }
805
806 collect_files_recursive(&rocksdb_dir, Path::new("rocksdb"))
807}
808
809fn looks_like_db_dir(path: &Path) -> Result<bool> {
810 let entries = match std::fs::read_dir(path) {
811 Ok(entries) => entries,
812 Err(_) => return Ok(false),
813 };
814
815 for entry in entries {
816 let entry = entry?;
817 if !entry.file_type()?.is_file() {
818 continue;
819 }
820 let name = entry.file_name();
821 let name = name.to_string_lossy();
822 if name == "mdbx.dat" || name == "lock.mdb" || name == "data.mdb" {
823 return Ok(true);
824 }
825 }
826
827 Ok(false)
828}
829
830fn collect_files_recursive(root: &Path, output_prefix: &Path) -> Result<Vec<PlannedFile>> {
831 let mut files = Vec::new();
832 collect_files_recursive_inner(root, root, output_prefix, &mut files)?;
833 files.sort_unstable_by(|a, b| a.relative_path.cmp(&b.relative_path));
834 Ok(files)
835}
836
837fn collect_files_recursive_inner(
838 root: &Path,
839 dir: &Path,
840 output_prefix: &Path,
841 files: &mut Vec<PlannedFile>,
842) -> Result<()> {
843 for entry in std::fs::read_dir(dir)? {
844 let entry = entry?;
845 let path = entry.path();
846 let file_type = entry.file_type()?;
847 if file_type.is_dir() {
848 collect_files_recursive_inner(root, &path, output_prefix, files)?;
849 continue;
850 }
851 if !file_type.is_file() {
852 continue;
853 }
854
855 let relative = path.strip_prefix(root)?.to_path_buf();
856 files.push(PlannedFile { source_path: path, relative_path: output_prefix.join(relative) });
857 }
858
859 Ok(())
860}
861
862fn package_single_component(
863 output_dir: &Path,
864 archive_file_name: &str,
865 files: &[PlannedFile],
866) -> Result<(u64, Vec<OutputFileChecksum>)> {
867 if files.is_empty() {
868 eyre::bail!("Cannot package empty single archive: {}", archive_file_name);
869 }
870
871 let archive_path = output_dir.join(archive_file_name);
872 let output_files = write_archive_from_planned_files(&archive_path, files)?;
873 let size = std::fs::metadata(&archive_path)?.len();
874 Ok((size, output_files))
875}
876
877fn write_chunk_archive(path: &Path, source_files: &[PathBuf]) -> Result<Vec<OutputFileChecksum>> {
878 let planned_files = source_files
879 .iter()
880 .map(|source_path| {
881 let file_name = source_path.file_name().ok_or_else(|| {
882 eyre::eyre!("Invalid source file path: {}", source_path.display())
883 })?;
884 Ok::<_, eyre::Error>(PlannedFile {
885 source_path: source_path.clone(),
886 relative_path: PathBuf::from("static_files").join(file_name),
887 })
888 })
889 .collect::<Result<Vec<_>>>()?;
890
891 write_archive_from_planned_files(path, &planned_files)
892}
893
894fn write_archive_from_planned_files(
895 path: &Path,
896 files: &[PlannedFile],
897) -> Result<Vec<OutputFileChecksum>> {
898 let file = std::fs::File::create(path)?;
899 let mut encoder = zstd::Encoder::new(file, 0)?;
900 encoder.include_checksum(true)?;
903 let mut builder = tar::Builder::new(encoder);
904
905 let mut output_files = Vec::with_capacity(files.len());
906 for planned in files {
907 let mut header = tar::Header::new_gnu();
908 header.set_size(std::fs::metadata(&planned.source_path)?.len());
909 header.set_mode(0o644);
910 header.set_cksum();
911
912 let source_file = std::fs::File::open(&planned.source_path)?;
913 let mut reader = HashingReader::new(source_file);
914 builder.append_data(&mut header, &planned.relative_path, &mut reader)?;
915
916 output_files.push(OutputFileChecksum {
917 path: planned.relative_path.to_string_lossy().to_string(),
918 size: reader.bytes_read,
919 blake3: reader.finalize(),
920 });
921 }
922
923 builder.finish()?;
924 let encoder = builder.into_inner()?;
925 encoder.finish()?;
926
927 Ok(output_files)
928}
929
930struct HashingReader<R> {
931 inner: R,
932 hasher: Hasher,
933 bytes_read: u64,
934}
935
936impl<R: Read> HashingReader<R> {
937 fn new(inner: R) -> Self {
938 Self { inner, hasher: Hasher::new(), bytes_read: 0 }
939 }
940
941 fn finalize(self) -> String {
942 self.hasher.finalize().to_hex().to_string()
943 }
944}
945
946impl<R: Read> Read for HashingReader<R> {
947 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
948 let n = self.inner.read(buf)?;
949 if n > 0 {
950 self.bytes_read += n as u64;
951 self.hasher.update(&buf[..n]);
952 }
953 Ok(n)
954 }
955}
956
957#[cfg(test)]
958mod tests {
959 use super::*;
960 use tempfile::tempdir;
961
962 fn test_manifest() -> SnapshotManifest {
963 let mut components = BTreeMap::new();
964 components.insert(
965 "state".to_string(),
966 ComponentManifest::Single(SingleArchive {
967 file: "state.tar.zst".to_string(),
968 size: 100,
969 decompressed_size: 0,
970 blake3: None,
971 output_files: vec![],
972 }),
973 );
974 components.insert(
975 "transactions".to_string(),
976 ComponentManifest::Chunked(ChunkedArchive {
977 blocks_per_file: 500_000,
978 total_blocks: 1_500_000,
979 chunk_sizes: vec![80_000, 100_000, 120_000],
980 chunk_decompressed_sizes: vec![],
981 chunk_files: vec![],
982 chunk_output_files: vec![vec![], vec![], vec![]],
983 }),
984 );
985 components.insert(
986 "headers".to_string(),
987 ComponentManifest::Chunked(ChunkedArchive {
988 blocks_per_file: 500_000,
989 total_blocks: 1_500_000,
990 chunk_sizes: vec![40_000, 50_000, 60_000],
991 chunk_decompressed_sizes: vec![],
992 chunk_files: vec![],
993 chunk_output_files: vec![vec![], vec![], vec![]],
994 }),
995 );
996 SnapshotManifest {
997 block: 1_500_000,
998 chain_id: 1,
999 storage_version: 2,
1000 timestamp: 0,
1001 base_url: Some("https://example.com".to_string()),
1002 reth_version: None,
1003 components,
1004 extensions: Default::default(),
1005 }
1006 }
1007
1008 #[test]
1009 fn manifest_preserves_extensions() {
1010 let manifest: SnapshotManifest = serde_json::from_str(
1011 r#"{
1012 "block": 1,
1013 "chain_id": 1,
1014 "storage_version": 2,
1015 "timestamp": 0,
1016 "components": {},
1017 "consensus": { "archive": "consensus.tar.zst" }
1018 }"#,
1019 )
1020 .unwrap();
1021
1022 assert_eq!(
1023 manifest.extensions.get("consensus"),
1024 Some(&serde_json::json!({ "archive": "consensus.tar.zst" }))
1025 );
1026 }
1027
1028 #[test]
1029 fn archive_urls_for_distance_all() {
1030 let m = test_manifest();
1031 let urls = m.archive_urls_for_distance(SnapshotComponentType::Transactions, None);
1032 assert_eq!(urls.len(), 3);
1033 assert_eq!(urls[0], "https://example.com/transactions-0-499999.tar.zst");
1034 assert_eq!(urls[2], "https://example.com/transactions-1000000-1499999.tar.zst");
1035 }
1036
1037 #[test]
1038 fn archive_urls_for_distance_partial() {
1039 let m = test_manifest();
1040 let urls = m.archive_urls_for_distance(SnapshotComponentType::Transactions, Some(600_000));
1042 assert_eq!(urls.len(), 2);
1043 assert_eq!(urls[0], "https://example.com/transactions-500000-999999.tar.zst");
1044 assert_eq!(urls[1], "https://example.com/transactions-1000000-1499999.tar.zst");
1045 }
1046
1047 #[test]
1048 fn archive_urls_for_distance_single_component() {
1049 let m = test_manifest();
1050 let urls = m.archive_urls_for_distance(SnapshotComponentType::State, Some(100));
1052 assert_eq!(urls.len(), 1);
1053 assert_eq!(urls[0], "https://example.com/state.tar.zst");
1054 }
1055
1056 #[test]
1057 fn archive_urls_for_distance_rocksdb_indices_single_component() {
1058 let mut components = BTreeMap::new();
1059 components.insert(
1060 "rocksdb_indices".to_string(),
1061 ComponentManifest::Single(SingleArchive {
1062 file: "rocksdb_indices.tar.zst".to_string(),
1063 size: 777,
1064 decompressed_size: 0,
1065 blake3: None,
1066 output_files: vec![],
1067 }),
1068 );
1069 let m = SnapshotManifest {
1070 block: 1,
1071 chain_id: 1,
1072 storage_version: 2,
1073 timestamp: 0,
1074 base_url: Some("https://example.com".to_string()),
1075 reth_version: None,
1076 components,
1077 extensions: Default::default(),
1078 };
1079
1080 let urls = m.archive_urls_for_distance(SnapshotComponentType::RocksdbIndices, Some(10));
1081 assert_eq!(urls.len(), 1);
1082 assert_eq!(urls[0], "https://example.com/rocksdb_indices.tar.zst");
1083 assert_eq!(m.size_for_distance(SnapshotComponentType::RocksdbIndices, Some(10)), 777);
1084 }
1085
1086 #[test]
1087 fn archive_urls_for_distance_missing_component() {
1088 let m = test_manifest();
1089 let urls = m.archive_urls_for_distance(SnapshotComponentType::Receipts, None);
1090 assert!(urls.is_empty());
1091 }
1092
1093 #[test]
1094 fn chunks_for_distance_all() {
1095 let m = test_manifest();
1096 assert_eq!(m.chunks_for_distance(SnapshotComponentType::Transactions, None), 3);
1097 }
1098
1099 #[test]
1100 fn chunks_for_distance_partial() {
1101 let m = test_manifest();
1102 assert_eq!(m.chunks_for_distance(SnapshotComponentType::Transactions, Some(600_000)), 2);
1103 assert_eq!(m.chunks_for_distance(SnapshotComponentType::Transactions, Some(100_000)), 1);
1104 }
1105
1106 #[test]
1107 fn chunks_for_distance_single() {
1108 let m = test_manifest();
1109 assert_eq!(m.chunks_for_distance(SnapshotComponentType::State, None), 1);
1110 assert_eq!(m.chunks_for_distance(SnapshotComponentType::State, Some(100)), 1);
1111 }
1112
1113 fn partial_tail_manifest() -> SnapshotManifest {
1117 let mut components = BTreeMap::new();
1118 components.insert(
1119 "transactions".to_string(),
1120 ComponentManifest::Chunked(ChunkedArchive {
1121 blocks_per_file: 500_000,
1122 total_blocks: 1_005_000,
1123 chunk_sizes: vec![10, 20, 30],
1124 chunk_decompressed_sizes: vec![100, 200, 300],
1125 chunk_files: vec![],
1126 chunk_output_files: vec![vec![], vec![], vec![]],
1127 }),
1128 );
1129 SnapshotManifest {
1130 block: 1_005_000,
1131 chain_id: 1,
1132 storage_version: 2,
1133 timestamp: 0,
1134 base_url: Some("https://example.com".to_string()),
1135 reth_version: None,
1136 components,
1137 extensions: Default::default(),
1138 }
1139 }
1140
1141 #[test]
1142 fn tail_chunks_for_distance_accounts_for_partial_final_chunk() {
1143 let chunked = ChunkedArchive {
1144 blocks_per_file: 500_000,
1145 total_blocks: 1_005_000,
1146 chunk_sizes: vec![10, 20, 30],
1147 chunk_decompressed_sizes: vec![],
1148 chunk_files: vec![],
1149 chunk_output_files: vec![],
1150 };
1151 assert_eq!(chunked.tail_chunks_for_distance(10_064), 2);
1154 assert_eq!(chunked.tail_chunks_for_distance(5_000), 1);
1156 assert_eq!(chunked.tail_chunks_for_distance(5_001), 2);
1158 assert_eq!(chunked.tail_chunks_for_distance(1), 1);
1159 assert_eq!(chunked.tail_chunks_for_distance(1_005_000), 3);
1161 assert_eq!(chunked.tail_chunks_for_distance(u64::MAX), 3);
1162 }
1163
1164 #[test]
1165 fn partial_final_chunk_downloads_enough_history() {
1166 let m = partial_tail_manifest();
1167 assert_eq!(m.chunks_for_distance(SnapshotComponentType::Transactions, Some(10_064)), 2);
1171
1172 let urls = m.archive_urls_for_distance(SnapshotComponentType::Transactions, Some(10_064));
1173 assert_eq!(urls.len(), 2);
1174 assert_eq!(urls[0], "https://example.com/transactions-500000-999999.tar.zst");
1175 assert_eq!(urls[1], "https://example.com/transactions-1000000-1499999.tar.zst");
1176
1177 let archives =
1178 m.snapshot_archives_for_distance(SnapshotComponentType::Transactions, Some(10_064));
1179 assert_eq!(archives.len(), 2);
1180
1181 assert_eq!(m.size_for_distance(SnapshotComponentType::Transactions, Some(10_064)), 20 + 30);
1183 assert_eq!(
1184 m.output_size_for_distance(SnapshotComponentType::Transactions, Some(10_064)),
1185 200 + 300
1186 );
1187
1188 assert_eq!(m.chunks_for_distance(SnapshotComponentType::Transactions, Some(5_000)), 1);
1190 }
1191
1192 #[test]
1193 fn chunks_for_distance_missing() {
1194 let m = test_manifest();
1195 assert_eq!(m.chunks_for_distance(SnapshotComponentType::Receipts, None), 0);
1196 }
1197
1198 #[test]
1199 fn component_selection_display() {
1200 assert_eq!(ComponentSelection::All.to_string(), "All");
1201 assert_eq!(ComponentSelection::Distance(10_064).to_string(), "Last 10064 blocks");
1202 assert_eq!(ComponentSelection::Since(15_537_394).to_string(), "Since block 15537394");
1203 assert_eq!(ComponentSelection::None.to_string(), "None");
1204 }
1205
1206 #[test]
1207 fn archive_urls_aligned_to_blocks_per_file() {
1208 let mut components = BTreeMap::new();
1211 components.insert(
1212 "storage_changesets".to_string(),
1213 ComponentManifest::Chunked(ChunkedArchive {
1214 blocks_per_file: 500_000,
1215 total_blocks: 24_396_822,
1216 chunk_sizes: vec![100; 49], chunk_decompressed_sizes: vec![],
1218 chunk_files: vec![],
1219 chunk_output_files: vec![vec![]; 49],
1220 }),
1221 );
1222 let m = SnapshotManifest {
1223 block: 24_396_822,
1224 chain_id: 1,
1225 storage_version: 2,
1226 timestamp: 0,
1227 base_url: Some("https://example.com".to_string()),
1228 reth_version: None,
1229 components,
1230 extensions: Default::default(),
1231 };
1232 let urls = m.archive_urls(SnapshotComponentType::StorageChangesets);
1233 assert_eq!(urls.len(), 49);
1234 assert_eq!(urls[0], "https://example.com/storage_changesets-0-499999.tar.zst");
1236 assert_eq!(urls[48], "https://example.com/storage_changesets-24000000-24499999.tar.zst");
1238 }
1239
1240 #[test]
1241 fn size_for_distance_sums_tail_chunks() {
1242 let m = test_manifest();
1243 assert_eq!(m.size_for_distance(SnapshotComponentType::Transactions, None), 300_000);
1246 assert_eq!(
1248 m.size_for_distance(SnapshotComponentType::Transactions, Some(500_000)),
1249 120_000
1250 );
1251 assert_eq!(
1253 m.size_for_distance(SnapshotComponentType::Transactions, Some(600_000)),
1254 220_000
1255 );
1256 assert_eq!(m.size_for_distance(SnapshotComponentType::State, Some(100)), 100);
1258 assert_eq!(m.size_for_distance(SnapshotComponentType::Receipts, None), 0);
1260 }
1261
1262 #[test]
1263 fn output_size_for_distance_uses_manifest_or_output_files() {
1264 let m = test_manifest();
1265 assert_eq!(m.output_size_for_distance(SnapshotComponentType::Transactions, None), 0);
1266
1267 let mut components = BTreeMap::new();
1268 components.insert(
1269 "state".to_string(),
1270 ComponentManifest::Single(SingleArchive {
1271 file: "state.tar.zst".to_string(),
1272 size: 100,
1273 decompressed_size: 1_000,
1274 blake3: None,
1275 output_files: vec![OutputFileChecksum {
1276 path: "db/mdbx.dat".to_string(),
1277 size: 1_000,
1278 blake3: "h0".to_string(),
1279 }],
1280 }),
1281 );
1282 components.insert(
1283 "transactions".to_string(),
1284 ComponentManifest::Chunked(ChunkedArchive {
1285 blocks_per_file: 500_000,
1286 total_blocks: 1_000_000,
1287 chunk_sizes: vec![80_000, 120_000],
1288 chunk_decompressed_sizes: vec![111, 222],
1289 chunk_files: vec![],
1290 chunk_output_files: vec![
1291 vec![OutputFileChecksum {
1292 path: "static_files/static_file_transactions_0_499999.bin".to_string(),
1293 size: 111,
1294 blake3: "h0".to_string(),
1295 }],
1296 vec![OutputFileChecksum {
1297 path: "static_files/static_file_transactions_500000_999999.bin".to_string(),
1298 size: 222,
1299 blake3: "h1".to_string(),
1300 }],
1301 ],
1302 }),
1303 );
1304 let manifest = SnapshotManifest {
1305 block: 1_000_000,
1306 chain_id: 1,
1307 storage_version: 2,
1308 timestamp: 0,
1309 base_url: Some("https://example.com".to_string()),
1310 reth_version: None,
1311 components,
1312 extensions: Default::default(),
1313 };
1314
1315 assert_eq!(manifest.output_size_for_distance(SnapshotComponentType::State, None), 1_000);
1316 assert_eq!(
1317 manifest.output_size_for_distance(SnapshotComponentType::Transactions, None),
1318 333
1319 );
1320 assert_eq!(
1321 manifest.output_size_for_distance(SnapshotComponentType::Transactions, Some(500_000)),
1322 222
1323 );
1324 }
1325
1326 #[test]
1327 fn archive_descriptors_include_checksum_metadata() {
1328 let mut components = BTreeMap::new();
1329 components.insert(
1330 "state".to_string(),
1331 ComponentManifest::Single(SingleArchive {
1332 file: "state.tar.zst".to_string(),
1333 size: 100,
1334 decompressed_size: 1_000,
1335 blake3: Some("abc123".to_string()),
1336 output_files: vec![OutputFileChecksum {
1337 path: "db/mdbx.dat".to_string(),
1338 size: 1000,
1339 blake3: "s0".to_string(),
1340 }],
1341 }),
1342 );
1343 components.insert(
1344 "transactions".to_string(),
1345 ComponentManifest::Chunked(ChunkedArchive {
1346 blocks_per_file: 500_000,
1347 total_blocks: 1_000_000,
1348 chunk_sizes: vec![80_000, 120_000],
1349 chunk_decompressed_sizes: vec![111, 222],
1350 chunk_files: vec![],
1351 chunk_output_files: vec![
1352 vec![OutputFileChecksum {
1353 path: "static_files/static_file_transactions_0_499999.bin".to_string(),
1354 size: 111,
1355 blake3: "h0".to_string(),
1356 }],
1357 vec![OutputFileChecksum {
1358 path: "static_files/static_file_transactions_500000_999999.bin".to_string(),
1359 size: 222,
1360 blake3: "h1".to_string(),
1361 }],
1362 ],
1363 }),
1364 );
1365
1366 let m = SnapshotManifest {
1367 block: 1_000_000,
1368 chain_id: 1,
1369 storage_version: 2,
1370 timestamp: 0,
1371 base_url: Some("https://example.com".to_string()),
1372 reth_version: None,
1373 components,
1374 extensions: Default::default(),
1375 };
1376
1377 let state = m.snapshot_archives_for_distance(SnapshotComponentType::State, None);
1378 assert_eq!(state.len(), 1);
1379 assert_eq!(state[0].file_name, "state.tar.zst");
1380 assert_eq!(state[0].blake3.as_deref(), Some("abc123"));
1381 assert_eq!(state[0].output_files.len(), 1);
1382
1383 let tx = m.snapshot_archives_for_distance(SnapshotComponentType::Transactions, None);
1384 assert_eq!(tx.len(), 2);
1385 assert_eq!(tx[0].blake3, None);
1386 assert_eq!(tx[1].blake3, None);
1387 assert_eq!(tx[0].output_files[0].size, 111);
1388 }
1389
1390 #[test]
1391 fn generate_manifest_includes_state_single_archive() {
1392 let source = tempdir().unwrap();
1393 let output = tempdir().unwrap();
1394 let db_dir = source.path().join("db");
1395 std::fs::create_dir_all(&db_dir).unwrap();
1396 std::fs::write(db_dir.join("mdbx.dat"), b"state-data").unwrap();
1397
1398 let manifest =
1399 generate_manifest(source.path(), output.path(), None, 0, 1, 500_000).unwrap();
1400
1401 let state = manifest.component(SnapshotComponentType::State).unwrap();
1402 let ComponentManifest::Single(state) = state else {
1403 panic!("state should be a single archive")
1404 };
1405 assert_eq!(state.file, "state.tar.zst");
1406 assert!(state.decompressed_size > 0);
1407 assert!(!state.output_files.is_empty());
1408 assert_eq!(state.output_files[0].path, "db/mdbx.dat");
1409 assert!(output.path().join("state.tar.zst").exists());
1410 }
1411
1412 #[test]
1413 fn generate_manifest_includes_rocksdb_single_archive_when_present() {
1414 let source = tempdir().unwrap();
1415 let output = tempdir().unwrap();
1416 let db_dir = source.path().join("db");
1417 std::fs::create_dir_all(&db_dir).unwrap();
1418 std::fs::write(db_dir.join("mdbx.dat"), b"state-data").unwrap();
1419 let rocksdb_dir = source.path().join("rocksdb");
1420 std::fs::create_dir_all(&rocksdb_dir).unwrap();
1421 std::fs::write(rocksdb_dir.join("CURRENT"), b"MANIFEST-000001").unwrap();
1422
1423 let manifest =
1424 generate_manifest(source.path(), output.path(), None, 0, 1, 500_000).unwrap();
1425
1426 let rocksdb = manifest.component(SnapshotComponentType::RocksdbIndices).unwrap();
1427 let ComponentManifest::Single(rocksdb) = rocksdb else {
1428 panic!("rocksdb indices should be a single archive")
1429 };
1430 assert_eq!(rocksdb.file, "rocksdb_indices.tar.zst");
1431 assert!(rocksdb.decompressed_size > 0);
1432 assert!(!rocksdb.output_files.is_empty());
1433 assert_eq!(rocksdb.output_files[0].path, "rocksdb/CURRENT");
1434 assert!(output.path().join("rocksdb_indices.tar.zst").exists());
1435 }
1436
1437 #[test]
1438 fn resolve_archive_url_joins_nested_paths_under_base() {
1439 assert_eq!(
1440 resolve_archive_url(
1441 "https://example.com/mainnet",
1442 "static_files/headers-0-499999.tar.zst"
1443 ),
1444 "https://example.com/mainnet/static_files/headers-0-499999.tar.zst"
1445 );
1446 assert_eq!(
1447 resolve_archive_url("https://example.com/mainnet/", "1700000/state.tar.zst"),
1448 "https://example.com/mainnet/1700000/state.tar.zst"
1449 );
1450 assert_eq!(
1451 resolve_archive_url("https://example.com/mainnet", "headers-0-499999.tar.zst"),
1452 "https://example.com/mainnet/headers-0-499999.tar.zst"
1453 );
1454 }
1455
1456 #[test]
1457 fn chunk_files_resolve_relative_to_root_base_url() {
1458 let mut components = BTreeMap::new();
1459 components.insert(
1460 "headers".to_string(),
1461 ComponentManifest::Chunked(ChunkedArchive {
1462 blocks_per_file: 500_000,
1463 total_blocks: 1_000_000,
1464 chunk_sizes: vec![40_000, 50_000],
1465 chunk_decompressed_sizes: vec![],
1466 chunk_files: vec![
1467 "static_files/headers-0-499999.tar.zst".to_string(),
1468 "1700000/headers-500000-999999.tar.zst".to_string(),
1469 ],
1470 chunk_output_files: vec![vec![], vec![]],
1471 }),
1472 );
1473 components.insert(
1474 "state".to_string(),
1475 ComponentManifest::Single(SingleArchive {
1476 file: "1700000/state.tar.zst".to_string(),
1477 size: 100,
1478 decompressed_size: 0,
1479 blake3: None,
1480 output_files: vec![],
1481 }),
1482 );
1483
1484 let m = SnapshotManifest {
1485 block: 1_000_000,
1486 chain_id: 1,
1487 storage_version: 2,
1488 timestamp: 1_700_000,
1489 base_url: Some("https://example.com/mainnet".to_string()),
1490 reth_version: None,
1491 components,
1492 extensions: Default::default(),
1493 };
1494
1495 let urls = m.archive_urls(SnapshotComponentType::Headers);
1496 assert_eq!(urls.len(), 2, "exactly 2 header chunk URLs");
1497 assert_eq!(
1498 urls[0], "https://example.com/mainnet/static_files/headers-0-499999.tar.zst",
1499 "finalized chunk stays under static_files/"
1500 );
1501 assert_eq!(
1502 urls[1], "https://example.com/mainnet/1700000/headers-500000-999999.tar.zst",
1503 "tip chunk resolves under the run timestamp directory"
1504 );
1505
1506 let state = m.snapshot_archives_for_distance(SnapshotComponentType::State, None);
1507 assert_eq!(state.len(), 1, "exactly one state archive");
1508 assert_eq!(
1509 state[0].url, "https://example.com/mainnet/1700000/state.tar.zst",
1510 "single archive file path joins under root base_url without ../"
1511 );
1512 assert_eq!(state[0].file_name, "1700000/state.tar.zst");
1513
1514 let headers =
1515 m.snapshot_archives_for_distance(SnapshotComponentType::Headers, Some(500_000));
1516 assert_eq!(headers.len(), 1, "distance selection returns only the tip chunk");
1517 assert_eq!(
1518 headers[0].file_name, "1700000/headers-500000-999999.tar.zst",
1519 "file_name keeps the relative path from chunk_files"
1520 );
1521 assert_eq!(
1522 headers[0].url,
1523 "https://example.com/mainnet/1700000/headers-500000-999999.tar.zst"
1524 );
1525 }
1526
1527 #[test]
1528 fn chunk_files_absent_keeps_default_chunk_names() {
1529 let m = test_manifest();
1530 let archives = m.snapshot_archives_for_distance(SnapshotComponentType::Transactions, None);
1531 assert_eq!(archives.len(), 3);
1532 assert_eq!(archives[0].file_name, "transactions-0-499999.tar.zst");
1533 assert_eq!(archives[0].url, "https://example.com/transactions-0-499999.tar.zst");
1534 }
1535
1536 #[test]
1537 fn resolve_archive_url_normalizes_parent_dirs_and_leading_slashes() {
1538 assert_eq!(
1539 resolve_archive_url("https://example.com/static_files", "../1700000/state.tar.zst"),
1540 "https://example.com/1700000/state.tar.zst",
1541 "../ under a static_files base resolves to the run directory"
1542 );
1543 assert_eq!(
1544 resolve_archive_url(
1545 "https://example.com/mainnet",
1546 "/static_files/headers-0-499999.tar.zst"
1547 ),
1548 "https://example.com/mainnet/static_files/headers-0-499999.tar.zst",
1549 "leading slash is stripped so the base path prefix is preserved"
1550 );
1551 }
1552
1553 #[test]
1554 fn mismatched_chunk_files_length_falls_back_to_default_names() {
1555 let mut components = BTreeMap::new();
1556 components.insert(
1557 "headers".to_string(),
1558 ComponentManifest::Chunked(ChunkedArchive {
1559 blocks_per_file: 500_000,
1560 total_blocks: 1_000_000,
1561 chunk_sizes: vec![40_000, 50_000],
1562 chunk_decompressed_sizes: vec![],
1563 chunk_files: vec!["static_files/headers-0-499999.tar.zst".to_string()],
1565 chunk_output_files: vec![vec![], vec![]],
1566 }),
1567 );
1568 let m = SnapshotManifest {
1569 block: 1_000_000,
1570 chain_id: 1,
1571 storage_version: 2,
1572 timestamp: 0,
1573 base_url: Some("https://example.com/mainnet".to_string()),
1574 reth_version: None,
1575 components,
1576 extensions: Default::default(),
1577 };
1578
1579 let ComponentManifest::Chunked(chunked) =
1580 m.component(SnapshotComponentType::Headers).unwrap()
1581 else {
1582 panic!("headers should be chunked");
1583 };
1584 assert!(!chunked.chunk_files_are_consistent());
1585
1586 let urls = m.archive_urls(SnapshotComponentType::Headers);
1587 assert_eq!(urls.len(), 2);
1588 assert_eq!(
1589 urls[0], "https://example.com/mainnet/headers-0-499999.tar.zst",
1590 "mismatched chunk_files must not partially apply"
1591 );
1592 assert_eq!(urls[1], "https://example.com/mainnet/headers-500000-999999.tar.zst");
1593 }
1594}