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