Skip to main content

reth_cli_commands/download/
manifest.rs

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