Skip to main content

reth_cli_commands/download/
manifest.rs

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/// A snapshot manifest describes available components for a snapshot at a given block height.
19///
20/// Each component is either a single archive (state) or a set of chunked archives (static file
21/// segments like transactions, receipts, etc). Chunked components use `blocks_per_file` to
22/// define the block range per archive, matching reth's static file segment boundaries.
23///
24/// Archive paths are resolved relative to [`SnapshotManifest::base_url`] via URL joining.
25/// Single archives use [`SingleArchive::file`]; chunked archives default to
26/// `{component}-{start_block}-{end_block}.tar.zst`, or use [`ChunkedArchive::chunk_files`]
27/// when present so publishers can place finalized and tip chunks under different prefixes:
28///
29/// ```text
30/// base_url = https://example.com/mainnet
31///   static_files/transactions-0-499999.tar.zst
32///   1700000/transactions-500000-999999.tar.zst
33///   1700000/state.tar.zst
34/// ```
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SnapshotManifest {
37    /// Block number this snapshot was taken at.
38    pub block: u64,
39    /// Chain ID.
40    pub chain_id: u64,
41    /// Storage version (1 = legacy, 2 = current).
42    pub storage_version: u64,
43    /// Timestamp when the snapshot was created (unix seconds).
44    pub timestamp: u64,
45    /// Base URL for archive downloads. Component archive URLs are relative to this.
46    ///
47    /// When omitted, downloaders should derive the base URL from the manifest URL.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub base_url: Option<String>,
50    /// Reth version that produced this snapshot.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub reth_version: Option<String>,
53    /// Available snapshot components.
54    pub components: BTreeMap<String, ComponentManifest>,
55    /// Chain-specific manifest fields not interpreted by Reth.
56    ///
57    /// Extensions are retained so downstream commands can consume snapshot metadata without
58    /// refetching or reparsing the manifest selected by Reth.
59    #[serde(default, flatten)]
60    pub extensions: BTreeMap<String, serde_json::Value>,
61}
62
63/// Manifest entry for a single snapshot component.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(untagged)]
66pub enum ComponentManifest {
67    /// A single archive file (used for state).
68    Single(SingleArchive),
69    /// A set of chunked archives split by block range (used for static file segments).
70    Chunked(ChunkedArchive),
71}
72
73/// A single, non-chunked archive.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct SingleArchive {
76    /// Archive file name (relative to base_url).
77    ///
78    /// Must be a relative path (no leading `/`). Nested paths like `1700000/state.tar.zst`
79    /// are supported when `base_url` points at the snapshot root.
80    pub file: String,
81    /// Compressed archive size in bytes.
82    pub size: u64,
83    /// Total extracted plain-output size in bytes.
84    ///
85    /// Older manifests may omit this, in which case downloaders should derive it from
86    /// `output_files`.
87    #[serde(default, skip_serializing_if = "is_zero")]
88    pub decompressed_size: u64,
89    /// Optional BLAKE3 checksum of the compressed archive.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub blake3: Option<String>,
92    /// Expected extracted plain files for this archive.
93    ///
94    /// This is the authoritative integrity source for the modular download path.
95    #[serde(default)]
96    pub output_files: Vec<OutputFileChecksum>,
97}
98
99/// A chunked archive set where each chunk covers a fixed block range.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct ChunkedArchive {
102    /// Number of blocks per archive file. Matches reth's `blocks_per_file` config.
103    pub blocks_per_file: u64,
104    /// Total number of blocks covered by this component.
105    pub total_blocks: u64,
106    /// Compressed size of each chunk in bytes, ordered from first to last.
107    /// Computed during manifest generation. Older manifests may omit this.
108    #[serde(default)]
109    pub chunk_sizes: Vec<u64>,
110    /// Extracted plain-output size of each chunk in bytes, ordered from first to last.
111    ///
112    /// Older manifests may omit this, in which case downloaders should derive it from
113    /// `chunk_output_files`.
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub chunk_decompressed_sizes: Vec<u64>,
116    /// Archive path for each chunk, relative to [`SnapshotManifest::base_url`], ordered from
117    /// first to last.
118    ///
119    /// When empty (older manifests), downloaders fall back to the default
120    /// `{component}-{start}-{end}.tar.zst` name. When set, the length must equal the chunk
121    /// count and each entry is joined with `base_url` so publishers can place chunks under
122    /// different prefixes (for example `static_files/…` for finalized chunks and
123    /// `{timestamp}/…` for the tip chunk).
124    ///
125    /// Paths must be relative (no leading `/`). A leading slash is treated as host-absolute by
126    /// URL joining and would drop the `base_url` path prefix.
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    pub chunk_files: Vec<String>,
129    /// Expected extracted plain files per chunk, ordered from first to last.
130    ///
131    /// This is the authoritative integrity source for the modular download path.
132    #[serde(default)]
133    pub chunk_output_files: Vec<Vec<OutputFileChecksum>>,
134}
135
136/// Expected metadata for one extracted plain file.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct OutputFileChecksum {
139    /// Relative path under the target datadir where this file is extracted.
140    pub path: String,
141    /// Plain file size in bytes.
142    pub size: u64,
143    /// BLAKE3 checksum of the plain file contents.
144    pub blake3: String,
145}
146
147/// A concrete snapshot archive with its download and verification metadata.
148#[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    /// Returns the total extracted plain-output size for this archive.
159    pub fn output_size(&self) -> u64 {
160        self.output_files.iter().map(|file| file.size).sum()
161    }
162}
163
164/// How much of a component to download.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum ComponentSelection {
167    /// Download all chunks (full archive).
168    All,
169    /// Download only the most recent chunks covering at least `distance` blocks.
170    /// Maps to `PruneMode::Distance(distance)` in the generated config.
171    Distance(u64),
172    /// Download chunks starting at the specified block number.
173    /// Maps to `PruneMode::Before(block)` in the generated config.
174    Since(u64),
175    /// Don't download this component at all.
176    /// Maps to `PruneMode::Full` for tx-based segments, or a minimal distance for others.
177    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/// The types of snapshot components that can be downloaded.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
193pub enum SnapshotComponentType {
194    /// State database (mdbx). Always required. Single archive.
195    State,
196    /// Block headers static files. Chunked.
197    Headers,
198    /// Transaction static files. Chunked.
199    Transactions,
200    /// Transaction sender static files. Chunked. Only downloaded for archive nodes.
201    TransactionSenders,
202    /// Receipt static files. Chunked.
203    Receipts,
204    /// Account changeset static files. Chunked.
205    AccountChangesets,
206    /// Storage changeset static files. Chunked.
207    StorageChangesets,
208    /// RocksDB index files. Single archive. Optional and archive-only.
209    RocksdbIndices,
210}
211
212impl SnapshotComponentType {
213    /// All component types in display order.
214    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    /// The string key used in the manifest JSON.
226    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    /// Human-readable display name.
240    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    /// Whether this component is always required for a functional node.
254    ///
255    /// State and headers are always needed — a node cannot operate without block headers.
256    pub const fn is_required(&self) -> bool {
257        matches!(self, Self::State | Self::Headers)
258    }
259
260    /// Returns the default selection for this component in the minimal download preset.
261    ///
262    /// Matches the `--minimal` prune configuration:
263    /// - State/Headers: always All (required)
264    /// - Transactions/Changesets: Distance(10_064) (`MINIMUM_UNWIND_SAFE_DISTANCE`)
265    /// - Receipts: Distance(64) (`MINIMUM_DISTANCE`)
266    /// - TransactionSenders: None (only downloaded for archive nodes)
267    /// - RocksdbIndices: None (only downloaded for archive nodes)
268    ///
269    /// `tx_lookup` and `sender_recovery` are always pruned full regardless.
270    pub const fn minimal_selection(&self) -> ComponentSelection {
271        match self {
272            Self::State | Self::Headers => ComponentSelection::All,
273            Self::Transactions | Self::AccountChangesets | Self::StorageChangesets => {
274                ComponentSelection::Distance(10_064)
275            }
276            Self::Receipts => ComponentSelection::Distance(64),
277            Self::TransactionSenders => ComponentSelection::None,
278            Self::RocksdbIndices => ComponentSelection::None,
279        }
280    }
281
282    /// Whether this component type uses chunked archives.
283    pub const fn is_chunked(&self) -> bool {
284        !matches!(self, Self::State | Self::RocksdbIndices)
285    }
286}
287
288impl SnapshotManifest {
289    fn base_url_or_empty(&self) -> &str {
290        self.base_url.as_deref().unwrap_or("")
291    }
292
293    /// Look up a component by type.
294    pub fn component(&self, ty: SnapshotComponentType) -> Option<&ComponentManifest> {
295        self.components.get(ty.key())
296    }
297
298    /// Returns the total download size for the given set of component types.
299    pub fn total_size(&self, types: &[SnapshotComponentType]) -> u64 {
300        types.iter().filter_map(|ty| self.component(*ty).map(|c| c.total_size())).sum()
301    }
302
303    /// Returns all archive URLs for a given component type.
304    pub fn archive_urls(&self, ty: SnapshotComponentType) -> Vec<String> {
305        let Some(component) = self.component(ty) else {
306            return vec![];
307        };
308
309        match component {
310            ComponentManifest::Single(single) => {
311                vec![resolve_archive_url(self.base_url_or_empty(), &single.file)]
312            }
313            ComponentManifest::Chunked(chunked) => {
314                let key = ty.key();
315                let num_chunks = chunked.num_chunks();
316                (0..num_chunks)
317                    .map(|i| {
318                        resolve_archive_url(
319                            self.base_url_or_empty(),
320                            &chunked.chunk_relative_path(key, i),
321                        )
322                    })
323                    .collect()
324            }
325        }
326    }
327
328    /// Returns archive URLs for a component, limited to chunks covering at least `distance`
329    /// blocks from the tip. Returns all URLs if distance is `None` (All mode).
330    pub fn archive_urls_for_distance(
331        &self,
332        ty: SnapshotComponentType,
333        distance: Option<u64>,
334    ) -> Vec<String> {
335        let Some(component) = self.component(ty) else {
336            return vec![];
337        };
338
339        match component {
340            ComponentManifest::Single(single) => {
341                vec![resolve_archive_url(self.base_url_or_empty(), &single.file)]
342            }
343            ComponentManifest::Chunked(chunked) => {
344                let key = ty.key();
345                let num_chunks = chunked.num_chunks();
346
347                // Calculate which chunks to include
348                let start_chunk = match distance {
349                    // Include the tail chunks that cover at least `dist` blocks.
350                    Some(dist) => num_chunks.saturating_sub(chunked.tail_chunks_for_distance(dist)),
351                    None => 0, // All chunks
352                };
353
354                (start_chunk..num_chunks)
355                    .map(|i| {
356                        resolve_archive_url(
357                            self.base_url_or_empty(),
358                            &chunked.chunk_relative_path(key, i),
359                        )
360                    })
361                    .collect()
362            }
363        }
364    }
365
366    /// Returns concrete snapshot archives for a component, optionally limited to distance.
367    pub fn snapshot_archives_for_distance(
368        &self,
369        ty: SnapshotComponentType,
370        distance: Option<u64>,
371    ) -> Vec<SnapshotArchive> {
372        let Some(component) = self.component(ty) else {
373            return vec![];
374        };
375
376        match component {
377            ComponentManifest::Single(single) => {
378                vec![SnapshotArchive {
379                    url: resolve_archive_url(self.base_url_or_empty(), &single.file),
380                    file_name: single.file.clone(),
381                    size: single.size,
382                    blake3: single.blake3.clone(),
383                    output_files: single.output_files.clone(),
384                }]
385            }
386            ComponentManifest::Chunked(chunked) => {
387                let key = ty.key();
388                let num_chunks = chunked.num_chunks();
389
390                let start_chunk = match distance {
391                    Some(dist) => num_chunks.saturating_sub(chunked.tail_chunks_for_distance(dist)),
392                    None => 0,
393                };
394
395                (start_chunk..num_chunks)
396                    .map(|i| {
397                        let file_name = chunked.chunk_relative_path(key, i);
398                        let size = chunked.chunk_sizes.get(i as usize).copied().unwrap_or_default();
399                        let output_files =
400                            chunked.chunk_output_files.get(i as usize).cloned().unwrap_or_default();
401
402                        SnapshotArchive {
403                            url: resolve_archive_url(self.base_url_or_empty(), &file_name),
404                            file_name,
405                            size,
406                            blake3: None,
407                            output_files,
408                        }
409                    })
410                    .collect()
411            }
412        }
413    }
414
415    /// Returns the exact download size for a component given a distance selection.
416    ///
417    /// For single archives, returns the full size. For chunked archives, sums the
418    /// sizes of the selected tail chunks from [`ChunkedArchive::chunk_sizes`].
419    pub fn size_for_distance(&self, ty: SnapshotComponentType, distance: Option<u64>) -> u64 {
420        let Some(component) = self.component(ty) else {
421            return 0;
422        };
423        match component {
424            ComponentManifest::Single(s) => s.size,
425            ComponentManifest::Chunked(chunked) => {
426                if chunked.chunk_sizes.is_empty() {
427                    return 0;
428                }
429                let num_chunks = chunked.chunk_sizes.len() as u64;
430                let start_chunk = match distance {
431                    Some(dist) => num_chunks.saturating_sub(chunked.tail_chunks_for_distance(dist)),
432                    None => 0,
433                };
434                chunked.chunk_sizes[start_chunk as usize..].iter().sum()
435            }
436        }
437    }
438
439    /// Returns the exact extracted plain-output size for a component given a distance selection.
440    pub fn output_size_for_distance(
441        &self,
442        ty: SnapshotComponentType,
443        distance: Option<u64>,
444    ) -> u64 {
445        let Some(component) = self.component(ty) else {
446            return 0;
447        };
448
449        match component {
450            ComponentManifest::Single(single) => single.output_size(),
451            ComponentManifest::Chunked(chunked) => {
452                let num_chunks = chunked.num_chunks();
453                let start_chunk = match distance {
454                    Some(dist) => num_chunks.saturating_sub(chunked.tail_chunks_for_distance(dist)),
455                    None => 0,
456                };
457
458                (start_chunk..num_chunks)
459                    .map(|index| chunked.chunk_output_size(index as usize))
460                    .sum()
461            }
462        }
463    }
464
465    /// Returns the number of chunks that would be downloaded for a given distance.
466    pub fn chunks_for_distance(&self, ty: SnapshotComponentType, distance: Option<u64>) -> u64 {
467        let Some(ComponentManifest::Chunked(chunked)) = self.component(ty) else {
468            return if self.component(ty).is_some() { 1 } else { 0 };
469        };
470        match distance {
471            Some(dist) => chunked.tail_chunks_for_distance(dist),
472            None => chunked.num_chunks(),
473        }
474    }
475}
476
477impl ComponentManifest {
478    /// Returns the total download size for this component.
479    pub fn total_size(&self) -> u64 {
480        match self {
481            Self::Single(s) => s.size,
482            Self::Chunked(c) => c.chunk_sizes.iter().sum(),
483        }
484    }
485
486    /// Returns the total extracted plain-output size for this component.
487    pub fn total_output_size(&self) -> u64 {
488        match self {
489            Self::Single(single) => single.output_size(),
490            Self::Chunked(chunked) => chunked.total_output_size(),
491        }
492    }
493}
494
495impl ChunkedArchive {
496    /// Returns the number of chunks.
497    pub fn num_chunks(&self) -> u64 {
498        self.total_blocks.div_ceil(self.blocks_per_file)
499    }
500
501    /// Returns the number of tail chunks required to cover at least `distance` blocks from the
502    /// tip.
503    pub fn tail_chunks_for_distance(&self, distance: u64) -> u64 {
504        let needed = distance.min(self.total_blocks);
505        if needed == 0 {
506            return 0;
507        }
508
509        // The first needed block determines the earliest chunk, including for a partial tail.
510        let first_chunk = (self.total_blocks - needed) / self.blocks_per_file;
511        self.num_chunks() - first_chunk
512    }
513
514    /// Returns `true` when `chunk_files` is empty or has exactly one path per chunk.
515    pub fn chunk_files_are_consistent(&self) -> bool {
516        self.chunk_files.is_empty() || self.chunk_files.len() as u64 == self.num_chunks()
517    }
518
519    /// Returns the archive path for chunk `index`, relative to the manifest base URL.
520    ///
521    /// Uses [`Self::chunk_files`] when it has exactly one entry per chunk; otherwise the
522    /// default `{key}-{start}-{end}.tar.zst` name.
523    pub fn chunk_relative_path(&self, key: &str, index: u64) -> String {
524        if self.chunk_files.len() as u64 == self.num_chunks() &&
525            let Some(path) = self.chunk_files.get(index as usize)
526        {
527            return path.clone();
528        }
529        let start = index * self.blocks_per_file;
530        let end = (index + 1) * self.blocks_per_file - 1;
531        format!("{key}-{start}-{end}.tar.zst")
532    }
533
534    /// Returns the extracted plain-output size for one chunk.
535    pub fn chunk_output_size(&self, index: usize) -> u64 {
536        self.chunk_decompressed_sizes.get(index).copied().unwrap_or_else(|| {
537            self.chunk_output_files
538                .get(index)
539                .map(|files| files.iter().map(|file| file.size).sum())
540                .unwrap_or(0)
541        })
542    }
543
544    /// Returns the total extracted plain-output size across all chunks.
545    pub fn total_output_size(&self) -> u64 {
546        if !self.chunk_decompressed_sizes.is_empty() {
547            self.chunk_decompressed_sizes.iter().sum()
548        } else {
549            self.chunk_output_files
550                .iter()
551                .map(|files| files.iter().map(|file| file.size).sum::<u64>())
552                .sum()
553        }
554    }
555}
556
557/// Joins an archive path relative to `base_url`.
558///
559/// Ensures directory semantics for `base_url` so the last path segment is not replaced when
560/// joining nested paths like `static_files/headers-0-499999.tar.zst`. Leading slashes on
561/// `relative_path` are stripped so publisher paths stay relative to the base prefix.
562fn resolve_archive_url(base_url: &str, relative_path: &str) -> String {
563    let relative_path = relative_path.trim_start_matches('/');
564
565    if base_url.is_empty() {
566        return relative_path.to_string();
567    }
568
569    let Ok(mut base) = Url::parse(base_url) else {
570        return format!("{}/{}", base_url.trim_end_matches('/'), relative_path);
571    };
572
573    // Url::join replaces the final path segment unless the base path ends with `/`.
574    let path = base.path();
575    if !path.ends_with('/') {
576        let mut with_slash = path.to_string();
577        with_slash.push('/');
578        base.set_path(&with_slash);
579    }
580
581    match base.join(relative_path) {
582        Ok(joined) => joined.to_string(),
583        Err(_) => format!("{}/{}", base_url.trim_end_matches('/'), relative_path),
584    }
585}
586
587impl SingleArchive {
588    /// Returns the total extracted plain-output size for this archive.
589    pub fn output_size(&self) -> u64 {
590        if self.decompressed_size != 0 {
591            self.decompressed_size
592        } else {
593            self.output_files.iter().map(|file| file.size).sum()
594        }
595    }
596}
597
598/// Fetch a snapshot manifest from a URL.
599pub async fn fetch_manifest(manifest_url: &str) -> Result<SnapshotManifest> {
600    let client = Client::new();
601    let manifest: SnapshotManifest =
602        client.get(manifest_url).send().await?.error_for_status()?.json().await?;
603    Ok(manifest)
604}
605
606/// Package chunk archives from a source datadir and generate a manifest.
607pub fn generate_manifest(
608    source_datadir: &Path,
609    output_dir: &Path,
610    base_url: Option<&str>,
611    block: u64,
612    chain_id: u64,
613    blocks_per_file: u64,
614) -> Result<SnapshotManifest> {
615    std::fs::create_dir_all(output_dir)?;
616
617    let mut components = BTreeMap::new();
618
619    // Package chunked static-file components.
620    for ty in &[
621        SnapshotComponentType::Headers,
622        SnapshotComponentType::Transactions,
623        SnapshotComponentType::TransactionSenders,
624        SnapshotComponentType::Receipts,
625        SnapshotComponentType::AccountChangesets,
626        SnapshotComponentType::StorageChangesets,
627    ] {
628        let key = ty.key();
629        let num_chunks = block.div_ceil(blocks_per_file);
630        let mut planned_chunks = Vec::with_capacity(num_chunks as usize);
631        let mut found_any = false;
632
633        for i in 0..num_chunks {
634            let start = i * blocks_per_file;
635            let end = (i + 1) * blocks_per_file - 1;
636            let source_files = source_files_for_chunk(source_datadir, *ty, start, end)?;
637
638            if source_files.is_empty() {
639                if found_any {
640                    eyre::bail!("Missing source files for {} chunk {}-{}", key, start, end);
641                }
642                continue;
643            }
644
645            found_any = true;
646            planned_chunks.push(PlannedChunk {
647                chunk_idx: i,
648                archive_path: output_dir.join(chunk_filename(key, start, end)),
649                source_files,
650            });
651        }
652
653        if found_any {
654            let mut packaged_chunks = planned_chunks
655                .into_par_iter()
656                .map(|planned| -> Result<PackagedChunk> {
657                    let output_files =
658                        write_chunk_archive(&planned.archive_path, &planned.source_files)?;
659                    let size = std::fs::metadata(&planned.archive_path)?.len();
660                    Ok(PackagedChunk { chunk_idx: planned.chunk_idx, size, output_files })
661                })
662                .collect::<Vec<_>>()
663                .into_iter()
664                .collect::<Result<Vec<_>>>()?;
665
666            packaged_chunks.sort_unstable_by_key(|chunk| chunk.chunk_idx);
667            let chunk_sizes = packaged_chunks.iter().map(|chunk| chunk.size).collect::<Vec<_>>();
668            let chunk_output_files =
669                packaged_chunks.into_iter().map(|chunk| chunk.output_files).collect::<Vec<_>>();
670            let total_size: u64 = chunk_sizes.iter().sum();
671            info!(target: "reth::cli",
672                component = ty.display_name(),
673                chunks = chunk_sizes.len(),
674                total_blocks = block,
675                size = %super::DownloadProgress::format_size(total_size),
676                "Found chunked component"
677            );
678            components.insert(
679                key.to_string(),
680                ComponentManifest::Chunked(ChunkedArchive {
681                    blocks_per_file,
682                    total_blocks: block,
683                    chunk_sizes,
684                    chunk_decompressed_sizes: chunk_output_files
685                        .iter()
686                        .map(|files| files.iter().map(|file| file.size).sum())
687                        .collect(),
688                    chunk_files: vec![],
689                    chunk_output_files,
690                }),
691            );
692        }
693    }
694
695    let (state_size, state_output_files) = package_single_component(
696        output_dir,
697        "state.tar.zst",
698        &state_source_files(source_datadir)?,
699    )?;
700    components.insert(
701        SnapshotComponentType::State.key().to_string(),
702        ComponentManifest::Single(SingleArchive {
703            file: "state.tar.zst".to_string(),
704            size: state_size,
705            decompressed_size: state_output_files.iter().map(|file| file.size).sum(),
706            blake3: None,
707            output_files: state_output_files,
708        }),
709    );
710
711    let rocksdb_files = rocksdb_source_files(source_datadir)?;
712    if !rocksdb_files.is_empty() {
713        let (rocksdb_size, rocksdb_output_files) =
714            package_single_component(output_dir, "rocksdb_indices.tar.zst", &rocksdb_files)?;
715        components.insert(
716            SnapshotComponentType::RocksdbIndices.key().to_string(),
717            ComponentManifest::Single(SingleArchive {
718                file: "rocksdb_indices.tar.zst".to_string(),
719                size: rocksdb_size,
720                decompressed_size: rocksdb_output_files.iter().map(|file| file.size).sum(),
721                blake3: None,
722                output_files: rocksdb_output_files,
723            }),
724        );
725    }
726
727    let timestamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs();
728
729    Ok(SnapshotManifest {
730        block,
731        chain_id,
732        storage_version: 2,
733        timestamp,
734        base_url: base_url.map(str::to_owned),
735        reth_version: Some(reth_node_core::version::version_metadata().short_version.to_string()),
736        components,
737        extensions: Default::default(),
738    })
739}
740
741/// Resolves an archive file path from a component key and naming convention.
742pub fn chunk_filename(component_key: &str, start: u64, end: u64) -> String {
743    format!("{component_key}-{start}-{end}.tar.zst")
744}
745
746#[derive(Debug)]
747struct PlannedChunk {
748    chunk_idx: u64,
749    archive_path: PathBuf,
750    source_files: Vec<PathBuf>,
751}
752
753#[derive(Debug)]
754struct PackagedChunk {
755    chunk_idx: u64,
756    size: u64,
757    output_files: Vec<OutputFileChecksum>,
758}
759
760#[derive(Debug)]
761struct PlannedFile {
762    source_path: PathBuf,
763    relative_path: PathBuf,
764}
765
766fn source_files_for_chunk(
767    source_datadir: &Path,
768    component: SnapshotComponentType,
769    start: u64,
770    end: u64,
771) -> Result<Vec<PathBuf>> {
772    let Some(segment_name) = static_segment_name(component) else {
773        return Ok(Vec::new());
774    };
775
776    let static_files_dir = source_datadir.join("static_files");
777    let static_files_dir =
778        if static_files_dir.exists() { static_files_dir } else { source_datadir.to_path_buf() };
779    let prefix = format!("static_file_{segment_name}_{start}_{end}");
780
781    let mut files = Vec::new();
782    for entry in std::fs::read_dir(&static_files_dir)? {
783        let entry = entry?;
784        if !entry.file_type()?.is_file() {
785            continue;
786        }
787        if entry.file_name().to_string_lossy().starts_with(&prefix) {
788            files.push(entry.path());
789        }
790    }
791
792    files.sort_unstable();
793    Ok(files)
794}
795
796fn static_segment_name(component: SnapshotComponentType) -> Option<&'static str> {
797    match component {
798        SnapshotComponentType::Headers => Some("headers"),
799        SnapshotComponentType::Transactions => Some("transactions"),
800        SnapshotComponentType::TransactionSenders => Some("transaction-senders"),
801        SnapshotComponentType::Receipts => Some("receipts"),
802        SnapshotComponentType::AccountChangesets => Some("account-change-sets"),
803        SnapshotComponentType::StorageChangesets => Some("storage-change-sets"),
804        SnapshotComponentType::State | SnapshotComponentType::RocksdbIndices => None,
805    }
806}
807
808fn state_source_files(source_datadir: &Path) -> Result<Vec<PlannedFile>> {
809    let db_dir = source_datadir.join("db");
810    if db_dir.exists() {
811        return collect_files_recursive(&db_dir, Path::new("db"));
812    }
813
814    if looks_like_db_dir(source_datadir)? {
815        return collect_files_recursive(source_datadir, Path::new("db"));
816    }
817
818    eyre::bail!("Could not find source state DB directory under {}", source_datadir.display());
819}
820
821fn rocksdb_source_files(source_datadir: &Path) -> Result<Vec<PlannedFile>> {
822    let rocksdb_dir = source_datadir.join("rocksdb");
823    if !rocksdb_dir.exists() {
824        return Ok(Vec::new());
825    }
826
827    collect_files_recursive(&rocksdb_dir, Path::new("rocksdb"))
828}
829
830fn looks_like_db_dir(path: &Path) -> Result<bool> {
831    let entries = match std::fs::read_dir(path) {
832        Ok(entries) => entries,
833        Err(_) => return Ok(false),
834    };
835
836    for entry in entries {
837        let entry = entry?;
838        if !entry.file_type()?.is_file() {
839            continue;
840        }
841        let name = entry.file_name();
842        let name = name.to_string_lossy();
843        if name == "mdbx.dat" || name == "lock.mdb" || name == "data.mdb" {
844            return Ok(true);
845        }
846    }
847
848    Ok(false)
849}
850
851fn collect_files_recursive(root: &Path, output_prefix: &Path) -> Result<Vec<PlannedFile>> {
852    let mut files = Vec::new();
853    collect_files_recursive_inner(root, root, output_prefix, &mut files)?;
854    files.sort_unstable_by(|a, b| a.relative_path.cmp(&b.relative_path));
855    Ok(files)
856}
857
858fn collect_files_recursive_inner(
859    root: &Path,
860    dir: &Path,
861    output_prefix: &Path,
862    files: &mut Vec<PlannedFile>,
863) -> Result<()> {
864    for entry in std::fs::read_dir(dir)? {
865        let entry = entry?;
866        let path = entry.path();
867        let file_type = entry.file_type()?;
868        if file_type.is_dir() {
869            collect_files_recursive_inner(root, &path, output_prefix, files)?;
870            continue;
871        }
872        if !file_type.is_file() {
873            continue;
874        }
875
876        let relative = path.strip_prefix(root)?.to_path_buf();
877        files.push(PlannedFile { source_path: path, relative_path: output_prefix.join(relative) });
878    }
879
880    Ok(())
881}
882
883fn package_single_component(
884    output_dir: &Path,
885    archive_file_name: &str,
886    files: &[PlannedFile],
887) -> Result<(u64, Vec<OutputFileChecksum>)> {
888    if files.is_empty() {
889        eyre::bail!("Cannot package empty single archive: {}", archive_file_name);
890    }
891
892    let archive_path = output_dir.join(archive_file_name);
893    let output_files = write_archive_from_planned_files(&archive_path, files)?;
894    let size = std::fs::metadata(&archive_path)?.len();
895    Ok((size, output_files))
896}
897
898fn write_chunk_archive(path: &Path, source_files: &[PathBuf]) -> Result<Vec<OutputFileChecksum>> {
899    let planned_files = source_files
900        .iter()
901        .map(|source_path| {
902            let file_name = source_path.file_name().ok_or_else(|| {
903                eyre::eyre!("Invalid source file path: {}", source_path.display())
904            })?;
905            Ok::<_, eyre::Error>(PlannedFile {
906                source_path: source_path.clone(),
907                relative_path: PathBuf::from("static_files").join(file_name),
908            })
909        })
910        .collect::<Result<Vec<_>>>()?;
911
912    write_archive_from_planned_files(path, &planned_files)
913}
914
915fn write_archive_from_planned_files(
916    path: &Path,
917    files: &[PlannedFile],
918) -> Result<Vec<OutputFileChecksum>> {
919    let file = std::fs::File::create(path)?;
920    let mut encoder = zstd::Encoder::new(file, 0)?;
921    // Emit standard zstd frames with checksums for compatibility with external
922    // tools such as `pzstd -d`.
923    encoder.include_checksum(true)?;
924    let mut builder = tar::Builder::new(encoder);
925
926    let mut output_files = Vec::with_capacity(files.len());
927    for planned in files {
928        let mut header = tar::Header::new_gnu();
929        header.set_size(std::fs::metadata(&planned.source_path)?.len());
930        header.set_mode(0o644);
931        header.set_cksum();
932
933        let source_file = std::fs::File::open(&planned.source_path)?;
934        let mut reader = HashingReader::new(source_file);
935        builder.append_data(&mut header, &planned.relative_path, &mut reader)?;
936
937        output_files.push(OutputFileChecksum {
938            path: planned.relative_path.to_string_lossy().to_string(),
939            size: reader.bytes_read,
940            blake3: reader.finalize(),
941        });
942    }
943
944    builder.finish()?;
945    let encoder = builder.into_inner()?;
946    encoder.finish()?;
947
948    Ok(output_files)
949}
950
951struct HashingReader<R> {
952    inner: R,
953    hasher: Hasher,
954    bytes_read: u64,
955}
956
957impl<R: Read> HashingReader<R> {
958    fn new(inner: R) -> Self {
959        Self { inner, hasher: Hasher::new(), bytes_read: 0 }
960    }
961
962    fn finalize(self) -> String {
963        self.hasher.finalize().to_hex().to_string()
964    }
965}
966
967impl<R: Read> Read for HashingReader<R> {
968    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
969        let n = self.inner.read(buf)?;
970        if n > 0 {
971            self.bytes_read += n as u64;
972            self.hasher.update(&buf[..n]);
973        }
974        Ok(n)
975    }
976}
977
978#[cfg(test)]
979mod tests {
980    use super::*;
981    use tempfile::tempdir;
982
983    fn test_manifest() -> SnapshotManifest {
984        let mut components = BTreeMap::new();
985        components.insert(
986            "state".to_string(),
987            ComponentManifest::Single(SingleArchive {
988                file: "state.tar.zst".to_string(),
989                size: 100,
990                decompressed_size: 0,
991                blake3: None,
992                output_files: vec![],
993            }),
994        );
995        components.insert(
996            "transactions".to_string(),
997            ComponentManifest::Chunked(ChunkedArchive {
998                blocks_per_file: 500_000,
999                total_blocks: 1_500_000,
1000                chunk_sizes: vec![80_000, 100_000, 120_000],
1001                chunk_decompressed_sizes: vec![],
1002                chunk_files: vec![],
1003                chunk_output_files: vec![vec![], vec![], vec![]],
1004            }),
1005        );
1006        components.insert(
1007            "headers".to_string(),
1008            ComponentManifest::Chunked(ChunkedArchive {
1009                blocks_per_file: 500_000,
1010                total_blocks: 1_500_000,
1011                chunk_sizes: vec![40_000, 50_000, 60_000],
1012                chunk_decompressed_sizes: vec![],
1013                chunk_files: vec![],
1014                chunk_output_files: vec![vec![], vec![], vec![]],
1015            }),
1016        );
1017        SnapshotManifest {
1018            block: 1_500_000,
1019            chain_id: 1,
1020            storage_version: 2,
1021            timestamp: 0,
1022            base_url: Some("https://example.com".to_string()),
1023            reth_version: None,
1024            components,
1025            extensions: Default::default(),
1026        }
1027    }
1028
1029    #[test]
1030    fn manifest_preserves_extensions() {
1031        let manifest: SnapshotManifest = serde_json::from_str(
1032            r#"{
1033                "block": 1,
1034                "chain_id": 1,
1035                "storage_version": 2,
1036                "timestamp": 0,
1037                "components": {},
1038                "consensus": { "archive": "consensus.tar.zst" }
1039            }"#,
1040        )
1041        .unwrap();
1042
1043        assert_eq!(
1044            manifest.extensions.get("consensus"),
1045            Some(&serde_json::json!({ "archive": "consensus.tar.zst" }))
1046        );
1047    }
1048
1049    #[test]
1050    fn archive_urls_for_distance_all() {
1051        let m = test_manifest();
1052        let urls = m.archive_urls_for_distance(SnapshotComponentType::Transactions, None);
1053        assert_eq!(urls.len(), 3);
1054        assert_eq!(urls[0], "https://example.com/transactions-0-499999.tar.zst");
1055        assert_eq!(urls[2], "https://example.com/transactions-1000000-1499999.tar.zst");
1056    }
1057
1058    #[test]
1059    fn archive_urls_for_distance_partial() {
1060        let m = test_manifest();
1061        // 600k blocks → needs 2 chunks (each 500k)
1062        let urls = m.archive_urls_for_distance(SnapshotComponentType::Transactions, Some(600_000));
1063        assert_eq!(urls.len(), 2);
1064        assert_eq!(urls[0], "https://example.com/transactions-500000-999999.tar.zst");
1065        assert_eq!(urls[1], "https://example.com/transactions-1000000-1499999.tar.zst");
1066    }
1067
1068    #[test]
1069    fn archive_urls_for_distance_single_component() {
1070        let m = test_manifest();
1071        // Single archives always return one URL regardless of distance
1072        let urls = m.archive_urls_for_distance(SnapshotComponentType::State, Some(100));
1073        assert_eq!(urls.len(), 1);
1074        assert_eq!(urls[0], "https://example.com/state.tar.zst");
1075    }
1076
1077    #[test]
1078    fn archive_urls_for_distance_rocksdb_indices_single_component() {
1079        let mut components = BTreeMap::new();
1080        components.insert(
1081            "rocksdb_indices".to_string(),
1082            ComponentManifest::Single(SingleArchive {
1083                file: "rocksdb_indices.tar.zst".to_string(),
1084                size: 777,
1085                decompressed_size: 0,
1086                blake3: None,
1087                output_files: vec![],
1088            }),
1089        );
1090        let m = SnapshotManifest {
1091            block: 1,
1092            chain_id: 1,
1093            storage_version: 2,
1094            timestamp: 0,
1095            base_url: Some("https://example.com".to_string()),
1096            reth_version: None,
1097            components,
1098            extensions: Default::default(),
1099        };
1100
1101        let urls = m.archive_urls_for_distance(SnapshotComponentType::RocksdbIndices, Some(10));
1102        assert_eq!(urls.len(), 1);
1103        assert_eq!(urls[0], "https://example.com/rocksdb_indices.tar.zst");
1104        assert_eq!(m.size_for_distance(SnapshotComponentType::RocksdbIndices, Some(10)), 777);
1105    }
1106
1107    #[test]
1108    fn archive_urls_for_distance_missing_component() {
1109        let m = test_manifest();
1110        let urls = m.archive_urls_for_distance(SnapshotComponentType::Receipts, None);
1111        assert!(urls.is_empty());
1112    }
1113
1114    #[test]
1115    fn chunks_for_distance_all() {
1116        let m = test_manifest();
1117        assert_eq!(m.chunks_for_distance(SnapshotComponentType::Transactions, None), 3);
1118    }
1119
1120    #[test]
1121    fn chunks_for_distance_partial() {
1122        let m = test_manifest();
1123        assert_eq!(m.chunks_for_distance(SnapshotComponentType::Transactions, Some(600_000)), 2);
1124        assert_eq!(m.chunks_for_distance(SnapshotComponentType::Transactions, Some(100_000)), 1);
1125    }
1126
1127    #[test]
1128    fn chunks_for_distance_single() {
1129        let m = test_manifest();
1130        assert_eq!(m.chunks_for_distance(SnapshotComponentType::State, None), 1);
1131        assert_eq!(m.chunks_for_distance(SnapshotComponentType::State, Some(100)), 1);
1132    }
1133
1134    /// A snapshot whose final chunk is partial: `total_blocks` is not a multiple of
1135    /// `blocks_per_file`, so the last chunk holds fewer than `blocks_per_file` blocks.
1136    /// Three chunks: `[0, 500k)`, `[500k, 1M)`, and `[1M, 1.005M)` — the last holds 5_000 blocks.
1137    fn partial_tail_manifest() -> SnapshotManifest {
1138        let mut components = BTreeMap::new();
1139        components.insert(
1140            "transactions".to_string(),
1141            ComponentManifest::Chunked(ChunkedArchive {
1142                blocks_per_file: 500_000,
1143                total_blocks: 1_005_000,
1144                chunk_sizes: vec![10, 20, 30],
1145                chunk_decompressed_sizes: vec![100, 200, 300],
1146                chunk_files: vec![],
1147                chunk_output_files: vec![vec![], vec![], vec![]],
1148            }),
1149        );
1150        SnapshotManifest {
1151            block: 1_005_000,
1152            chain_id: 1,
1153            storage_version: 2,
1154            timestamp: 0,
1155            base_url: Some("https://example.com".to_string()),
1156            reth_version: None,
1157            components,
1158            extensions: Default::default(),
1159        }
1160    }
1161
1162    #[test]
1163    fn tail_chunks_for_distance_accounts_for_partial_final_chunk() {
1164        let chunked = ChunkedArchive {
1165            blocks_per_file: 500_000,
1166            total_blocks: 1_005_000,
1167            chunk_sizes: vec![10, 20, 30],
1168            chunk_decompressed_sizes: vec![],
1169            chunk_files: vec![],
1170            chunk_output_files: vec![],
1171        };
1172        // The final chunk holds only 5_000 blocks, so covering 10_064 blocks needs the last two
1173        // chunks. The naive `distance.div_ceil(blocks_per_file)` returns 1 here and falls short.
1174        assert_eq!(chunked.tail_chunks_for_distance(10_064), 2);
1175        // A distance that fits inside the final chunk still needs just one.
1176        assert_eq!(chunked.tail_chunks_for_distance(5_000), 1);
1177        // One block more than the final chunk holds must pull in the previous chunk.
1178        assert_eq!(chunked.tail_chunks_for_distance(5_001), 2);
1179        assert_eq!(chunked.tail_chunks_for_distance(1), 1);
1180        // Covering everything selects all chunks and never exceeds the chunk count.
1181        assert_eq!(chunked.tail_chunks_for_distance(1_005_000), 3);
1182        assert_eq!(chunked.tail_chunks_for_distance(u64::MAX), 3);
1183    }
1184
1185    #[test]
1186    fn partial_final_chunk_downloads_enough_history() {
1187        let m = partial_tail_manifest();
1188        // The published snapshot's final chunk is partial (5_000 blocks). A --full download
1189        // asking for 10_064 blocks must pull the last two chunks, then prune the surplus,
1190        // rather than the single short tail chunk the old distance math selected.
1191        assert_eq!(m.chunks_for_distance(SnapshotComponentType::Transactions, Some(10_064)), 2);
1192
1193        let urls = m.archive_urls_for_distance(SnapshotComponentType::Transactions, Some(10_064));
1194        assert_eq!(urls.len(), 2);
1195        assert_eq!(urls[0], "https://example.com/transactions-500000-999999.tar.zst");
1196        assert_eq!(urls[1], "https://example.com/transactions-1000000-1499999.tar.zst");
1197
1198        let archives =
1199            m.snapshot_archives_for_distance(SnapshotComponentType::Transactions, Some(10_064));
1200        assert_eq!(archives.len(), 2);
1201
1202        // Reported sizes cover the selected tail chunks (indices 1 and 2).
1203        assert_eq!(m.size_for_distance(SnapshotComponentType::Transactions, Some(10_064)), 20 + 30);
1204        assert_eq!(
1205            m.output_size_for_distance(SnapshotComponentType::Transactions, Some(10_064)),
1206            200 + 300
1207        );
1208
1209        // A distance that fits inside the final chunk still selects a single chunk.
1210        assert_eq!(m.chunks_for_distance(SnapshotComponentType::Transactions, Some(5_000)), 1);
1211    }
1212
1213    #[test]
1214    fn chunks_for_distance_missing() {
1215        let m = test_manifest();
1216        assert_eq!(m.chunks_for_distance(SnapshotComponentType::Receipts, None), 0);
1217    }
1218
1219    #[test]
1220    fn component_selection_display() {
1221        assert_eq!(ComponentSelection::All.to_string(), "All");
1222        assert_eq!(ComponentSelection::Distance(10_064).to_string(), "Last 10064 blocks");
1223        assert_eq!(ComponentSelection::Since(15_537_394).to_string(), "Since block 15537394");
1224        assert_eq!(ComponentSelection::None.to_string(), "None");
1225    }
1226
1227    #[test]
1228    fn archive_urls_aligned_to_blocks_per_file() {
1229        // When total_blocks is not aligned to blocks_per_file, chunk boundaries
1230        // must still align to blocks_per_file (not total_blocks).
1231        let mut components = BTreeMap::new();
1232        components.insert(
1233            "storage_changesets".to_string(),
1234            ComponentManifest::Chunked(ChunkedArchive {
1235                blocks_per_file: 500_000,
1236                total_blocks: 24_396_822,
1237                chunk_sizes: vec![100; 49], // 49 chunks
1238                chunk_decompressed_sizes: vec![],
1239                chunk_files: vec![],
1240                chunk_output_files: vec![vec![]; 49],
1241            }),
1242        );
1243        let m = SnapshotManifest {
1244            block: 24_396_822,
1245            chain_id: 1,
1246            storage_version: 2,
1247            timestamp: 0,
1248            base_url: Some("https://example.com".to_string()),
1249            reth_version: None,
1250            components,
1251            extensions: Default::default(),
1252        };
1253        let urls = m.archive_urls(SnapshotComponentType::StorageChangesets);
1254        assert_eq!(urls.len(), 49);
1255        // First chunk: 0-499999 (not 0-396821 or similar)
1256        assert_eq!(urls[0], "https://example.com/storage_changesets-0-499999.tar.zst");
1257        // Last chunk: 24000000-24499999 (not 24000000-24396821)
1258        assert_eq!(urls[48], "https://example.com/storage_changesets-24000000-24499999.tar.zst");
1259    }
1260
1261    #[test]
1262    fn size_for_distance_sums_tail_chunks() {
1263        let m = test_manifest();
1264        // Transactions has chunk_sizes [80_000, 100_000, 120_000]
1265        // All: sum of all 3
1266        assert_eq!(m.size_for_distance(SnapshotComponentType::Transactions, None), 300_000);
1267        // Last 500K blocks = 1 chunk = last chunk only
1268        assert_eq!(
1269            m.size_for_distance(SnapshotComponentType::Transactions, Some(500_000)),
1270            120_000
1271        );
1272        // Last 600K blocks = 2 chunks = last two
1273        assert_eq!(
1274            m.size_for_distance(SnapshotComponentType::Transactions, Some(600_000)),
1275            220_000
1276        );
1277        // Single archive (state) always returns full size
1278        assert_eq!(m.size_for_distance(SnapshotComponentType::State, Some(100)), 100);
1279        // Missing component
1280        assert_eq!(m.size_for_distance(SnapshotComponentType::Receipts, None), 0);
1281    }
1282
1283    #[test]
1284    fn output_size_for_distance_uses_manifest_or_output_files() {
1285        let m = test_manifest();
1286        assert_eq!(m.output_size_for_distance(SnapshotComponentType::Transactions, None), 0);
1287
1288        let mut components = BTreeMap::new();
1289        components.insert(
1290            "state".to_string(),
1291            ComponentManifest::Single(SingleArchive {
1292                file: "state.tar.zst".to_string(),
1293                size: 100,
1294                decompressed_size: 1_000,
1295                blake3: None,
1296                output_files: vec![OutputFileChecksum {
1297                    path: "db/mdbx.dat".to_string(),
1298                    size: 1_000,
1299                    blake3: "h0".to_string(),
1300                }],
1301            }),
1302        );
1303        components.insert(
1304            "transactions".to_string(),
1305            ComponentManifest::Chunked(ChunkedArchive {
1306                blocks_per_file: 500_000,
1307                total_blocks: 1_000_000,
1308                chunk_sizes: vec![80_000, 120_000],
1309                chunk_decompressed_sizes: vec![111, 222],
1310                chunk_files: vec![],
1311                chunk_output_files: vec![
1312                    vec![OutputFileChecksum {
1313                        path: "static_files/static_file_transactions_0_499999.bin".to_string(),
1314                        size: 111,
1315                        blake3: "h0".to_string(),
1316                    }],
1317                    vec![OutputFileChecksum {
1318                        path: "static_files/static_file_transactions_500000_999999.bin".to_string(),
1319                        size: 222,
1320                        blake3: "h1".to_string(),
1321                    }],
1322                ],
1323            }),
1324        );
1325        let manifest = SnapshotManifest {
1326            block: 1_000_000,
1327            chain_id: 1,
1328            storage_version: 2,
1329            timestamp: 0,
1330            base_url: Some("https://example.com".to_string()),
1331            reth_version: None,
1332            components,
1333            extensions: Default::default(),
1334        };
1335
1336        assert_eq!(manifest.output_size_for_distance(SnapshotComponentType::State, None), 1_000);
1337        assert_eq!(
1338            manifest.output_size_for_distance(SnapshotComponentType::Transactions, None),
1339            333
1340        );
1341        assert_eq!(
1342            manifest.output_size_for_distance(SnapshotComponentType::Transactions, Some(500_000)),
1343            222
1344        );
1345    }
1346
1347    #[test]
1348    fn archive_descriptors_include_checksum_metadata() {
1349        let mut components = BTreeMap::new();
1350        components.insert(
1351            "state".to_string(),
1352            ComponentManifest::Single(SingleArchive {
1353                file: "state.tar.zst".to_string(),
1354                size: 100,
1355                decompressed_size: 1_000,
1356                blake3: Some("abc123".to_string()),
1357                output_files: vec![OutputFileChecksum {
1358                    path: "db/mdbx.dat".to_string(),
1359                    size: 1000,
1360                    blake3: "s0".to_string(),
1361                }],
1362            }),
1363        );
1364        components.insert(
1365            "transactions".to_string(),
1366            ComponentManifest::Chunked(ChunkedArchive {
1367                blocks_per_file: 500_000,
1368                total_blocks: 1_000_000,
1369                chunk_sizes: vec![80_000, 120_000],
1370                chunk_decompressed_sizes: vec![111, 222],
1371                chunk_files: vec![],
1372                chunk_output_files: vec![
1373                    vec![OutputFileChecksum {
1374                        path: "static_files/static_file_transactions_0_499999.bin".to_string(),
1375                        size: 111,
1376                        blake3: "h0".to_string(),
1377                    }],
1378                    vec![OutputFileChecksum {
1379                        path: "static_files/static_file_transactions_500000_999999.bin".to_string(),
1380                        size: 222,
1381                        blake3: "h1".to_string(),
1382                    }],
1383                ],
1384            }),
1385        );
1386
1387        let m = SnapshotManifest {
1388            block: 1_000_000,
1389            chain_id: 1,
1390            storage_version: 2,
1391            timestamp: 0,
1392            base_url: Some("https://example.com".to_string()),
1393            reth_version: None,
1394            components,
1395            extensions: Default::default(),
1396        };
1397
1398        let state = m.snapshot_archives_for_distance(SnapshotComponentType::State, None);
1399        assert_eq!(state.len(), 1);
1400        assert_eq!(state[0].file_name, "state.tar.zst");
1401        assert_eq!(state[0].blake3.as_deref(), Some("abc123"));
1402        assert_eq!(state[0].output_files.len(), 1);
1403
1404        let tx = m.snapshot_archives_for_distance(SnapshotComponentType::Transactions, None);
1405        assert_eq!(tx.len(), 2);
1406        assert_eq!(tx[0].blake3, None);
1407        assert_eq!(tx[1].blake3, None);
1408        assert_eq!(tx[0].output_files[0].size, 111);
1409    }
1410
1411    #[test]
1412    fn generate_manifest_includes_state_single_archive() {
1413        let source = tempdir().unwrap();
1414        let output = tempdir().unwrap();
1415        let db_dir = source.path().join("db");
1416        std::fs::create_dir_all(&db_dir).unwrap();
1417        std::fs::write(db_dir.join("mdbx.dat"), b"state-data").unwrap();
1418
1419        let manifest =
1420            generate_manifest(source.path(), output.path(), None, 0, 1, 500_000).unwrap();
1421
1422        let state = manifest.component(SnapshotComponentType::State).unwrap();
1423        let ComponentManifest::Single(state) = state else {
1424            panic!("state should be a single archive")
1425        };
1426        assert_eq!(state.file, "state.tar.zst");
1427        assert!(state.decompressed_size > 0);
1428        assert!(!state.output_files.is_empty());
1429        assert_eq!(state.output_files[0].path, "db/mdbx.dat");
1430        assert!(output.path().join("state.tar.zst").exists());
1431    }
1432
1433    #[test]
1434    fn generate_manifest_includes_rocksdb_single_archive_when_present() {
1435        let source = tempdir().unwrap();
1436        let output = tempdir().unwrap();
1437        let db_dir = source.path().join("db");
1438        std::fs::create_dir_all(&db_dir).unwrap();
1439        std::fs::write(db_dir.join("mdbx.dat"), b"state-data").unwrap();
1440        let rocksdb_dir = source.path().join("rocksdb");
1441        std::fs::create_dir_all(&rocksdb_dir).unwrap();
1442        std::fs::write(rocksdb_dir.join("CURRENT"), b"MANIFEST-000001").unwrap();
1443
1444        let manifest =
1445            generate_manifest(source.path(), output.path(), None, 0, 1, 500_000).unwrap();
1446
1447        let rocksdb = manifest.component(SnapshotComponentType::RocksdbIndices).unwrap();
1448        let ComponentManifest::Single(rocksdb) = rocksdb else {
1449            panic!("rocksdb indices should be a single archive")
1450        };
1451        assert_eq!(rocksdb.file, "rocksdb_indices.tar.zst");
1452        assert!(rocksdb.decompressed_size > 0);
1453        assert!(!rocksdb.output_files.is_empty());
1454        assert_eq!(rocksdb.output_files[0].path, "rocksdb/CURRENT");
1455        assert!(output.path().join("rocksdb_indices.tar.zst").exists());
1456    }
1457
1458    #[test]
1459    fn resolve_archive_url_joins_nested_paths_under_base() {
1460        assert_eq!(
1461            resolve_archive_url(
1462                "https://example.com/mainnet",
1463                "static_files/headers-0-499999.tar.zst"
1464            ),
1465            "https://example.com/mainnet/static_files/headers-0-499999.tar.zst"
1466        );
1467        assert_eq!(
1468            resolve_archive_url("https://example.com/mainnet/", "1700000/state.tar.zst"),
1469            "https://example.com/mainnet/1700000/state.tar.zst"
1470        );
1471        assert_eq!(
1472            resolve_archive_url("https://example.com/mainnet", "headers-0-499999.tar.zst"),
1473            "https://example.com/mainnet/headers-0-499999.tar.zst"
1474        );
1475    }
1476
1477    #[test]
1478    fn chunk_files_resolve_relative_to_root_base_url() {
1479        let mut components = BTreeMap::new();
1480        components.insert(
1481            "headers".to_string(),
1482            ComponentManifest::Chunked(ChunkedArchive {
1483                blocks_per_file: 500_000,
1484                total_blocks: 1_000_000,
1485                chunk_sizes: vec![40_000, 50_000],
1486                chunk_decompressed_sizes: vec![],
1487                chunk_files: vec![
1488                    "static_files/headers-0-499999.tar.zst".to_string(),
1489                    "1700000/headers-500000-999999.tar.zst".to_string(),
1490                ],
1491                chunk_output_files: vec![vec![], vec![]],
1492            }),
1493        );
1494        components.insert(
1495            "state".to_string(),
1496            ComponentManifest::Single(SingleArchive {
1497                file: "1700000/state.tar.zst".to_string(),
1498                size: 100,
1499                decompressed_size: 0,
1500                blake3: None,
1501                output_files: vec![],
1502            }),
1503        );
1504
1505        let m = SnapshotManifest {
1506            block: 1_000_000,
1507            chain_id: 1,
1508            storage_version: 2,
1509            timestamp: 1_700_000,
1510            base_url: Some("https://example.com/mainnet".to_string()),
1511            reth_version: None,
1512            components,
1513            extensions: Default::default(),
1514        };
1515
1516        let urls = m.archive_urls(SnapshotComponentType::Headers);
1517        assert_eq!(urls.len(), 2, "exactly 2 header chunk URLs");
1518        assert_eq!(
1519            urls[0], "https://example.com/mainnet/static_files/headers-0-499999.tar.zst",
1520            "finalized chunk stays under static_files/"
1521        );
1522        assert_eq!(
1523            urls[1], "https://example.com/mainnet/1700000/headers-500000-999999.tar.zst",
1524            "tip chunk resolves under the run timestamp directory"
1525        );
1526
1527        let state = m.snapshot_archives_for_distance(SnapshotComponentType::State, None);
1528        assert_eq!(state.len(), 1, "exactly one state archive");
1529        assert_eq!(
1530            state[0].url, "https://example.com/mainnet/1700000/state.tar.zst",
1531            "single archive file path joins under root base_url without ../"
1532        );
1533        assert_eq!(state[0].file_name, "1700000/state.tar.zst");
1534
1535        let headers =
1536            m.snapshot_archives_for_distance(SnapshotComponentType::Headers, Some(500_000));
1537        assert_eq!(headers.len(), 1, "distance selection returns only the tip chunk");
1538        assert_eq!(
1539            headers[0].file_name, "1700000/headers-500000-999999.tar.zst",
1540            "file_name keeps the relative path from chunk_files"
1541        );
1542        assert_eq!(
1543            headers[0].url,
1544            "https://example.com/mainnet/1700000/headers-500000-999999.tar.zst"
1545        );
1546    }
1547
1548    #[test]
1549    fn chunk_files_absent_keeps_default_chunk_names() {
1550        let m = test_manifest();
1551        let archives = m.snapshot_archives_for_distance(SnapshotComponentType::Transactions, None);
1552        assert_eq!(archives.len(), 3);
1553        assert_eq!(archives[0].file_name, "transactions-0-499999.tar.zst");
1554        assert_eq!(archives[0].url, "https://example.com/transactions-0-499999.tar.zst");
1555    }
1556
1557    #[test]
1558    fn resolve_archive_url_normalizes_parent_dirs_and_leading_slashes() {
1559        assert_eq!(
1560            resolve_archive_url("https://example.com/static_files", "../1700000/state.tar.zst"),
1561            "https://example.com/1700000/state.tar.zst",
1562            "../ under a static_files base resolves to the run directory"
1563        );
1564        assert_eq!(
1565            resolve_archive_url(
1566                "https://example.com/mainnet",
1567                "/static_files/headers-0-499999.tar.zst"
1568            ),
1569            "https://example.com/mainnet/static_files/headers-0-499999.tar.zst",
1570            "leading slash is stripped so the base path prefix is preserved"
1571        );
1572    }
1573
1574    #[test]
1575    fn mismatched_chunk_files_length_falls_back_to_default_names() {
1576        let mut components = BTreeMap::new();
1577        components.insert(
1578            "headers".to_string(),
1579            ComponentManifest::Chunked(ChunkedArchive {
1580                blocks_per_file: 500_000,
1581                total_blocks: 1_000_000,
1582                chunk_sizes: vec![40_000, 50_000],
1583                chunk_decompressed_sizes: vec![],
1584                // Only one entry for two chunks — ignored in favor of default names.
1585                chunk_files: vec!["static_files/headers-0-499999.tar.zst".to_string()],
1586                chunk_output_files: vec![vec![], vec![]],
1587            }),
1588        );
1589        let m = SnapshotManifest {
1590            block: 1_000_000,
1591            chain_id: 1,
1592            storage_version: 2,
1593            timestamp: 0,
1594            base_url: Some("https://example.com/mainnet".to_string()),
1595            reth_version: None,
1596            components,
1597            extensions: Default::default(),
1598        };
1599
1600        let ComponentManifest::Chunked(chunked) =
1601            m.component(SnapshotComponentType::Headers).unwrap()
1602        else {
1603            panic!("headers should be chunked");
1604        };
1605        assert!(!chunked.chunk_files_are_consistent());
1606
1607        let urls = m.archive_urls(SnapshotComponentType::Headers);
1608        assert_eq!(urls.len(), 2);
1609        assert_eq!(
1610            urls[0], "https://example.com/mainnet/headers-0-499999.tar.zst",
1611            "mismatched chunk_files must not partially apply"
1612        );
1613        assert_eq!(urls[1], "https://example.com/mainnet/headers-500000-999999.tar.zst");
1614    }
1615}