1use blake3::Hasher;
2use eyre::{Result, WrapErr};
3use rayon::prelude::*;
4use reqwest::Client;
5use reth_db::static_file::iter_static_files;
6use reth_static_file_types::StaticFileSegment;
7use serde::{Deserialize, Serialize};
8use std::{
9 collections::BTreeMap,
10 io::Read,
11 path::{Component, Path, PathBuf},
12};
13use tracing::info;
14use url::Url;
15
16fn is_zero(value: &u64) -> bool {
17 *value == 0
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct SnapshotManifest {
40 pub block: u64,
42 pub chain_id: u64,
44 pub storage_version: u64,
46 pub timestamp: u64,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub base_url: Option<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub reth_version: Option<String>,
56 pub components: BTreeMap<String, ComponentManifest>,
58 #[serde(default, flatten)]
63 pub extensions: BTreeMap<String, serde_json::Value>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(untagged)]
69pub enum ComponentManifest {
70 Single(SingleArchive),
72 Chunked(ChunkedArchive),
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct SingleArchive {
79 pub file: String,
84 pub size: u64,
86 #[serde(default, skip_serializing_if = "is_zero")]
91 pub decompressed_size: u64,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub blake3: Option<String>,
95 #[serde(default)]
99 pub output_files: Vec<OutputFileChecksum>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ChunkedArchive {
105 pub blocks_per_file: u64,
107 pub total_blocks: u64,
113 #[serde(default)]
116 pub chunk_sizes: Vec<u64>,
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
122 pub chunk_decompressed_sizes: Vec<u64>,
123 #[serde(default, skip_serializing_if = "Vec::is_empty")]
135 pub chunk_files: Vec<String>,
136 #[serde(default)]
140 pub chunk_output_files: Vec<Vec<OutputFileChecksum>>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct OutputFileChecksum {
146 pub path: String,
148 pub size: u64,
150 pub blake3: String,
152}
153
154impl OutputFileChecksum {
155 pub(crate) fn relative_path(&self) -> Result<&Path> {
161 let path = Path::new(&self.path);
162 eyre::ensure!(
163 path.components().all(|part| matches!(part, Component::Normal(_) | Component::CurDir)) &&
164 path.components().any(|part| matches!(part, Component::Normal(_))),
165 "Invalid manifest output file path `{}`: must be a relative path without `..`",
166 self.path
167 );
168 Ok(path)
169 }
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct SnapshotArchive {
175 pub url: String,
176 pub file_name: String,
177 pub size: u64,
178 pub blake3: Option<String>,
179 pub output_files: Vec<OutputFileChecksum>,
180}
181
182impl SnapshotArchive {
183 pub fn output_size(&self) -> u64 {
185 self.output_files.iter().map(|file| file.size).sum()
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum ComponentSelection {
192 All,
194 Distance(u64),
197 Since(u64),
200 None,
203}
204
205impl std::fmt::Display for ComponentSelection {
206 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207 match self {
208 Self::All => write!(f, "All"),
209 Self::Distance(d) => write!(f, "Last {d} blocks"),
210 Self::Since(block) => write!(f, "Since block {block}"),
211 Self::None => write!(f, "None"),
212 }
213 }
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
218pub enum SnapshotComponentType {
219 State,
221 Headers,
223 Transactions,
225 TransactionSenders,
227 Receipts,
229 AccountChangesets,
231 StorageChangesets,
233 RocksdbIndices,
235}
236
237impl SnapshotComponentType {
238 pub const ALL: [Self; 8] = [
240 Self::State,
241 Self::Headers,
242 Self::Transactions,
243 Self::TransactionSenders,
244 Self::Receipts,
245 Self::AccountChangesets,
246 Self::StorageChangesets,
247 Self::RocksdbIndices,
248 ];
249
250 pub const fn key(&self) -> &'static str {
252 match self {
253 Self::State => "state",
254 Self::Headers => "headers",
255 Self::Transactions => "transactions",
256 Self::TransactionSenders => "transaction_senders",
257 Self::Receipts => "receipts",
258 Self::AccountChangesets => "account_changesets",
259 Self::StorageChangesets => "storage_changesets",
260 Self::RocksdbIndices => "rocksdb_indices",
261 }
262 }
263
264 pub const fn display_name(&self) -> &'static str {
266 match self {
267 Self::State => "State (mdbx)",
268 Self::Headers => "Headers",
269 Self::Transactions => "Transactions",
270 Self::TransactionSenders => "Transaction Senders",
271 Self::Receipts => "Receipts",
272 Self::AccountChangesets => "Account Changesets",
273 Self::StorageChangesets => "Storage Changesets",
274 Self::RocksdbIndices => "RocksDB Indices",
275 }
276 }
277
278 pub const fn is_required(&self) -> bool {
282 matches!(self, Self::State | Self::Headers)
283 }
284
285 pub const fn is_chunked(&self) -> bool {
287 !matches!(self, Self::State | Self::RocksdbIndices)
288 }
289}
290
291impl SnapshotManifest {
292 fn base_url_or_empty(&self) -> &str {
293 self.base_url.as_deref().unwrap_or("")
294 }
295
296 pub fn component(&self, ty: SnapshotComponentType) -> Option<&ComponentManifest> {
298 self.components.get(ty.key())
299 }
300
301 pub fn total_size(&self, types: &[SnapshotComponentType]) -> u64 {
303 types.iter().filter_map(|ty| self.component(*ty).map(|c| c.total_size())).sum()
304 }
305
306 pub fn archive_urls(&self, ty: SnapshotComponentType) -> Vec<String> {
308 let Some(component) = self.component(ty) else {
309 return vec![];
310 };
311
312 match component {
313 ComponentManifest::Single(single) => {
314 vec![resolve_archive_url(self.base_url_or_empty(), &single.file)]
315 }
316 ComponentManifest::Chunked(chunked) => {
317 let key = ty.key();
318 let num_chunks = chunked.num_chunks();
319 (0..num_chunks)
320 .map(|i| {
321 resolve_archive_url(
322 self.base_url_or_empty(),
323 &chunked.chunk_relative_path(key, i),
324 )
325 })
326 .collect()
327 }
328 }
329 }
330
331 pub fn archive_urls_for_distance(
334 &self,
335 ty: SnapshotComponentType,
336 distance: Option<u64>,
337 ) -> Vec<String> {
338 let Some(component) = self.component(ty) else {
339 return vec![];
340 };
341
342 match component {
343 ComponentManifest::Single(single) => {
344 vec![resolve_archive_url(self.base_url_or_empty(), &single.file)]
345 }
346 ComponentManifest::Chunked(chunked) => {
347 let key = ty.key();
348 let num_chunks = chunked.num_chunks();
349
350 let start_chunk = self.start_chunk_for_distance(chunked, distance);
351
352 (start_chunk..num_chunks)
353 .map(|i| {
354 resolve_archive_url(
355 self.base_url_or_empty(),
356 &chunked.chunk_relative_path(key, i),
357 )
358 })
359 .collect()
360 }
361 }
362 }
363
364 pub fn snapshot_archives_for_distance(
367 &self,
368 ty: SnapshotComponentType,
369 distance: Option<u64>,
370 ) -> Vec<SnapshotArchive> {
371 let Some(component) = self.component(ty) else {
372 return vec![];
373 };
374
375 match component {
376 ComponentManifest::Single(single) => {
377 vec![SnapshotArchive {
378 url: resolve_archive_url(self.base_url_or_empty(), &single.file),
379 file_name: single.file.clone(),
380 size: single.size,
381 blake3: single.blake3.clone(),
382 output_files: single.output_files.clone(),
383 }]
384 }
385 ComponentManifest::Chunked(chunked) => {
386 let key = ty.key();
387 let num_chunks = chunked.num_chunks();
388
389 let start_chunk = self.start_chunk_for_distance(chunked, distance);
390
391 (start_chunk..num_chunks)
392 .map(|i| {
393 let file_name = chunked.chunk_relative_path(key, i);
394 let size = chunked.chunk_sizes.get(i as usize).copied().unwrap_or_default();
395 let output_files =
396 chunked.chunk_output_files.get(i as usize).cloned().unwrap_or_default();
397
398 SnapshotArchive {
399 url: resolve_archive_url(self.base_url_or_empty(), &file_name),
400 file_name,
401 size,
402 blake3: None,
403 output_files,
404 }
405 })
406 .collect()
407 }
408 }
409 }
410
411 pub fn validate_output_paths(&self) -> Result<()> {
416 for (key, component) in &self.components {
417 let outputs: Box<dyn Iterator<Item = &OutputFileChecksum>> = match component {
418 ComponentManifest::Single(single) => Box::new(single.output_files.iter()),
419 ComponentManifest::Chunked(chunked) => {
420 Box::new(chunked.chunk_output_files.iter().flatten())
421 }
422 };
423 for output in outputs {
424 output.relative_path().wrap_err_with(|| format!("In component `{key}`"))?;
425 }
426 }
427 Ok(())
428 }
429
430 pub fn size_for_distance(&self, ty: SnapshotComponentType, distance: Option<u64>) -> u64 {
435 let Some(component) = self.component(ty) else {
436 return 0;
437 };
438 match component {
439 ComponentManifest::Single(s) => s.size,
440 ComponentManifest::Chunked(chunked) => {
441 if chunked.chunk_sizes.is_empty() {
442 return 0;
443 }
444 let start_chunk = self.start_chunk_for_distance(chunked, distance);
445 chunked.chunk_sizes.iter().skip(start_chunk as usize).sum()
446 }
447 }
448 }
449
450 pub fn output_size_for_distance(
453 &self,
454 ty: SnapshotComponentType,
455 distance: Option<u64>,
456 ) -> u64 {
457 let Some(component) = self.component(ty) else {
458 return 0;
459 };
460
461 match component {
462 ComponentManifest::Single(single) => single.output_size(),
463 ComponentManifest::Chunked(chunked) => {
464 let num_chunks = chunked.num_chunks();
465 let start_chunk = self.start_chunk_for_distance(chunked, distance);
466
467 (start_chunk..num_chunks)
468 .map(|index| chunked.chunk_output_size(index as usize))
469 .sum()
470 }
471 }
472 }
473
474 pub fn chunks_for_distance(&self, ty: SnapshotComponentType, distance: Option<u64>) -> u64 {
477 let Some(ComponentManifest::Chunked(chunked)) = self.component(ty) else {
478 return if self.component(ty).is_some() { 1 } else { 0 };
479 };
480 chunked.num_chunks() - self.start_chunk_for_distance(chunked, distance)
481 }
482
483 fn start_chunk_for_distance(&self, chunked: &ChunkedArchive, distance: Option<u64>) -> u64 {
485 let Some(distance) = distance else { return 0 };
486 let first_block = self.block.saturating_sub(distance.saturating_sub(1));
489 if distance == 0 || first_block > chunked.total_blocks {
492 return chunked.num_chunks();
493 }
494 first_block / chunked.blocks_per_file
495 }
496
497 pub(crate) fn validate_blocks_per_file(&self) -> Result<()> {
499 for (key, component) in &self.components {
500 if let ComponentManifest::Chunked(chunked) = component {
501 eyre::ensure!(
502 chunked.blocks_per_file > 0,
503 "Invalid modular manifest: {key} blocks_per_file must be greater than zero"
504 );
505 }
506 }
507 Ok(())
508 }
509}
510
511impl ComponentManifest {
512 pub fn total_size(&self) -> u64 {
514 match self {
515 Self::Single(s) => s.size,
516 Self::Chunked(c) => c.chunk_sizes.iter().sum(),
517 }
518 }
519
520 pub fn total_output_size(&self) -> u64 {
522 match self {
523 Self::Single(single) => single.output_size(),
524 Self::Chunked(chunked) => chunked.total_output_size(),
525 }
526 }
527}
528
529impl ChunkedArchive {
530 pub fn num_chunks(&self) -> u64 {
532 self.total_blocks.div_ceil(self.blocks_per_file)
533 }
534
535 pub fn tail_chunks_for_distance(&self, distance: u64) -> u64 {
538 let needed = distance.min(self.total_blocks);
539 if needed == 0 {
540 return 0;
541 }
542
543 let first_chunk = (self.total_blocks - needed) / self.blocks_per_file;
545 self.num_chunks() - first_chunk
546 }
547
548 pub fn chunk_files_are_consistent(&self) -> bool {
550 self.chunk_files.is_empty() || self.chunk_files.len() as u64 == self.num_chunks()
551 }
552
553 pub fn chunk_relative_path(&self, key: &str, index: u64) -> String {
559 if self.chunk_files.len() as u64 == self.num_chunks() &&
560 let Some(path) = self.chunk_files.get(index as usize)
561 {
562 return path.clone();
563 }
564 let start = index * self.blocks_per_file;
565 let end = (index + 1) * self.blocks_per_file - 1;
566 format!("{key}-{start}-{end}.tar.zst")
567 }
568
569 pub fn chunk_output_size(&self, index: usize) -> u64 {
571 self.chunk_decompressed_sizes.get(index).copied().unwrap_or_else(|| {
572 self.chunk_output_files
573 .get(index)
574 .map(|files| files.iter().map(|file| file.size).sum())
575 .unwrap_or(0)
576 })
577 }
578
579 pub fn total_output_size(&self) -> u64 {
581 if !self.chunk_decompressed_sizes.is_empty() {
582 self.chunk_decompressed_sizes.iter().sum()
583 } else {
584 self.chunk_output_files
585 .iter()
586 .map(|files| files.iter().map(|file| file.size).sum::<u64>())
587 .sum()
588 }
589 }
590}
591
592fn resolve_archive_url(base_url: &str, relative_path: &str) -> String {
599 if Url::parse(relative_path).is_ok() {
600 return relative_path.to_string();
601 }
602
603 let relative_path = relative_path.trim_start_matches('/');
604
605 if base_url.is_empty() {
606 return relative_path.to_string();
607 }
608
609 let Ok(mut base) = Url::parse(base_url) else {
610 return format!("{}/{}", base_url.trim_end_matches('/'), relative_path);
611 };
612
613 let path = base.path();
615 if !path.ends_with('/') {
616 let mut with_slash = path.to_string();
617 with_slash.push('/');
618 base.set_path(&with_slash);
619 }
620
621 match base.join(relative_path) {
622 Ok(joined) => joined.to_string(),
623 Err(_) => format!("{}/{}", base_url.trim_end_matches('/'), relative_path),
624 }
625}
626
627impl SingleArchive {
628 pub fn output_size(&self) -> u64 {
630 if self.decompressed_size != 0 {
631 self.decompressed_size
632 } else {
633 self.output_files.iter().map(|file| file.size).sum()
634 }
635 }
636}
637
638pub async fn fetch_manifest(manifest_url: &str) -> Result<SnapshotManifest> {
640 let client = Client::new();
641 let manifest: SnapshotManifest =
642 client.get(manifest_url).send().await?.error_for_status()?.json().await?;
643 manifest.validate_blocks_per_file()?;
644 Ok(manifest)
645}
646
647pub fn generate_manifest(
657 source_datadir: &Path,
658 output_dir: &Path,
659 base_url: Option<&str>,
660 block: u64,
661 chain_id: u64,
662 blocks_per_file: u64,
663 previous: Option<&SnapshotManifest>,
664) -> Result<SnapshotManifest> {
665 eyre::ensure!(blocks_per_file > 0, "blocks_per_file must be greater than zero");
666 if let Some(previous) = previous {
667 eyre::ensure!(
668 previous.chain_id == chain_id,
669 "Previous manifest is for chain {}, not {chain_id}",
670 previous.chain_id
671 );
672 eyre::ensure!(
674 previous.base_url.as_deref().is_some_and(|url| Url::parse(url).is_ok()),
675 "Previous manifest has no absolute base_url to locate its archives"
676 );
677 }
678
679 std::fs::create_dir_all(output_dir)?;
680
681 let mut components = BTreeMap::new();
682 let mut static_files_dir = source_datadir.join("static_files");
683 if !static_files_dir.exists() {
684 static_files_dir = source_datadir.to_path_buf();
685 }
686
687 let static_files = iter_static_files(&static_files_dir)?;
688
689 for ty in &[
691 SnapshotComponentType::Headers,
692 SnapshotComponentType::Transactions,
693 SnapshotComponentType::TransactionSenders,
694 SnapshotComponentType::Receipts,
695 SnapshotComponentType::AccountChangesets,
696 SnapshotComponentType::StorageChangesets,
697 ] {
698 let key = ty.key();
699 let segment = static_segment(*ty).expect("chunked components have a static file segment");
700 let Some((last_range, _)) = static_files.get(segment).and_then(|ranges| {
701 ranges.iter().rfind(|(_, header)| header.expected_block_range().start() <= block)
702 }) else {
703 continue;
704 };
705
706 let total_blocks = last_range
709 .end()
710 .checked_add(1)
711 .ok_or_else(|| eyre::eyre!("Block count overflows for {key}"))?;
712
713 let num_chunks = total_blocks.div_ceil(blocks_per_file);
714 let mut planned_chunks = Vec::with_capacity(num_chunks as usize);
715 let mut found_any = false;
716 for i in 0..num_chunks {
717 let start = i * blocks_per_file;
718 let end = (i + 1) * blocks_per_file - 1;
719 let source_files = source_files_for_chunk(source_datadir, *ty, start, end)?;
720
721 if source_files.is_empty() {
722 if found_any {
723 eyre::bail!("Missing source files for {} chunk {}-{}", key, start, end);
724 }
725 continue;
726 }
727
728 found_any = true;
729 planned_chunks.push(PlannedChunk {
730 chunk_idx: i,
731 archive_path: output_dir.join(chunk_filename(key, start, end)),
732 source_files,
733 });
734 }
735
736 if found_any {
737 let previous_chunks = previous
739 .filter(|_| planned_chunks.len() as u64 == num_chunks)
740 .and_then(|manifest| match manifest.component(*ty)? {
741 ComponentManifest::Chunked(chunked) => Some((manifest, chunked)),
742 ComponentManifest::Single(_) => None,
743 })
744 .filter(|(_, chunked)| {
746 chunked.blocks_per_file == blocks_per_file &&
747 chunked.chunk_files_are_consistent() &&
748 chunked.chunk_sizes.len() as u64 == chunked.num_chunks() &&
749 chunked.chunk_output_files.len() as u64 == chunked.num_chunks()
750 });
751
752 let reusable_chunks = previous_chunks
754 .map_or(0, |(_, chunked)| num_chunks.min(chunked.num_chunks()).saturating_sub(1));
755
756 let mut packaged_chunks = planned_chunks
757 .into_par_iter()
758 .map(|planned| -> Result<PackagedChunk> {
759 let start = planned.chunk_idx * blocks_per_file;
760 let file_name = chunk_filename(key, start, start + blocks_per_file - 1);
761 if planned.chunk_idx < reusable_chunks &&
762 let Some((manifest, chunked)) = previous_chunks
763 {
764 let idx = planned.chunk_idx as usize;
765 let output_files = hash_chunk_files(&planned.source_files)?;
766 eyre::ensure!(
767 output_files == chunked.chunk_output_files[idx],
768 "{file_name} differs from the previous manifest"
769 );
770
771 return Ok(PackagedChunk {
772 chunk_idx: planned.chunk_idx,
773 size: chunked.chunk_sizes[idx],
774 output_files,
775 file: resolve_archive_url(
776 manifest.base_url_or_empty(),
777 &chunked.chunk_relative_path(key, planned.chunk_idx),
778 ),
779 });
780 }
781
782 let output_files =
783 write_chunk_archive(&planned.archive_path, &planned.source_files)?;
784 let size = std::fs::metadata(&planned.archive_path)?.len();
785 Ok(PackagedChunk {
786 chunk_idx: planned.chunk_idx,
787 size,
788 output_files,
789 file: file_name,
790 })
791 })
792 .collect::<Vec<_>>()
793 .into_iter()
794 .collect::<Result<Vec<_>>>()?;
795
796 packaged_chunks.sort_unstable_by_key(|chunk| chunk.chunk_idx);
797 let chunk_files = packaged_chunks.iter().map(|chunk| chunk.file.clone()).collect();
798 let chunk_sizes = packaged_chunks.iter().map(|chunk| chunk.size).collect::<Vec<_>>();
799 let chunk_output_files =
800 packaged_chunks.into_iter().map(|chunk| chunk.output_files).collect::<Vec<_>>();
801 let total_size: u64 = chunk_sizes.iter().sum();
802 info!(target: "reth::cli",
803 component = ty.display_name(),
804 chunks = chunk_sizes.len(),
805 total_blocks,
806 size = %super::DownloadProgress::format_size(total_size),
807 "Found chunked component"
808 );
809 components.insert(
810 key.to_string(),
811 ComponentManifest::Chunked(ChunkedArchive {
812 blocks_per_file,
813 total_blocks,
814 chunk_sizes,
815 chunk_decompressed_sizes: chunk_output_files
816 .iter()
817 .map(|files| files.iter().map(|file| file.size).sum())
818 .collect(),
819 chunk_files,
820 chunk_output_files,
821 }),
822 );
823 }
824 }
825
826 let (state_size, state_output_files) = package_single_component(
827 output_dir,
828 "state.tar.zst",
829 &state_source_files(source_datadir)?,
830 )?;
831 components.insert(
832 SnapshotComponentType::State.key().to_string(),
833 ComponentManifest::Single(SingleArchive {
834 file: "state.tar.zst".to_string(),
835 size: state_size,
836 decompressed_size: state_output_files.iter().map(|file| file.size).sum(),
837 blake3: None,
838 output_files: state_output_files,
839 }),
840 );
841
842 let rocksdb_files = rocksdb_source_files(source_datadir)?;
843 if !rocksdb_files.is_empty() {
844 let (rocksdb_size, rocksdb_output_files) =
845 package_single_component(output_dir, "rocksdb_indices.tar.zst", &rocksdb_files)?;
846 components.insert(
847 SnapshotComponentType::RocksdbIndices.key().to_string(),
848 ComponentManifest::Single(SingleArchive {
849 file: "rocksdb_indices.tar.zst".to_string(),
850 size: rocksdb_size,
851 decompressed_size: rocksdb_output_files.iter().map(|file| file.size).sum(),
852 blake3: None,
853 output_files: rocksdb_output_files,
854 }),
855 );
856 }
857
858 let timestamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs();
859
860 Ok(SnapshotManifest {
861 block,
862 chain_id,
863 storage_version: 2,
864 timestamp,
865 base_url: base_url.map(str::to_owned),
866 reth_version: Some(reth_node_core::version::version_metadata().short_version.to_string()),
867 components,
868 extensions: Default::default(),
869 })
870}
871
872pub fn chunk_filename(component_key: &str, start: u64, end: u64) -> String {
874 format!("{component_key}-{start}-{end}.tar.zst")
875}
876
877#[derive(Debug)]
878struct PlannedChunk {
879 chunk_idx: u64,
880 archive_path: PathBuf,
881 source_files: Vec<PathBuf>,
882}
883
884#[derive(Debug)]
885struct PackagedChunk {
886 chunk_idx: u64,
887 size: u64,
888 output_files: Vec<OutputFileChecksum>,
889 file: String,
891}
892
893#[derive(Debug)]
894struct PlannedFile {
895 source_path: PathBuf,
896 relative_path: PathBuf,
897}
898
899fn source_files_for_chunk(
900 source_datadir: &Path,
901 component: SnapshotComponentType,
902 start: u64,
903 end: u64,
904) -> Result<Vec<PathBuf>> {
905 let Some(segment) = static_segment(component) else {
906 return Ok(Vec::new());
907 };
908
909 let static_files_dir = source_datadir.join("static_files");
910 let static_files_dir =
911 if static_files_dir.exists() { static_files_dir } else { source_datadir.to_path_buf() };
912 let prefix = format!("static_file_{}_{start}_{end}", segment.as_str());
913
914 let mut files = Vec::new();
915 for entry in std::fs::read_dir(&static_files_dir)? {
916 let entry = entry?;
917 if !entry.file_type()?.is_file() {
918 continue;
919 }
920 if entry.file_name().to_string_lossy().starts_with(&prefix) {
921 files.push(entry.path());
922 }
923 }
924
925 files.sort_unstable();
926 Ok(files)
927}
928
929fn static_segment(component: SnapshotComponentType) -> Option<StaticFileSegment> {
930 match component {
931 SnapshotComponentType::Headers => Some(StaticFileSegment::Headers),
932 SnapshotComponentType::Transactions => Some(StaticFileSegment::Transactions),
933 SnapshotComponentType::TransactionSenders => Some(StaticFileSegment::TransactionSenders),
934 SnapshotComponentType::Receipts => Some(StaticFileSegment::Receipts),
935 SnapshotComponentType::AccountChangesets => Some(StaticFileSegment::AccountChangeSets),
936 SnapshotComponentType::StorageChangesets => Some(StaticFileSegment::StorageChangeSets),
937 SnapshotComponentType::State | SnapshotComponentType::RocksdbIndices => None,
938 }
939}
940
941fn state_source_files(source_datadir: &Path) -> Result<Vec<PlannedFile>> {
942 let db_dir = source_datadir.join("db");
943 if db_dir.exists() {
944 return collect_files_recursive(&db_dir, Path::new("db"));
945 }
946
947 if looks_like_db_dir(source_datadir)? {
948 return collect_files_recursive(source_datadir, Path::new("db"));
949 }
950
951 eyre::bail!("Could not find source state DB directory under {}", source_datadir.display());
952}
953
954fn rocksdb_source_files(source_datadir: &Path) -> Result<Vec<PlannedFile>> {
955 let rocksdb_dir = source_datadir.join("rocksdb");
956 if !rocksdb_dir.exists() {
957 return Ok(Vec::new());
958 }
959
960 collect_files_recursive(&rocksdb_dir, Path::new("rocksdb"))
961}
962
963fn looks_like_db_dir(path: &Path) -> Result<bool> {
964 let entries = match std::fs::read_dir(path) {
965 Ok(entries) => entries,
966 Err(_) => return Ok(false),
967 };
968
969 for entry in entries {
970 let entry = entry?;
971 if !entry.file_type()?.is_file() {
972 continue;
973 }
974 let name = entry.file_name();
975 let name = name.to_string_lossy();
976 if name == "mdbx.dat" || name == "lock.mdb" || name == "data.mdb" {
977 return Ok(true);
978 }
979 }
980
981 Ok(false)
982}
983
984fn collect_files_recursive(root: &Path, output_prefix: &Path) -> Result<Vec<PlannedFile>> {
985 let mut files = Vec::new();
986 collect_files_recursive_inner(root, root, output_prefix, &mut files)?;
987 files.sort_unstable_by(|a, b| a.relative_path.cmp(&b.relative_path));
988 Ok(files)
989}
990
991fn collect_files_recursive_inner(
992 root: &Path,
993 dir: &Path,
994 output_prefix: &Path,
995 files: &mut Vec<PlannedFile>,
996) -> Result<()> {
997 for entry in std::fs::read_dir(dir)? {
998 let entry = entry?;
999 let path = entry.path();
1000 let file_type = entry.file_type()?;
1001 if file_type.is_dir() {
1002 collect_files_recursive_inner(root, &path, output_prefix, files)?;
1003 continue;
1004 }
1005 if !file_type.is_file() {
1006 continue;
1007 }
1008
1009 let relative = path.strip_prefix(root)?.to_path_buf();
1010 files.push(PlannedFile { source_path: path, relative_path: output_prefix.join(relative) });
1011 }
1012
1013 Ok(())
1014}
1015
1016fn package_single_component(
1017 output_dir: &Path,
1018 archive_file_name: &str,
1019 files: &[PlannedFile],
1020) -> Result<(u64, Vec<OutputFileChecksum>)> {
1021 if files.is_empty() {
1022 eyre::bail!("Cannot package empty single archive: {}", archive_file_name);
1023 }
1024
1025 let archive_path = output_dir.join(archive_file_name);
1026 let output_files = write_archive_from_planned_files(&archive_path, files)?;
1027 let size = std::fs::metadata(&archive_path)?.len();
1028 Ok((size, output_files))
1029}
1030
1031fn write_chunk_archive(path: &Path, source_files: &[PathBuf]) -> Result<Vec<OutputFileChecksum>> {
1032 write_archive_from_planned_files(path, &chunk_planned_files(source_files)?)
1033}
1034
1035fn hash_chunk_files(source_files: &[PathBuf]) -> Result<Vec<OutputFileChecksum>> {
1037 chunk_planned_files(source_files)?
1038 .into_iter()
1039 .map(|planned| {
1040 let mut reader = HashingReader::new(reth_fs_util::open(&planned.source_path)?);
1041 std::io::copy(&mut reader, &mut std::io::sink())?;
1042 Ok(OutputFileChecksum {
1043 path: planned.relative_path.to_string_lossy().to_string(),
1044 size: reader.bytes_read,
1045 blake3: reader.finalize(),
1046 })
1047 })
1048 .collect()
1049}
1050
1051fn chunk_planned_files(source_files: &[PathBuf]) -> Result<Vec<PlannedFile>> {
1052 source_files
1053 .iter()
1054 .map(|source_path| {
1055 let file_name = source_path.file_name().ok_or_else(|| {
1056 eyre::eyre!("Invalid source file path: {}", source_path.display())
1057 })?;
1058 Ok(PlannedFile {
1059 source_path: source_path.clone(),
1060 relative_path: PathBuf::from("static_files").join(file_name),
1061 })
1062 })
1063 .collect()
1064}
1065
1066fn write_archive_from_planned_files(
1067 path: &Path,
1068 files: &[PlannedFile],
1069) -> Result<Vec<OutputFileChecksum>> {
1070 let file = std::fs::File::create(path)?;
1071 let mut encoder = zstd::Encoder::new(file, 0)?;
1072 encoder.include_checksum(true)?;
1075 let mut builder = tar::Builder::new(encoder);
1076
1077 let mut output_files = Vec::with_capacity(files.len());
1078 for planned in files {
1079 let mut header = tar::Header::new_gnu();
1080 header.set_size(std::fs::metadata(&planned.source_path)?.len());
1081 header.set_mode(0o644);
1082 header.set_cksum();
1083
1084 let source_file = std::fs::File::open(&planned.source_path)?;
1085 let mut reader = HashingReader::new(source_file);
1086 builder.append_data(&mut header, &planned.relative_path, &mut reader)?;
1087
1088 output_files.push(OutputFileChecksum {
1089 path: planned.relative_path.to_string_lossy().to_string(),
1090 size: reader.bytes_read,
1091 blake3: reader.finalize(),
1092 });
1093 }
1094
1095 builder.finish()?;
1096 let encoder = builder.into_inner()?;
1097 encoder.finish()?;
1098
1099 Ok(output_files)
1100}
1101
1102struct HashingReader<R> {
1103 inner: R,
1104 hasher: Hasher,
1105 bytes_read: u64,
1106}
1107
1108impl<R: Read> HashingReader<R> {
1109 fn new(inner: R) -> Self {
1110 Self { inner, hasher: Hasher::new(), bytes_read: 0 }
1111 }
1112
1113 fn finalize(self) -> String {
1114 self.hasher.finalize().to_hex().to_string()
1115 }
1116}
1117
1118impl<R: Read> Read for HashingReader<R> {
1119 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1120 let n = self.inner.read(buf)?;
1121 if n > 0 {
1122 self.bytes_read += n as u64;
1123 self.hasher.update(&buf[..n]);
1124 }
1125 Ok(n)
1126 }
1127}
1128
1129#[cfg(test)]
1130mod tests {
1131 use super::*;
1132 use alloy_consensus::Header;
1133 use alloy_primitives::B256;
1134 use reth_node_api::NodeTypes;
1135 use reth_provider::{test_utils::MockNodeTypes, StaticFileProviderBuilder, StaticFileWriter};
1136 use tempfile::tempdir;
1137
1138 fn test_manifest() -> SnapshotManifest {
1139 let mut components = BTreeMap::new();
1140 components.insert(
1141 "state".to_string(),
1142 ComponentManifest::Single(SingleArchive {
1143 file: "state.tar.zst".to_string(),
1144 size: 100,
1145 decompressed_size: 0,
1146 blake3: None,
1147 output_files: vec![],
1148 }),
1149 );
1150 components.insert(
1151 "transactions".to_string(),
1152 ComponentManifest::Chunked(ChunkedArchive {
1153 blocks_per_file: 500_000,
1154 total_blocks: 1_500_000,
1155 chunk_sizes: vec![80_000, 100_000, 120_000],
1156 chunk_decompressed_sizes: vec![],
1157 chunk_files: vec![],
1158 chunk_output_files: vec![vec![], vec![], vec![]],
1159 }),
1160 );
1161 components.insert(
1162 "headers".to_string(),
1163 ComponentManifest::Chunked(ChunkedArchive {
1164 blocks_per_file: 500_000,
1165 total_blocks: 1_500_000,
1166 chunk_sizes: vec![40_000, 50_000, 60_000],
1167 chunk_decompressed_sizes: vec![],
1168 chunk_files: vec![],
1169 chunk_output_files: vec![vec![], vec![], vec![]],
1170 }),
1171 );
1172 SnapshotManifest {
1173 block: 1_500_000,
1174 chain_id: 1,
1175 storage_version: 2,
1176 timestamp: 0,
1177 base_url: Some("https://example.com".to_string()),
1178 reth_version: None,
1179 components,
1180 extensions: Default::default(),
1181 }
1182 }
1183
1184 fn snapshot_source() -> tempfile::TempDir {
1185 let source = tempdir().unwrap();
1186 reth_fs_util::create_dir_all(source.path().join("db")).unwrap();
1187 reth_fs_util::write(source.path().join("db/mdbx.dat"), b"state-data").unwrap();
1188 source
1189 }
1190
1191 fn write_test_static_files(source: &Path, segment: StaticFileSegment, tip: u64) {
1192 let static_files = source.join("static_files");
1193 reth_fs_util::create_dir_all(&static_files).unwrap();
1194
1195 let provider = StaticFileProviderBuilder::read_write(&static_files)
1196 .with_blocks_per_file(10)
1197 .build::<<MockNodeTypes as NodeTypes>::Primitives>()
1198 .unwrap();
1199
1200 let mut writer = provider.get_writer(0, segment).unwrap();
1201 for number in 0..=tip {
1202 if segment == StaticFileSegment::Headers {
1203 writer
1204 .append_header(&Header { number, ..Default::default() }, &B256::ZERO)
1205 .unwrap();
1206 } else {
1207 writer.increment_block(number).unwrap();
1208 }
1209 }
1210
1211 writer.commit().unwrap();
1212 }
1213
1214 #[test]
1215 fn manifest_preserves_extensions() {
1216 let manifest: SnapshotManifest = serde_json::from_str(
1217 r#"{
1218 "block": 1,
1219 "chain_id": 1,
1220 "storage_version": 2,
1221 "timestamp": 0,
1222 "components": {},
1223 "consensus": { "archive": "consensus.tar.zst" }
1224 }"#,
1225 )
1226 .unwrap();
1227
1228 assert_eq!(
1229 manifest.extensions.get("consensus"),
1230 Some(&serde_json::json!({ "archive": "consensus.tar.zst" }))
1231 );
1232 }
1233
1234 #[test]
1235 fn archive_urls_for_distance_all() {
1236 let m = test_manifest();
1237 let urls = m.archive_urls_for_distance(SnapshotComponentType::Transactions, None);
1238 assert_eq!(urls.len(), 3);
1239 assert_eq!(urls[0], "https://example.com/transactions-0-499999.tar.zst");
1240 assert_eq!(urls[2], "https://example.com/transactions-1000000-1499999.tar.zst");
1241 }
1242
1243 #[test]
1244 fn archive_urls_for_distance_partial() {
1245 let m = test_manifest();
1246 let urls = m.archive_urls_for_distance(SnapshotComponentType::Transactions, Some(600_000));
1248 assert_eq!(urls.len(), 2);
1249 assert_eq!(urls[0], "https://example.com/transactions-500000-999999.tar.zst");
1250 assert_eq!(urls[1], "https://example.com/transactions-1000000-1499999.tar.zst");
1251 }
1252
1253 #[test]
1254 fn archive_urls_for_distance_single_component() {
1255 let m = test_manifest();
1256 let urls = m.archive_urls_for_distance(SnapshotComponentType::State, Some(100));
1258 assert_eq!(urls.len(), 1);
1259 assert_eq!(urls[0], "https://example.com/state.tar.zst");
1260 }
1261
1262 #[test]
1263 fn archive_urls_for_distance_rocksdb_indices_single_component() {
1264 let mut components = BTreeMap::new();
1265 components.insert(
1266 "rocksdb_indices".to_string(),
1267 ComponentManifest::Single(SingleArchive {
1268 file: "rocksdb_indices.tar.zst".to_string(),
1269 size: 777,
1270 decompressed_size: 0,
1271 blake3: None,
1272 output_files: vec![],
1273 }),
1274 );
1275 let m = SnapshotManifest {
1276 block: 1,
1277 chain_id: 1,
1278 storage_version: 2,
1279 timestamp: 0,
1280 base_url: Some("https://example.com".to_string()),
1281 reth_version: None,
1282 components,
1283 extensions: Default::default(),
1284 };
1285
1286 let urls = m.archive_urls_for_distance(SnapshotComponentType::RocksdbIndices, Some(10));
1287 assert_eq!(urls.len(), 1);
1288 assert_eq!(urls[0], "https://example.com/rocksdb_indices.tar.zst");
1289 assert_eq!(m.size_for_distance(SnapshotComponentType::RocksdbIndices, Some(10)), 777);
1290 }
1291
1292 #[test]
1293 fn archive_urls_for_distance_missing_component() {
1294 let m = test_manifest();
1295 let urls = m.archive_urls_for_distance(SnapshotComponentType::Receipts, None);
1296 assert!(urls.is_empty());
1297 }
1298
1299 #[test]
1300 fn chunks_for_distance_all() {
1301 let m = test_manifest();
1302 assert_eq!(m.chunks_for_distance(SnapshotComponentType::Transactions, None), 3);
1303 }
1304
1305 #[test]
1306 fn chunks_for_distance_partial() {
1307 let m = test_manifest();
1308 assert_eq!(m.chunks_for_distance(SnapshotComponentType::Transactions, Some(600_000)), 2);
1309 assert_eq!(m.chunks_for_distance(SnapshotComponentType::Transactions, Some(100_000)), 1);
1310 }
1311
1312 #[test]
1313 fn chunks_for_distance_single() {
1314 let m = test_manifest();
1315 assert_eq!(m.chunks_for_distance(SnapshotComponentType::State, None), 1);
1316 assert_eq!(m.chunks_for_distance(SnapshotComponentType::State, Some(100)), 1);
1317 }
1318
1319 fn partial_tail_manifest() -> SnapshotManifest {
1323 let mut components = BTreeMap::new();
1324 components.insert(
1325 "transactions".to_string(),
1326 ComponentManifest::Chunked(ChunkedArchive {
1327 blocks_per_file: 500_000,
1328 total_blocks: 1_005_000,
1329 chunk_sizes: vec![10, 20, 30],
1330 chunk_decompressed_sizes: vec![100, 200, 300],
1331 chunk_files: vec![],
1332 chunk_output_files: vec![vec![], vec![], vec![]],
1333 }),
1334 );
1335 SnapshotManifest {
1336 block: 1_005_000,
1337 chain_id: 1,
1338 storage_version: 2,
1339 timestamp: 0,
1340 base_url: Some("https://example.com".to_string()),
1341 reth_version: None,
1342 components,
1343 extensions: Default::default(),
1344 }
1345 }
1346
1347 #[test]
1348 fn tail_chunks_for_distance_accounts_for_partial_final_chunk() {
1349 let chunked = ChunkedArchive {
1350 blocks_per_file: 500_000,
1351 total_blocks: 1_005_000,
1352 chunk_sizes: vec![10, 20, 30],
1353 chunk_decompressed_sizes: vec![],
1354 chunk_files: vec![],
1355 chunk_output_files: vec![],
1356 };
1357 assert_eq!(chunked.tail_chunks_for_distance(10_064), 2);
1360 assert_eq!(chunked.tail_chunks_for_distance(5_000), 1);
1362 assert_eq!(chunked.tail_chunks_for_distance(5_001), 2);
1364 assert_eq!(chunked.tail_chunks_for_distance(1), 1);
1365 assert_eq!(chunked.tail_chunks_for_distance(1_005_000), 3);
1367 assert_eq!(chunked.tail_chunks_for_distance(u64::MAX), 3);
1368 }
1369
1370 #[test]
1371 fn partial_final_chunk_downloads_enough_history() {
1372 let m = partial_tail_manifest();
1373 assert_eq!(m.chunks_for_distance(SnapshotComponentType::Transactions, Some(10_064)), 2);
1377
1378 let urls = m.archive_urls_for_distance(SnapshotComponentType::Transactions, Some(10_064));
1379 assert_eq!(urls.len(), 2);
1380 assert_eq!(urls[0], "https://example.com/transactions-500000-999999.tar.zst");
1381 assert_eq!(urls[1], "https://example.com/transactions-1000000-1499999.tar.zst");
1382
1383 let archives =
1384 m.snapshot_archives_for_distance(SnapshotComponentType::Transactions, Some(10_064));
1385 assert_eq!(archives.len(), 2);
1386
1387 assert_eq!(m.size_for_distance(SnapshotComponentType::Transactions, Some(10_064)), 20 + 30);
1389 assert_eq!(
1390 m.output_size_for_distance(SnapshotComponentType::Transactions, Some(10_064)),
1391 200 + 300
1392 );
1393
1394 assert_eq!(m.chunks_for_distance(SnapshotComponentType::Transactions, Some(5_000)), 1);
1396 }
1397
1398 #[test]
1399 fn chunks_for_distance_missing() {
1400 let m = test_manifest();
1401 assert_eq!(m.chunks_for_distance(SnapshotComponentType::Receipts, None), 0);
1402 }
1403
1404 #[test]
1405 fn component_selection_display() {
1406 assert_eq!(ComponentSelection::All.to_string(), "All");
1407 assert_eq!(ComponentSelection::Distance(10_064).to_string(), "Last 10064 blocks");
1408 assert_eq!(ComponentSelection::Since(15_537_394).to_string(), "Since block 15537394");
1409 assert_eq!(ComponentSelection::None.to_string(), "None");
1410 }
1411
1412 #[test]
1413 fn archive_urls_aligned_to_blocks_per_file() {
1414 let mut components = BTreeMap::new();
1417 components.insert(
1418 "storage_changesets".to_string(),
1419 ComponentManifest::Chunked(ChunkedArchive {
1420 blocks_per_file: 500_000,
1421 total_blocks: 24_396_822,
1422 chunk_sizes: vec![100; 49], chunk_decompressed_sizes: vec![],
1424 chunk_files: vec![],
1425 chunk_output_files: vec![vec![]; 49],
1426 }),
1427 );
1428 let m = SnapshotManifest {
1429 block: 24_396_822,
1430 chain_id: 1,
1431 storage_version: 2,
1432 timestamp: 0,
1433 base_url: Some("https://example.com".to_string()),
1434 reth_version: None,
1435 components,
1436 extensions: Default::default(),
1437 };
1438 let urls = m.archive_urls(SnapshotComponentType::StorageChangesets);
1439 assert_eq!(urls.len(), 49);
1440 assert_eq!(urls[0], "https://example.com/storage_changesets-0-499999.tar.zst");
1442 assert_eq!(urls[48], "https://example.com/storage_changesets-24000000-24499999.tar.zst");
1444 }
1445
1446 #[test]
1447 fn size_for_distance_sums_tail_chunks() {
1448 let m = test_manifest();
1449 assert_eq!(m.size_for_distance(SnapshotComponentType::Transactions, None), 300_000);
1452 assert_eq!(
1454 m.size_for_distance(SnapshotComponentType::Transactions, Some(500_000)),
1455 120_000
1456 );
1457 assert_eq!(
1459 m.size_for_distance(SnapshotComponentType::Transactions, Some(600_000)),
1460 220_000
1461 );
1462 assert_eq!(m.size_for_distance(SnapshotComponentType::State, Some(100)), 100);
1464 assert_eq!(m.size_for_distance(SnapshotComponentType::Receipts, None), 0);
1466 }
1467
1468 #[test]
1469 fn manifest_output_paths_must_stay_relative() {
1470 fn output(path: &str) -> OutputFileChecksum {
1471 OutputFileChecksum { path: path.into(), size: 0, blake3: String::new() }
1472 }
1473
1474 let mut manifest = test_manifest();
1475 let mut with_outputs = |single: &[&str], chunks: &[&str]| {
1476 manifest.components.insert(
1477 "state".into(),
1478 ComponentManifest::Single(SingleArchive {
1479 file: "state.tar.zst".into(),
1480 size: 1,
1481 decompressed_size: 0,
1482 blake3: None,
1483 output_files: single.iter().map(|path| output(path)).collect(),
1484 }),
1485 );
1486 manifest.components.insert(
1487 "transactions".into(),
1488 ComponentManifest::Chunked(ChunkedArchive {
1489 blocks_per_file: 500_000,
1490 total_blocks: 500_000,
1491 chunk_sizes: vec![1],
1492 chunk_decompressed_sizes: vec![],
1493 chunk_files: vec![],
1494 chunk_output_files: vec![chunks.iter().map(|path| output(path)).collect()],
1495 }),
1496 );
1497 manifest.validate_output_paths()
1498 };
1499
1500 with_outputs(&["db/mdbx.dat", "./static_files/headers"], &["static_files/a/b"]).unwrap();
1501 for bad in ["/etc/passwd", "../outside", "db/../../outside", "db/..", ".", "", "./"] {
1502 let err = with_outputs(&[bad], &[]).unwrap_err();
1503 assert!(format!("{err:#}").contains("Invalid manifest output file path"), "{bad}");
1504 assert!(with_outputs(&[], &[bad]).is_err(), "{bad}");
1505 }
1506 }
1507
1508 #[test]
1509 fn output_size_for_distance_uses_manifest_or_output_files() {
1510 let m = test_manifest();
1511 assert_eq!(m.output_size_for_distance(SnapshotComponentType::Transactions, None), 0);
1512
1513 let mut components = BTreeMap::new();
1514 components.insert(
1515 "state".to_string(),
1516 ComponentManifest::Single(SingleArchive {
1517 file: "state.tar.zst".to_string(),
1518 size: 100,
1519 decompressed_size: 1_000,
1520 blake3: None,
1521 output_files: vec![OutputFileChecksum {
1522 path: "db/mdbx.dat".to_string(),
1523 size: 1_000,
1524 blake3: "h0".to_string(),
1525 }],
1526 }),
1527 );
1528 components.insert(
1529 "transactions".to_string(),
1530 ComponentManifest::Chunked(ChunkedArchive {
1531 blocks_per_file: 500_000,
1532 total_blocks: 1_000_000,
1533 chunk_sizes: vec![80_000, 120_000],
1534 chunk_decompressed_sizes: vec![111, 222],
1535 chunk_files: vec![],
1536 chunk_output_files: vec![
1537 vec![OutputFileChecksum {
1538 path: "static_files/static_file_transactions_0_499999.bin".to_string(),
1539 size: 111,
1540 blake3: "h0".to_string(),
1541 }],
1542 vec![OutputFileChecksum {
1543 path: "static_files/static_file_transactions_500000_999999.bin".to_string(),
1544 size: 222,
1545 blake3: "h1".to_string(),
1546 }],
1547 ],
1548 }),
1549 );
1550 let manifest = SnapshotManifest {
1551 block: 1_000_000,
1552 chain_id: 1,
1553 storage_version: 2,
1554 timestamp: 0,
1555 base_url: Some("https://example.com".to_string()),
1556 reth_version: None,
1557 components,
1558 extensions: Default::default(),
1559 };
1560
1561 assert_eq!(manifest.output_size_for_distance(SnapshotComponentType::State, None), 1_000);
1562 assert_eq!(
1563 manifest.output_size_for_distance(SnapshotComponentType::Transactions, None),
1564 333
1565 );
1566 assert_eq!(
1567 manifest.output_size_for_distance(SnapshotComponentType::Transactions, Some(500_000)),
1568 222
1569 );
1570 }
1571
1572 #[test]
1573 fn archive_descriptors_include_checksum_metadata() {
1574 let mut components = BTreeMap::new();
1575 components.insert(
1576 "state".to_string(),
1577 ComponentManifest::Single(SingleArchive {
1578 file: "state.tar.zst".to_string(),
1579 size: 100,
1580 decompressed_size: 1_000,
1581 blake3: Some("abc123".to_string()),
1582 output_files: vec![OutputFileChecksum {
1583 path: "db/mdbx.dat".to_string(),
1584 size: 1000,
1585 blake3: "s0".to_string(),
1586 }],
1587 }),
1588 );
1589 components.insert(
1590 "transactions".to_string(),
1591 ComponentManifest::Chunked(ChunkedArchive {
1592 blocks_per_file: 500_000,
1593 total_blocks: 1_000_000,
1594 chunk_sizes: vec![80_000, 120_000],
1595 chunk_decompressed_sizes: vec![111, 222],
1596 chunk_files: vec![],
1597 chunk_output_files: vec![
1598 vec![OutputFileChecksum {
1599 path: "static_files/static_file_transactions_0_499999.bin".to_string(),
1600 size: 111,
1601 blake3: "h0".to_string(),
1602 }],
1603 vec![OutputFileChecksum {
1604 path: "static_files/static_file_transactions_500000_999999.bin".to_string(),
1605 size: 222,
1606 blake3: "h1".to_string(),
1607 }],
1608 ],
1609 }),
1610 );
1611
1612 let m = SnapshotManifest {
1613 block: 1_000_000,
1614 chain_id: 1,
1615 storage_version: 2,
1616 timestamp: 0,
1617 base_url: Some("https://example.com".to_string()),
1618 reth_version: None,
1619 components,
1620 extensions: Default::default(),
1621 };
1622
1623 let state = m.snapshot_archives_for_distance(SnapshotComponentType::State, None);
1624 assert_eq!(state.len(), 1);
1625 assert_eq!(state[0].file_name, "state.tar.zst");
1626 assert_eq!(state[0].blake3.as_deref(), Some("abc123"));
1627 assert_eq!(state[0].output_files.len(), 1);
1628
1629 let tx = m.snapshot_archives_for_distance(SnapshotComponentType::Transactions, None);
1630 assert_eq!(tx.len(), 2);
1631 assert_eq!(tx[0].blake3, None);
1632 assert_eq!(tx[1].blake3, None);
1633 assert_eq!(tx[0].output_files[0].size, 111);
1634 }
1635
1636 #[test]
1637 fn generate_manifest_includes_state_single_archive() {
1638 let source = tempdir().unwrap();
1639 let output = tempdir().unwrap();
1640 let db_dir = source.path().join("db");
1641 std::fs::create_dir_all(&db_dir).unwrap();
1642 std::fs::write(db_dir.join("mdbx.dat"), b"state-data").unwrap();
1643
1644 let manifest =
1645 generate_manifest(source.path(), output.path(), None, 0, 1, 500_000, None).unwrap();
1646
1647 let state = manifest.component(SnapshotComponentType::State).unwrap();
1648 let ComponentManifest::Single(state) = state else {
1649 panic!("state should be a single archive")
1650 };
1651 assert_eq!(state.file, "state.tar.zst");
1652 assert!(state.decompressed_size > 0);
1653 assert!(!state.output_files.is_empty());
1654 assert_eq!(state.output_files[0].path, "db/mdbx.dat");
1655 assert!(output.path().join("state.tar.zst").exists());
1656 }
1657
1658 #[test]
1659 fn generate_manifest_includes_rocksdb_single_archive_when_present() {
1660 let source = tempdir().unwrap();
1661 let output = tempdir().unwrap();
1662 let db_dir = source.path().join("db");
1663 std::fs::create_dir_all(&db_dir).unwrap();
1664 std::fs::write(db_dir.join("mdbx.dat"), b"state-data").unwrap();
1665 let rocksdb_dir = source.path().join("rocksdb");
1666 std::fs::create_dir_all(&rocksdb_dir).unwrap();
1667 std::fs::write(rocksdb_dir.join("CURRENT"), b"MANIFEST-000001").unwrap();
1668
1669 let manifest =
1670 generate_manifest(source.path(), output.path(), None, 0, 1, 500_000, None).unwrap();
1671
1672 let rocksdb = manifest.component(SnapshotComponentType::RocksdbIndices).unwrap();
1673 let ComponentManifest::Single(rocksdb) = rocksdb else {
1674 panic!("rocksdb indices should be a single archive")
1675 };
1676 assert_eq!(rocksdb.file, "rocksdb_indices.tar.zst");
1677 assert!(rocksdb.decompressed_size > 0);
1678 assert!(!rocksdb.output_files.is_empty());
1679 assert_eq!(rocksdb.output_files[0].path, "rocksdb/CURRENT");
1680 assert!(output.path().join("rocksdb_indices.tar.zst").exists());
1681 }
1682
1683 #[test]
1684 fn resolve_archive_url_joins_nested_paths_under_base() {
1685 assert_eq!(
1686 resolve_archive_url(
1687 "https://example.com/mainnet",
1688 "static_files/headers-0-499999.tar.zst"
1689 ),
1690 "https://example.com/mainnet/static_files/headers-0-499999.tar.zst"
1691 );
1692 assert_eq!(
1693 resolve_archive_url("https://example.com/mainnet/", "1700000/state.tar.zst"),
1694 "https://example.com/mainnet/1700000/state.tar.zst"
1695 );
1696 assert_eq!(
1697 resolve_archive_url("https://example.com/mainnet", "headers-0-499999.tar.zst"),
1698 "https://example.com/mainnet/headers-0-499999.tar.zst"
1699 );
1700 }
1701
1702 #[test]
1703 fn chunk_files_resolve_relative_to_root_base_url() {
1704 let mut components = BTreeMap::new();
1705 components.insert(
1706 "headers".to_string(),
1707 ComponentManifest::Chunked(ChunkedArchive {
1708 blocks_per_file: 500_000,
1709 total_blocks: 1_000_000,
1710 chunk_sizes: vec![40_000, 50_000],
1711 chunk_decompressed_sizes: vec![],
1712 chunk_files: vec![
1713 "static_files/headers-0-499999.tar.zst".to_string(),
1714 "1700000/headers-500000-999999.tar.zst".to_string(),
1715 ],
1716 chunk_output_files: vec![vec![], vec![]],
1717 }),
1718 );
1719 components.insert(
1720 "state".to_string(),
1721 ComponentManifest::Single(SingleArchive {
1722 file: "1700000/state.tar.zst".to_string(),
1723 size: 100,
1724 decompressed_size: 0,
1725 blake3: None,
1726 output_files: vec![],
1727 }),
1728 );
1729
1730 let m = SnapshotManifest {
1731 block: 1_000_000,
1732 chain_id: 1,
1733 storage_version: 2,
1734 timestamp: 1_700_000,
1735 base_url: Some("https://example.com/mainnet".to_string()),
1736 reth_version: None,
1737 components,
1738 extensions: Default::default(),
1739 };
1740
1741 let urls = m.archive_urls(SnapshotComponentType::Headers);
1742 assert_eq!(urls.len(), 2, "exactly 2 header chunk URLs");
1743 assert_eq!(
1744 urls[0], "https://example.com/mainnet/static_files/headers-0-499999.tar.zst",
1745 "finalized chunk stays under static_files/"
1746 );
1747 assert_eq!(
1748 urls[1], "https://example.com/mainnet/1700000/headers-500000-999999.tar.zst",
1749 "tip chunk resolves under the run timestamp directory"
1750 );
1751
1752 let state = m.snapshot_archives_for_distance(SnapshotComponentType::State, None);
1753 assert_eq!(state.len(), 1, "exactly one state archive");
1754 assert_eq!(
1755 state[0].url, "https://example.com/mainnet/1700000/state.tar.zst",
1756 "single archive file path joins under root base_url without ../"
1757 );
1758 assert_eq!(state[0].file_name, "1700000/state.tar.zst");
1759
1760 let headers =
1761 m.snapshot_archives_for_distance(SnapshotComponentType::Headers, Some(500_000));
1762 assert_eq!(headers.len(), 1, "distance selection returns only the tip chunk");
1763 assert_eq!(
1764 headers[0].file_name, "1700000/headers-500000-999999.tar.zst",
1765 "file_name keeps the relative path from chunk_files"
1766 );
1767 assert_eq!(
1768 headers[0].url,
1769 "https://example.com/mainnet/1700000/headers-500000-999999.tar.zst"
1770 );
1771 }
1772
1773 #[test]
1774 fn chunk_files_absent_keeps_default_chunk_names() {
1775 let m = test_manifest();
1776 let archives = m.snapshot_archives_for_distance(SnapshotComponentType::Transactions, None);
1777 assert_eq!(archives.len(), 3);
1778 assert_eq!(archives[0].file_name, "transactions-0-499999.tar.zst");
1779 assert_eq!(archives[0].url, "https://example.com/transactions-0-499999.tar.zst");
1780 }
1781
1782 #[test]
1783 fn resolve_archive_url_normalizes_parent_dirs_and_leading_slashes() {
1784 assert_eq!(
1785 resolve_archive_url("https://example.com/static_files", "../1700000/state.tar.zst"),
1786 "https://example.com/1700000/state.tar.zst",
1787 "../ under a static_files base resolves to the run directory"
1788 );
1789 assert_eq!(
1790 resolve_archive_url(
1791 "https://example.com/mainnet",
1792 "/static_files/headers-0-499999.tar.zst"
1793 ),
1794 "https://example.com/mainnet/static_files/headers-0-499999.tar.zst",
1795 "leading slash is stripped so the base path prefix is preserved"
1796 );
1797 }
1798
1799 #[test]
1800 fn resolve_archive_url_keeps_absolute_urls() {
1801 let url = "https://example.com/previous/headers-0-9.tar.zst";
1802 for base_url in ["", "https://example.com/mainnet", "not-a-url"] {
1803 assert_eq!(resolve_archive_url(base_url, url), url, "base_url {base_url:?}");
1804 }
1805 }
1806
1807 #[test]
1808 fn mismatched_chunk_files_length_falls_back_to_default_names() {
1809 let mut components = BTreeMap::new();
1810 components.insert(
1811 "headers".to_string(),
1812 ComponentManifest::Chunked(ChunkedArchive {
1813 blocks_per_file: 500_000,
1814 total_blocks: 1_000_000,
1815 chunk_sizes: vec![40_000, 50_000],
1816 chunk_decompressed_sizes: vec![],
1817 chunk_files: vec!["static_files/headers-0-499999.tar.zst".to_string()],
1819 chunk_output_files: vec![vec![], vec![]],
1820 }),
1821 );
1822 let m = SnapshotManifest {
1823 block: 1_000_000,
1824 chain_id: 1,
1825 storage_version: 2,
1826 timestamp: 0,
1827 base_url: Some("https://example.com/mainnet".to_string()),
1828 reth_version: None,
1829 components,
1830 extensions: Default::default(),
1831 };
1832
1833 let ComponentManifest::Chunked(chunked) =
1834 m.component(SnapshotComponentType::Headers).unwrap()
1835 else {
1836 panic!("headers should be chunked");
1837 };
1838 assert!(!chunked.chunk_files_are_consistent());
1839
1840 let urls = m.archive_urls(SnapshotComponentType::Headers);
1841 assert_eq!(urls.len(), 2);
1842 assert_eq!(
1843 urls[0], "https://example.com/mainnet/headers-0-499999.tar.zst",
1844 "mismatched chunk_files must not partially apply"
1845 );
1846 assert_eq!(urls[1], "https://example.com/mainnet/headers-500000-999999.tar.zst");
1847 }
1848
1849 #[test]
1850 fn generate_manifest_uses_actual_component_block_ranges() {
1851 let source = snapshot_source();
1852 let output = tempdir().unwrap();
1853 write_test_static_files(source.path(), StaticFileSegment::Headers, 24);
1854 write_test_static_files(source.path(), StaticFileSegment::Transactions, 21);
1855
1856 let manifest =
1858 generate_manifest(source.path(), output.path(), None, 29, 1, 10, None).unwrap();
1859 assert_eq!(manifest.block, 29);
1860 for (component, expected_blocks) in
1861 [(SnapshotComponentType::Headers, 25), (SnapshotComponentType::Transactions, 22)]
1862 {
1863 let ComponentManifest::Chunked(chunked) = manifest.component(component).unwrap() else {
1864 panic!("component should be chunked");
1865 };
1866
1867 assert_eq!(chunked.total_blocks, expected_blocks);
1868 assert_eq!(chunked.num_chunks(), 3);
1869 assert_eq!(chunked.chunk_sizes.len(), 3);
1870 assert_eq!(chunked.chunk_output_files.len(), 3);
1871 assert_eq!(chunked.tail_chunks_for_distance(6), 2);
1872 for (index, size) in chunked.chunk_sizes.iter().enumerate() {
1873 let path =
1874 output.path().join(chunked.chunk_relative_path(component.key(), index as u64));
1875 assert_eq!(reth_fs_util::metadata(path).unwrap().len(), *size);
1876 }
1877 }
1878 }
1879
1880 #[test]
1881 fn generate_manifest_includes_genesis_and_chunk_boundary_blocks() {
1882 for tip in [0, 9, 10, 19, 20] {
1883 let source = snapshot_source();
1884 let output = tempdir().unwrap();
1885 write_test_static_files(source.path(), StaticFileSegment::Headers, tip);
1886
1887 let manifest =
1888 generate_manifest(source.path(), output.path(), None, tip, 1, 10, None).unwrap();
1889 let ComponentManifest::Chunked(chunked) =
1890 manifest.component(SnapshotComponentType::Headers).unwrap()
1891 else {
1892 panic!("headers should be chunked");
1893 };
1894
1895 assert_eq!(chunked.total_blocks, tip + 1);
1896 assert_eq!(chunked.num_chunks(), tip / 10 + 1);
1897 assert_eq!(chunked.chunk_sizes.len() as u64, chunked.num_chunks());
1898 }
1899 }
1900
1901 #[test]
1902 fn generate_manifest_counts_whole_selected_chunks() {
1903 let source = snapshot_source();
1904 write_test_static_files(source.path(), StaticFileSegment::Headers, 24);
1905
1906 for (block, expected_blocks) in [(9, 10), (10, 20), (20, 25), (39, 25)] {
1908 let output = tempdir().unwrap();
1909 let manifest =
1910 generate_manifest(source.path(), output.path(), None, block, 1, 10, None).unwrap();
1911
1912 let ComponentManifest::Chunked(chunked) =
1913 manifest.component(SnapshotComponentType::Headers).unwrap()
1914 else {
1915 panic!("headers should be chunked");
1916 };
1917
1918 assert_eq!(chunked.total_blocks, expected_blocks);
1919 assert_eq!(chunked.chunk_sizes.len() as u64, chunked.num_chunks());
1920 }
1921 }
1922
1923 #[test]
1924 fn generate_manifest_reuses_sealed_chunks_from_previous_manifest() {
1925 let previous_source = snapshot_source();
1926 let previous_output = tempdir().unwrap();
1927 write_test_static_files(previous_source.path(), StaticFileSegment::Headers, 24);
1928 let previous = generate_manifest(
1929 previous_source.path(),
1930 previous_output.path(),
1931 Some("https://example.com/previous"),
1932 24,
1933 1,
1934 10,
1935 None,
1936 )
1937 .unwrap();
1938
1939 let source = snapshot_source();
1940 let output = tempdir().unwrap();
1941 write_test_static_files(source.path(), StaticFileSegment::Headers, 34);
1942 let manifest =
1943 generate_manifest(source.path(), output.path(), None, 34, 1, 10, Some(&previous))
1944 .unwrap();
1945
1946 let ComponentManifest::Chunked(previous_headers) =
1947 previous.component(SnapshotComponentType::Headers).unwrap()
1948 else {
1949 panic!("headers should be chunked");
1950 };
1951
1952 let ComponentManifest::Chunked(headers) =
1953 manifest.component(SnapshotComponentType::Headers).unwrap()
1954 else {
1955 panic!("headers should be chunked");
1956 };
1957
1958 assert_eq!(
1960 manifest.archive_urls(SnapshotComponentType::Headers),
1961 vec![
1962 "https://example.com/previous/headers-0-9.tar.zst",
1963 "https://example.com/previous/headers-10-19.tar.zst",
1964 "headers-20-29.tar.zst",
1965 "headers-30-39.tar.zst",
1966 ]
1967 );
1968 assert_eq!(headers.chunk_sizes[..2], previous_headers.chunk_sizes[..2]);
1969 assert_eq!(headers.chunk_output_files[..2], previous_headers.chunk_output_files[..2]);
1970 assert!(!output.path().join("headers-0-9.tar.zst").exists());
1971 assert!(!output.path().join("headers-10-19.tar.zst").exists());
1972 for index in 2..4 {
1973 let path = output.path().join(headers.chunk_relative_path("headers", index));
1974 assert_eq!(
1975 reth_fs_util::metadata(path).unwrap().len(),
1976 headers.chunk_sizes[index as usize]
1977 );
1978 }
1979
1980 let mut manifest = manifest;
1982 manifest.base_url = Some("https://example.com/current".to_string());
1983 let next_source = snapshot_source();
1984 write_test_static_files(next_source.path(), StaticFileSegment::Headers, 44);
1985 let next = generate_manifest(
1986 next_source.path(),
1987 tempdir().unwrap().path(),
1988 None,
1989 44,
1990 1,
1991 10,
1992 Some(&manifest),
1993 )
1994 .unwrap();
1995 assert_eq!(
1996 next.archive_urls(SnapshotComponentType::Headers),
1997 vec![
1998 "https://example.com/previous/headers-0-9.tar.zst",
1999 "https://example.com/previous/headers-10-19.tar.zst",
2000 "https://example.com/current/headers-20-29.tar.zst",
2001 "headers-30-39.tar.zst",
2002 "headers-40-49.tar.zst",
2003 ]
2004 );
2005 }
2006
2007 #[test]
2008 fn generate_manifest_rejects_sealed_chunk_that_differs_from_previous_manifest() {
2009 let source = snapshot_source();
2010 write_test_static_files(source.path(), StaticFileSegment::Headers, 24);
2011 let mut previous = generate_manifest(
2012 source.path(),
2013 tempdir().unwrap().path(),
2014 Some("https://example.com/previous"),
2015 24,
2016 1,
2017 10,
2018 None,
2019 )
2020 .unwrap();
2021 let Some(ComponentManifest::Chunked(headers)) =
2022 previous.components.get_mut(SnapshotComponentType::Headers.key())
2023 else {
2024 panic!("headers should be chunked");
2025 };
2026
2027 headers.chunk_output_files[0][0].blake3 = "00".repeat(32);
2028
2029 let err = generate_manifest(
2030 source.path(),
2031 tempdir().unwrap().path(),
2032 None,
2033 24,
2034 1,
2035 10,
2036 Some(&previous),
2037 )
2038 .unwrap_err();
2039 assert!(err.to_string().contains("differs from the previous manifest"), "{err}");
2040 }
2041
2042 #[test]
2043 fn generate_manifest_rejects_previous_manifest_without_absolute_base_url() {
2044 let source = snapshot_source();
2045 write_test_static_files(source.path(), StaticFileSegment::Headers, 24);
2046 let mut previous = generate_manifest(
2047 source.path(),
2048 tempdir().unwrap().path(),
2049 Some("https://example.com/previous"),
2050 24,
2051 1,
2052 10,
2053 None,
2054 )
2055 .unwrap();
2056
2057 for base_url in [None, Some(""), Some("previous")] {
2058 previous.base_url = base_url.map(str::to_owned);
2059 let err = generate_manifest(
2060 source.path(),
2061 tempdir().unwrap().path(),
2062 None,
2063 24,
2064 1,
2065 10,
2066 Some(&previous),
2067 )
2068 .unwrap_err();
2069 assert_eq!(
2070 err.to_string(),
2071 "Previous manifest has no absolute base_url to locate its archives"
2072 );
2073 }
2074 }
2075
2076 #[test]
2077 fn generate_manifest_packages_chunks_when_previous_chunk_files_are_inconsistent() {
2078 let source = snapshot_source();
2079 write_test_static_files(source.path(), StaticFileSegment::Headers, 24);
2080 let mut previous = generate_manifest(
2081 source.path(),
2082 tempdir().unwrap().path(),
2083 Some("https://example.com/previous"),
2084 24,
2085 1,
2086 10,
2087 None,
2088 )
2089 .unwrap();
2090 let Some(ComponentManifest::Chunked(headers)) =
2091 previous.components.get_mut(SnapshotComponentType::Headers.key())
2092 else {
2093 panic!("headers should be chunked");
2094 };
2095
2096 headers.chunk_files.truncate(1);
2097
2098 let output = tempdir().unwrap();
2099 let manifest =
2100 generate_manifest(source.path(), output.path(), None, 24, 1, 10, Some(&previous))
2101 .unwrap();
2102 assert_eq!(
2103 manifest.archive_urls(SnapshotComponentType::Headers),
2104 vec!["headers-0-9.tar.zst", "headers-10-19.tar.zst", "headers-20-29.tar.zst"]
2105 );
2106 assert!(output.path().join("headers-0-9.tar.zst").exists());
2107 }
2108
2109 #[test]
2110 fn generate_manifest_packages_chunks_when_previous_blocks_per_file_is_zero() {
2111 let source = snapshot_source();
2112 write_test_static_files(source.path(), StaticFileSegment::Headers, 24);
2113 let mut previous = generate_manifest(
2114 source.path(),
2115 tempdir().unwrap().path(),
2116 Some("https://example.com/previous"),
2117 24,
2118 1,
2119 10,
2120 None,
2121 )
2122 .unwrap();
2123 let Some(ComponentManifest::Chunked(headers)) =
2124 previous.components.get_mut(SnapshotComponentType::Headers.key())
2125 else {
2126 panic!("headers should be chunked");
2127 };
2128
2129 headers.blocks_per_file = 0;
2130
2131 let output = tempdir().unwrap();
2132 let manifest =
2133 generate_manifest(source.path(), output.path(), None, 24, 1, 10, Some(&previous))
2134 .unwrap();
2135 assert_eq!(
2136 manifest.archive_urls(SnapshotComponentType::Headers),
2137 vec!["headers-0-9.tar.zst", "headers-10-19.tar.zst", "headers-20-29.tar.zst"]
2138 );
2139 assert!(output.path().join("headers-0-9.tar.zst").exists());
2140 }
2141}