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