Skip to main content

reth_cli_commands/download/
mod.rs

1//! Snapshot download command.
2//!
3//! `reth download` prepares a data directory from published snapshot archives. [`DownloadCommand`]
4//! covers both a single-archive path and a manifest-driven path, and owns the steps required to
5//! turn downloaded bytes into a bootable node directory.
6//!
7//! ## Entry modes
8//!
9//! [`DownloadCommand`] has two main execution modes:
10//!
11//! - Single-archive mode processes one `.tar.lz4` or `.tar.zst` archive from `--url`.
12//!   Depending on the source and flags, it either extracts a local `file://` archive, streams a
13//!   remote archive straight into extraction, or downloads the archive to disk first and then
14//!   extracts it.
15//! - Manifest mode resolves a [`SnapshotManifest`], turns CLI or TUI choices into
16//!   [`ComponentSelection`]s, plans the required archives, processes them, and then writes the
17//!   resulting config and database checkpoints.
18//!
19//! [`DownloadDefaults`] defines the discovery endpoints and default help text used when the command
20//! needs to discover a manifest instead of consuming an explicit source.
21//! The manifest chain ID is checked when the selected chain determines the source, default data
22//! directory, or pre-Merge pruning settings. Explicit sources and data directories do not require
23//! a matching chain when component selection is independent of its hardforks.
24//!
25//! ## Selection and planning
26//!
27//! Manifest mode first reduces user input into `ResolvedComponents`: a map of
28//! [`SnapshotComponentType`] to [`ComponentSelection`] plus an optional `SelectionPreset`.
29//! This turns CLI input (`minimal`, `full`, `archive`, or explicit `--with-*` flags) into the
30//! component selections used by the download code.
31//!
32//! The selected components are then expanded into `PlannedDownloads`, which is the set of
33//! `PlannedArchive`s that must be verified, downloaded, or reused. Planning also computes the
34//! total byte count used by progress reporting.
35//!
36//! ## Archive processing
37//!
38//! Each planned archive is processed independently, but `DownloadSession` holds the shared
39//! progress, request limit, and cancellation token for the whole command.
40//! `ArchiveProcessContext` adds the paths needed to process one archive.
41//!
42//! Archive processing is modeled around `ModularDownloadJob`, which schedules work, and
43//! `ArchiveProcessor`, which owns the explicit retry state machine for one archive.
44//! `ArchiveMode` decides whether that archive should be fetched through the cache or streamed
45//! directly.
46//!
47//! Before any archive is scheduled, `PlannedDownloads::partition_reusable` hashes the existing
48//! output files once and skips archives whose outputs already verify. Each remaining archive is
49//! processed as follows:
50//!
51//! - fetch and extract the archive,
52//! - verify the declared output files,
53//! - retry the entire archive attempt if extraction succeeded but verification failed.
54//!
55//! Reuse and completion are based on verified output files, not on whether an old archive file is
56//! present.
57//!
58//! ## Fetch and extraction
59//!
60//! `stream_and_extract` handles the single-archive path. It supports local files, resumable
61//! downloads to disk, and direct streaming extraction.
62//!
63//! When the code needs to fetch an archive to disk, it uses `ArchiveFetcher`. The fetcher probes
64//! the remote source and chooses between a sequential download and a segmented download plan
65//! (`SegmentedDownloadPlan`). `SequentialDownloadFallback` records why a source could not use the
66//! segmented path, while `SegmentedDownload` runs the worker queue and piece retries for the
67//! parallel path.
68//!
69//! Segmented download retries individual byte ranges. Archive processing retries whole-archive
70//! attempts. These are separate layers: range retries deal with transient request failures, while
71//! archive retries deal with extraction or output verification failures.
72//!
73//! `CompressionFormat` determines how the archive stream is unpacked once bytes are available, and
74//! `OutputVerifier` checks the extracted output files before reuse or completion.
75//!
76//! ## Progress and finalization
77//!
78//! `DownloadProgress` reports progress for the single-archive path. `SharedProgress` reports
79//! aggregate progress for modular downloads. It tracks fetched bytes separately from completed
80//! bytes so repeated fetches during retries do not overstate completion.
81//!
82//! After all required archives are complete, [`DownloadCommand`] finalizes the directory by
83//! writing the derived node configuration and updating prune or index-stage checkpoints. A
84//! successful command leaves a data directory that matches the snapshot shape that was selected.
85
86mod archive;
87pub mod config_gen;
88mod extract;
89mod fetch;
90pub mod manifest;
91pub mod manifest_cmd;
92mod planning;
93mod progress;
94mod prune;
95mod session;
96mod source;
97mod tui;
98mod verify;
99
100pub use planning::{DownloadPlan, DownloadPlanArchive};
101
102use crate::common::EnvironmentArgs;
103use archive::run_modular_downloads;
104use clap::{builder::RangedU64ValueParser, Parser};
105use config_gen::{config_for_selections, write_config};
106use extract::stream_and_extract;
107use eyre::Result;
108use manifest::{ComponentSelection, SnapshotComponentType, SnapshotManifest};
109use planning::{collect_planned_archives, PlannedDownloads};
110use progress::{DownloadProgress, DownloadRequestLimiter};
111use prune::prune_unlisted_outputs;
112use reth_chainspec::{EthChainSpec, EthereumHardfork, EthereumHardforks, MAINNET};
113use reth_cli::chainspec::ChainSpecParser;
114use reth_cli_util::cancellation::CancellationToken;
115use reth_db::{init_db, Database};
116use reth_db_api::transaction::DbTx;
117use reth_fs_util as fs;
118use reth_node_core::args::DefaultPruningValues;
119use reth_prune_types::{PruneMode, PruneModes};
120use source::{
121    discover_manifest_url, fetch_manifest_from_source, fetch_snapshot_api_entries,
122    print_snapshot_listing, resolve_manifest_base_url,
123};
124use std::{
125    borrow::Cow,
126    collections::BTreeMap,
127    path::{Path, PathBuf},
128    sync::{Arc, OnceLock},
129    time::Duration,
130};
131use tracing::info;
132use tui::{run_selector, SelectorOutput};
133
134const RETH_SNAPSHOTS_BASE_URL: &str = "https://snapshots-r2.reth.rs";
135const RETH_SNAPSHOTS_API_URL: &str = "https://snapshots.reth.rs/api/snapshots";
136const RETH_SNAPSHOTS_SOURCE: &str = "https://snapshots.reth.rs (default)";
137const SNAPSHOT_API_PATH: &str = "/api/snapshots";
138const MANAGED_DATADIR_PATHS: &[&str] = &["db", "rocksdb", "static_files", "reth.toml"];
139
140/// Maximum number of simultaneous HTTP downloads across the entire snapshot job.
141const MAX_CONCURRENT_DOWNLOADS: usize = 8;
142
143/// Built-in component presets for snapshot selection.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub(crate) enum SelectionPreset {
146    /// Minimal node data needed to start from a snapshot.
147    Minimal,
148    /// Full-node data matching the default full prune settings.
149    Full,
150    /// All available snapshot data.
151    Archive,
152}
153
154struct ResolvedComponents {
155    selections: BTreeMap<SnapshotComponentType, ComponentSelection>,
156    preset: Option<SelectionPreset>,
157}
158
159/// Global static download defaults
160static DOWNLOAD_DEFAULTS: OnceLock<DownloadDefaults> = OnceLock::new();
161
162/// Download configuration defaults
163///
164/// Global defaults can be set via [`DownloadDefaults::try_init`].
165#[derive(Debug, Clone)]
166pub struct DownloadDefaults {
167    /// List of available snapshot sources
168    pub available_snapshots: Vec<Cow<'static, str>>,
169    /// Default base URL for snapshots
170    pub default_base_url: Cow<'static, str>,
171    /// Default base URL for chain-aware snapshots.
172    ///
173    /// When set, the chain ID is appended to form the full URL: `{base_url}/{chain_id}`.
174    /// For example, given a base URL of `https://snapshots.example.com` and chain ID `1`,
175    /// the resulting URL would be `https://snapshots.example.com/1`.
176    ///
177    /// Falls back to [`default_base_url`](Self::default_base_url) when `None`.
178    pub default_chain_aware_base_url: Option<Cow<'static, str>>,
179    /// URL for the snapshot discovery API that lists available snapshots.
180    ///
181    /// Defaults to `https://snapshots.reth.rs/api/snapshots`.
182    pub snapshot_api_url: Cow<'static, str>,
183    /// Optional custom long help text that overrides the generated help
184    pub long_help: Option<String>,
185    /// Whether the full preset also downloads the snapshot's RocksDB indices instead of
186    /// rebuilding them.
187    pub full_preset_includes_rocksdb_indices: bool,
188}
189
190impl DownloadDefaults {
191    /// Initialize the global download defaults with this configuration
192    #[expect(clippy::result_large_err, reason = "returns the rejected defaults to the caller")]
193    pub fn try_init(self) -> Result<(), Self> {
194        DOWNLOAD_DEFAULTS.set(self)
195    }
196
197    /// Get a reference to the global download defaults
198    pub fn get_global() -> &'static DownloadDefaults {
199        DOWNLOAD_DEFAULTS.get_or_init(DownloadDefaults::default_download_defaults)
200    }
201
202    /// Default download configuration with defaults from snapshots.reth.rs and publicnode
203    pub fn default_download_defaults() -> Self {
204        Self {
205            available_snapshots: vec![
206                Cow::Borrowed(RETH_SNAPSHOTS_SOURCE),
207                Cow::Borrowed("https://publicnode.com/snapshots (full nodes & testnets)"),
208            ],
209            default_base_url: Cow::Borrowed(RETH_SNAPSHOTS_BASE_URL),
210            default_chain_aware_base_url: None,
211            snapshot_api_url: Cow::Borrowed(RETH_SNAPSHOTS_API_URL),
212            long_help: None,
213            full_preset_includes_rocksdb_indices: false,
214        }
215    }
216
217    /// Generates the long help text for the download URL argument using these defaults.
218    ///
219    /// If a custom long_help is set, it will be returned. Otherwise, help text is generated
220    /// from the available_snapshots list.
221    pub fn long_help(&self) -> String {
222        if let Some(ref custom_help) = self.long_help {
223            return custom_help.clone();
224        }
225
226        let implicit_download_help = if self.mainnet_only_discovery() {
227            "\nIf no URL is provided, the latest archive snapshot will only be proposed\nfor Ethereum mainnet. For other chains, provide --manifest-url, --manifest-path,\nor -u explicitly."
228        } else {
229            "\nIf no URL is provided, the latest archive snapshot for the selected chain\nwill be proposed for download from "
230        };
231
232        let mut help = format!(
233            "Specify a snapshot URL or let the command propose a default one.\n\n\
234             Browse available snapshots at {}\n\
235             or use --list-snapshots to see them from the CLI.\n\nAvailable snapshot sources:\n",
236            self.snapshot_source_url(),
237        );
238
239        for source in &self.available_snapshots {
240            help.push_str("- ");
241            help.push_str(source);
242            help.push('\n');
243        }
244
245        help.push_str(implicit_download_help);
246        if !self.mainnet_only_discovery() {
247            help.push_str(
248                self.default_chain_aware_base_url.as_deref().unwrap_or(&self.default_base_url),
249            );
250            help.push('.');
251        }
252        help.push_str(
253            "\n\nLocal file:// URLs are also supported for extracting snapshots from disk.",
254        );
255        help
256    }
257
258    fn mainnet_only_discovery(&self) -> bool {
259        self.snapshot_api_url.trim_end_matches('/') == RETH_SNAPSHOTS_API_URL
260    }
261
262    fn snapshot_source_url(&self) -> &str {
263        snapshot_source_url_from_api(&self.snapshot_api_url)
264    }
265
266    /// Add a snapshot source to the list
267    pub fn with_snapshot(mut self, source: impl Into<Cow<'static, str>>) -> Self {
268        self.available_snapshots.push(source.into());
269        self
270    }
271
272    /// Replace all snapshot sources
273    pub fn with_snapshots(mut self, sources: Vec<Cow<'static, str>>) -> Self {
274        self.available_snapshots = sources;
275        self
276    }
277
278    /// Set the default base URL, e.g. `https://downloads.merkle.io`.
279    pub fn with_base_url(mut self, url: impl Into<Cow<'static, str>>) -> Self {
280        self.default_base_url = url.into();
281        self
282    }
283
284    /// Set the default chain-aware base URL.
285    pub fn with_chain_aware_base_url(mut self, url: impl Into<Cow<'static, str>>) -> Self {
286        self.default_chain_aware_base_url = Some(url.into());
287        self
288    }
289
290    /// Set the snapshot discovery API URL.
291    ///
292    /// Generated help uses the API root as the default snapshot source unless a custom
293    /// chain-aware base URL or source list was already provided.
294    pub fn with_snapshot_api_url(mut self, url: impl Into<Cow<'static, str>>) -> Self {
295        self.snapshot_api_url = url.into();
296
297        let source_url = self.snapshot_source_url().to_string();
298        if self.default_chain_aware_base_url.is_none() {
299            self.default_chain_aware_base_url = Some(Cow::Owned(source_url.clone()));
300        }
301        for source in &mut self.available_snapshots {
302            if source.as_ref() == RETH_SNAPSHOTS_SOURCE {
303                *source = Cow::Owned(format!("{source_url} (default)"));
304            }
305        }
306
307        self
308    }
309
310    /// Set one default snapshot source URL for discovery and generated CLI references.
311    ///
312    /// The provided URL is the public snapshot root, such as `https://snapshots.example.com`.
313    /// The discovery API is derived as `{url}/api/snapshots`.
314    pub fn with_snapshot_source_url(mut self, url: impl Into<Cow<'static, str>>) -> Self {
315        let source_url = normalize_snapshot_source_url(url.into());
316        self.available_snapshots = vec![Cow::Owned(format!("{} (default)", source_url.as_ref()))];
317        self.default_base_url = source_url.clone();
318        self.default_chain_aware_base_url = Some(source_url.clone());
319        self.snapshot_api_url = Cow::Owned(format!("{}{SNAPSHOT_API_PATH}", source_url.as_ref()));
320        self
321    }
322
323    /// Builder: Make the full preset also download the snapshot's RocksDB indices.
324    pub const fn with_full_preset_rocksdb_indices(mut self) -> Self {
325        self.full_preset_includes_rocksdb_indices = true;
326        self
327    }
328
329    /// Builder: Set custom long help text, overriding the generated help
330    pub fn with_long_help(mut self, help: impl Into<String>) -> Self {
331        self.long_help = Some(help.into());
332        self
333    }
334}
335
336fn snapshot_source_url_from_api(api_url: &str) -> &str {
337    api_url.trim_end_matches('/').trim_end_matches(SNAPSHOT_API_PATH)
338}
339
340fn normalize_snapshot_source_url(url: Cow<'static, str>) -> Cow<'static, str> {
341    match url {
342        Cow::Borrowed(url) => Cow::Borrowed(snapshot_source_url_from_api(url)),
343        Cow::Owned(url) => Cow::Owned(snapshot_source_url_from_api(&url).to_string()),
344    }
345}
346
347impl Default for DownloadDefaults {
348    /// Returns the built-in download defaults.
349    fn default() -> Self {
350        Self::default_download_defaults()
351    }
352}
353
354/// CLI command that downloads snapshot archives and configures a reth node from them.
355#[derive(Debug, Parser)]
356pub struct DownloadCommand<C: ChainSpecParser> {
357    #[command(flatten)]
358    env: EnvironmentArgs<C>,
359
360    /// Custom URL to download a single snapshot archive (legacy mode).
361    ///
362    /// When provided, downloads and extracts a single archive without component selection.
363    /// Browse available snapshots with --list-snapshots.
364    #[arg(long, short, long_help = DownloadDefaults::get_global().long_help())]
365    url: Option<String>,
366
367    /// URL to a snapshot manifest.json for modular component downloads.
368    ///
369    /// When provided, fetches this manifest instead of discovering it from the default
370    /// base URL. Useful for testing with custom or local manifests.
371    #[arg(long, value_name = "URL", conflicts_with = "url")]
372    manifest_url: Option<String>,
373
374    /// Local path to a snapshot manifest.json for modular component downloads.
375    #[arg(long, value_name = "PATH", conflicts_with_all = ["url", "manifest_url"])]
376    manifest_path: Option<PathBuf>,
377
378    /// Include all transaction static files.
379    #[arg(long, conflicts_with_all = ["with_txs_since", "with_txs_distance", "minimal", "full", "archive"])]
380    with_txs: bool,
381
382    /// Include transaction static files starting at the specified block.
383    #[arg(long, value_name = "BLOCK_NUMBER", conflicts_with_all = ["with_txs", "with_txs_distance", "minimal", "full", "archive"])]
384    with_txs_since: Option<u64>,
385
386    /// Include transaction static files covering the last N blocks.
387    #[arg(long, value_name = "BLOCKS", value_parser = RangedU64ValueParser::<u64>::new().range(1..), conflicts_with_all = ["with_txs", "with_txs_since", "minimal", "full", "archive"])]
388    with_txs_distance: Option<u64>,
389
390    /// Include all receipt static files.
391    #[arg(long, conflicts_with_all = ["with_receipts_since", "with_receipts_distance", "minimal", "full", "archive"])]
392    with_receipts: bool,
393
394    /// Include receipt static files starting at the specified block.
395    #[arg(long, value_name = "BLOCK_NUMBER", conflicts_with_all = ["with_receipts", "with_receipts_distance", "minimal", "full", "archive"])]
396    with_receipts_since: Option<u64>,
397
398    /// Include receipt static files covering the last N blocks.
399    #[arg(long, value_name = "BLOCKS", value_parser = RangedU64ValueParser::<u64>::new().range(1..), conflicts_with_all = ["with_receipts", "with_receipts_since", "minimal", "full", "archive"])]
400    with_receipts_distance: Option<u64>,
401
402    /// Include all account and storage history static files.
403    #[arg(long, alias = "with-changesets", conflicts_with_all = ["with_state_history_since", "with_state_history_distance", "minimal", "full", "archive"])]
404    with_state_history: bool,
405
406    /// Include account and storage history static files starting at the specified block.
407    #[arg(long, value_name = "BLOCK_NUMBER", conflicts_with_all = ["with_state_history", "with_state_history_distance", "minimal", "full", "archive"])]
408    with_state_history_since: Option<u64>,
409
410    /// Include account and storage history static files covering the last N blocks.
411    #[arg(long, value_name = "BLOCKS", value_parser = RangedU64ValueParser::<u64>::new().range(1..), conflicts_with_all = ["with_state_history", "with_state_history_since", "minimal", "full", "archive"])]
412    with_state_history_distance: Option<u64>,
413
414    /// Include transaction sender static files. Requires `--with-txs`.
415    #[arg(long, requires = "with_txs", conflicts_with_all = ["minimal", "full", "archive"])]
416    with_senders: bool,
417
418    /// Include RocksDB index files.
419    #[arg(long, conflicts_with_all = ["minimal", "full", "archive", "without_rocksdb"])]
420    with_rocksdb: bool,
421
422    /// Download all available components (archive node, no pruning).
423    #[arg(long, alias = "all", conflicts_with_all = ["with_txs", "with_txs_since", "with_txs_distance", "with_receipts", "with_receipts_since", "with_receipts_distance", "with_state_history", "with_state_history_since", "with_state_history_distance", "with_senders", "with_rocksdb", "minimal", "full"])]
424    archive: bool,
425
426    /// Download the minimal component set (same default as --non-interactive).
427    #[arg(long, conflicts_with_all = ["with_txs", "with_txs_since", "with_txs_distance", "with_receipts", "with_receipts_since", "with_receipts_distance", "with_state_history", "with_state_history_since", "with_state_history_distance", "with_senders", "with_rocksdb", "archive", "full"])]
428    minimal: bool,
429
430    /// Download the full node component set (matches default full prune settings).
431    #[arg(long, conflicts_with_all = ["with_txs", "with_txs_since", "with_txs_distance", "with_receipts", "with_receipts_since", "with_receipts_distance", "with_state_history", "with_state_history_since", "with_state_history_distance", "with_senders", "with_rocksdb", "archive", "minimal"])]
432    full: bool,
433
434    /// Skip optional RocksDB indices even when archive components are selected.
435    ///
436    /// This affects `--archive`/`--all`, TUI archive preset (`a`), and the full preset when it
437    /// includes RocksDB indices.
438    #[arg(long, conflicts_with_all = ["url", "with_rocksdb"])]
439    without_rocksdb: bool,
440
441    /// Skip interactive component selection. Downloads the minimal set
442    /// (state + headers + transactions + changesets) unless explicit --with-* flags narrow it.
443    #[arg(long, short = 'y')]
444    non_interactive: bool,
445
446    /// Overwrite existing snapshot data by removing db, rocksdb, static_files, and reth.toml.
447    #[arg(long, conflicts_with = "list")]
448    force: bool,
449
450    /// Remove files from db, rocksdb, static_files, and reth.toml that the selected snapshot
451    /// plan does not list, then reuse the listed files that still verify.
452    ///
453    /// Unlike `--force`, this keeps reusable snapshot files, so only missing or changed archives
454    /// are downloaded. Unlike a plain re-run, the data dir ends up with exactly the snapshot's
455    /// files, which is required when the node has written data past the snapshot block.
456    ///
457    /// Components that are not selected are removed entirely, so `--prune-unlisted --minimal`
458    /// over an archive node deletes its history. With `--non-interactive`, components must be
459    /// selected explicitly with a preset or `--with-*` flags. reth.toml is always regenerated,
460    /// which discards local edits. Symlinks below these paths are removed, not followed. Pruning
461    /// refuses to run while a node holds the database lock, and happens before downloading, so a
462    /// failed download leaves the data dir pruned, as with `--force`.
463    #[arg(long, conflicts_with_all = ["force", "list", "url"])]
464    prune_unlisted: bool,
465
466    /// Enable resumable two-phase downloads (download to disk first, then extract).
467    ///
468    /// Archives are downloaded to a `.part` file with HTTP Range resume support
469    /// before extraction. This is enabled by default because it tolerates
470    /// network interruptions without restarting. Pass `--resumable=false` to
471    /// stream archives directly into the extractor instead.
472    #[arg(long, default_value_t = true, num_args = 0..=1, default_missing_value = "true")]
473    resumable: bool,
474
475    /// Maximum number of simultaneous HTTP downloads.
476    ///
477    /// Applies across the entire snapshot download. Small files use one slot,
478    /// while large files may use multiple slots by splitting into fixed-size pieces.
479    #[arg(long, default_value_t = MAX_CONCURRENT_DOWNLOADS)]
480    download_concurrency: usize,
481
482    /// Override the delay between retry attempts (for example, 500ms or 5s).
483    ///
484    /// Applies to requests, extraction, output verification, and segmented downloads.
485    /// By default, retries wait five seconds; segmented requests use adaptive backoff.
486    /// This does not change the number of attempts.
487    #[arg(long, value_name = "DURATION", value_parser = reth_cli_util::parse_duration_from_secs_or_ms)]
488    retry_backoff: Option<Duration>,
489
490    /// List available snapshots and exit.
491    ///
492    /// Queries the snapshots API and prints all available snapshots for the selected chain,
493    /// including block number, size, and manifest URL.
494    #[arg(long, alias = "list-snapshots", conflicts_with_all = ["url", "manifest_url", "manifest_path"])]
495    list: bool,
496
497    /// Print the selected modular archive plan as JSON and exit without downloading.
498    #[arg(long, conflicts_with_all = ["url", "list"])]
499    print_plan_json: bool,
500}
501
502impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> DownloadCommand<C> {
503    /// Runs the download command in single-archive or manifest mode.
504    pub async fn execute<N>(self) -> Result<Option<PreparedSnapshotDownload>> {
505        let chain = self.env.chain.chain();
506
507        // --list: print available snapshots and exit
508        if self.list {
509            let entries = fetch_snapshot_api_entries(chain.id()).await?;
510            print_snapshot_listing(&entries, chain.id());
511            return Ok(None);
512        }
513
514        let data_dir = self.env.datadir.clone().resolve_datadir(chain);
515        let static_files_dir = data_dir.static_files();
516        let static_files_dir = (static_files_dir != data_dir.data_dir().join("static_files"))
517            .then_some(static_files_dir);
518
519        // Legacy single-URL mode: download one archive and extract it
520        if let Some(ref url) = self.url {
521            let cancel_token = CancellationToken::new();
522            let _cancel_guard = cancel_token.drop_guard();
523            let data_dir = self.env.datadir.clone().resolve_datadir(chain);
524            let target_dir = data_dir.data_dir();
525            if self.force {
526                clear_existing_datadir(target_dir, static_files_dir.as_deref())?;
527            }
528            fs::create_dir_all(target_dir)?;
529
530            let request_limiter = DownloadRequestLimiter::new(self.download_concurrency.max(1));
531            info!(target: "reth::cli",
532                dir = ?data_dir.data_dir(),
533                url = %url,
534                "Starting snapshot download and extraction"
535            );
536
537            stream_and_extract(
538                url,
539                data_dir.data_dir(),
540                static_files_dir.as_deref(),
541                self.resumable,
542                Some(request_limiter),
543                cancel_token.clone(),
544                self.retry_backoff,
545            )
546            .await?;
547            info!(target: "reth::cli", "Snapshot downloaded and extracted successfully");
548
549            return Ok(None);
550        }
551
552        let ResolvedDownload { manifest, selections, preset, planned } =
553            self.resolve_download(chain.id()).await?;
554        let data_dir = self.env.datadir.clone().resolve_datadir(chain).data_dir().to_path_buf();
555        let prepared = PreparedSnapshotDownload { manifest, data_dir };
556        if self.print_plan_json {
557            DownloadPlan::from_planned(&prepared.manifest, &planned)
558                .write_json(std::io::stdout().lock())?;
559            return Ok(Some(prepared))
560        }
561
562        let target_dir = prepared.data_dir.as_path();
563        let cancel_token = CancellationToken::new();
564        let _cancel_guard = cancel_token.drop_guard();
565        if self.force {
566            clear_existing_datadir(target_dir, static_files_dir.as_deref())?;
567        } else if self.prune_unlisted {
568            prune_unlisted_outputs(&planned.archives, target_dir, static_files_dir.as_deref())?;
569        }
570        fs::create_dir_all(target_dir)?;
571        let downloads = {
572            let (target_dir, static_files_dir) =
573                (target_dir.to_path_buf(), static_files_dir.clone());
574            tokio::task::spawn_blocking(move || {
575                planned.partition_reusable(&target_dir, static_files_dir.as_deref())
576            })
577            .await??
578        };
579        info!(target: "reth::cli",
580            reusable = downloads.reused.len(),
581            needs_download = downloads.pending.len(),
582            "Startup integrity summary (plain output files)"
583        );
584
585        info!(target: "reth::cli",
586            archives = downloads.total_archives(),
587            download_total = %DownloadProgress::format_size(downloads.total_download_size),
588            output_total = %DownloadProgress::format_size(downloads.total_output_size),
589            "Downloading all archives"
590        );
591
592        run_modular_downloads(
593            downloads,
594            target_dir,
595            static_files_dir.as_deref(),
596            self.download_concurrency.max(1),
597            cancel_token.clone(),
598            self.retry_backoff,
599        )
600        .await?;
601
602        self.finalize_modular_download(
603            &selections,
604            &prepared.manifest,
605            preset,
606            target_dir,
607            &target_dir.join("db"),
608        )?;
609
610        Ok(Some(prepared))
611    }
612
613    /// Resolves the exact modular archive plan and manifest context without downloading or
614    /// modifying the data dir.
615    pub async fn plan(&self) -> Result<(DownloadPlan, PreparedSnapshotDownload)> {
616        let chain = self.env.chain.chain();
617        let resolved = self.resolve_download(chain.id()).await?;
618        let plan = DownloadPlan::from_planned(&resolved.manifest, &resolved.planned);
619        let prepared = PreparedSnapshotDownload {
620            manifest: resolved.manifest,
621            data_dir: self.env.datadir.clone().resolve_datadir(chain).data_dir().to_path_buf(),
622        };
623        Ok((plan, prepared))
624    }
625
626    async fn resolve_download(&self, chain_id: u64) -> Result<ResolvedDownload> {
627        let manifest = self.load_manifest(chain_id).await?;
628        let ResolvedComponents { mut selections, preset } = self.resolve_components(&manifest)?;
629
630        match preset {
631            Some(SelectionPreset::Archive) => {
632                inject_archive_only_components(&mut selections, &manifest, !self.without_rocksdb)
633            }
634            Some(SelectionPreset::Full)
635                if DownloadDefaults::get_global().full_preset_includes_rocksdb_indices &&
636                    !self.without_rocksdb =>
637            {
638                inject_rocksdb_indices(&mut selections, &manifest)
639            }
640            _ => {}
641        }
642
643        let planned = collect_planned_archives(&manifest, &selections)?;
644        Ok(ResolvedDownload { manifest, selections, preset, planned })
645    }
646
647    /// Loads the manifest and resolves its effective base URL.
648    async fn load_manifest(&self, chain_id: u64) -> Result<SnapshotManifest> {
649        let manifest_source = self.resolve_manifest_source(chain_id).await?;
650
651        info!(target: "reth::cli", source = %manifest_source, "Fetching snapshot manifest");
652        let mut manifest = fetch_manifest_from_source(&manifest_source).await?;
653        if self.env.datadir.datadir.as_ref().is_none() ||
654            (self.manifest_url.is_none() && self.manifest_path.is_none())
655        {
656            self.validate_manifest_chain(&manifest)?;
657        }
658        manifest.base_url = Some(resolve_manifest_base_url(&manifest, &manifest_source)?);
659
660        info!(target: "reth::cli",
661            block = manifest.block,
662            chain_id = manifest.chain_id,
663            storage_version = %manifest.storage_version,
664            components = manifest.components.len(),
665            "Loaded snapshot manifest"
666        );
667
668        Ok(manifest)
669    }
670
671    /// Writes config and checkpoint state after all modular archives complete.
672    fn finalize_modular_download(
673        &self,
674        selections: &BTreeMap<SnapshotComponentType, ComponentSelection>,
675        manifest: &SnapshotManifest,
676        preset: Option<SelectionPreset>,
677        target_dir: &Path,
678        db_path: &Path,
679    ) -> Result<()> {
680        let config =
681            config_for_selections(selections, manifest, preset, Some(self.env.chain.as_ref()));
682        if write_config(&config, target_dir)? {
683            let desc = config_gen::describe_prune_config(&config);
684            info!(target: "reth::cli", "{}", desc.join(", "));
685        }
686
687        let db = init_db(db_path, self.env.db.database_args())?;
688        let should_write_prune = config.prune.segments != Default::default();
689        let should_reset_indices = should_reset_index_stage_checkpoints(selections);
690        if should_write_prune || should_reset_indices {
691            let tx = db.tx_mut()?;
692
693            if should_write_prune {
694                config_gen::write_prune_checkpoints_tx(&tx, &config, selections, manifest.block)?;
695            }
696
697            if should_reset_indices {
698                config_gen::reset_index_stage_checkpoints_tx(&tx)?;
699            }
700
701            tx.commit()?;
702        }
703
704        if self.env.chain.chain().id() == manifest.chain_id {
705            let start_command = startup_node_command::<C>(self.env.chain.as_ref());
706            info!(target: "reth::cli", "Snapshot download complete. Run `{}` to start syncing.", start_command);
707        } else {
708            info!(target: "reth::cli", "Snapshot download complete.");
709        }
710
711        Ok(())
712    }
713
714    /// Determines which components to download based on CLI flags or interactive selection.
715    fn resolve_components(&self, manifest: &SnapshotManifest) -> Result<ResolvedComponents> {
716        let available = |ty: SnapshotComponentType| manifest.component(ty).is_some();
717
718        // --archive/--all: everything available as All
719        if self.archive {
720            return Ok(ResolvedComponents {
721                selections: SnapshotComponentType::ALL
722                    .iter()
723                    .copied()
724                    .filter(|ty| available(*ty))
725                    .filter(|ty| {
726                        !self.without_rocksdb || *ty != SnapshotComponentType::RocksdbIndices
727                    })
728                    .map(|ty| (ty, ComponentSelection::All))
729                    .collect(),
730                preset: Some(SelectionPreset::Archive),
731            });
732        }
733
734        if self.full {
735            return Ok(ResolvedComponents {
736                selections: self.full_preset_selections(manifest)?,
737                preset: Some(SelectionPreset::Full),
738            });
739        }
740
741        if self.minimal {
742            return Ok(ResolvedComponents {
743                selections: self.minimal_preset_selections(manifest),
744                preset: Some(SelectionPreset::Minimal),
745            });
746        }
747
748        let has_explicit_flags = self.with_txs ||
749            self.with_txs_since.is_some() ||
750            self.with_txs_distance.is_some() ||
751            self.with_receipts ||
752            self.with_receipts_since.is_some() ||
753            self.with_receipts_distance.is_some() ||
754            self.with_state_history ||
755            self.with_state_history_since.is_some() ||
756            self.with_state_history_distance.is_some() ||
757            self.with_senders ||
758            self.with_rocksdb;
759
760        if has_explicit_flags {
761            let mut selections = BTreeMap::new();
762            let tx_selection = explicit_component_selection(
763                self.with_txs,
764                self.with_txs_since,
765                self.with_txs_distance,
766                manifest.block,
767            );
768            let receipt_selection = explicit_component_selection(
769                self.with_receipts,
770                self.with_receipts_since,
771                self.with_receipts_distance,
772                manifest.block,
773            );
774            let state_history_selection = explicit_component_selection(
775                self.with_state_history,
776                self.with_state_history_since,
777                self.with_state_history_distance,
778                manifest.block,
779            );
780
781            // Required components always All
782            if available(SnapshotComponentType::State) {
783                selections.insert(SnapshotComponentType::State, ComponentSelection::All);
784            }
785            if available(SnapshotComponentType::Headers) {
786                selections.insert(SnapshotComponentType::Headers, ComponentSelection::All);
787            }
788            if let Some(selection) = tx_selection &&
789                available(SnapshotComponentType::Transactions)
790            {
791                selections.insert(SnapshotComponentType::Transactions, selection);
792            }
793            if let Some(selection) = receipt_selection &&
794                available(SnapshotComponentType::Receipts)
795            {
796                selections.insert(SnapshotComponentType::Receipts, selection);
797            }
798            if let Some(selection) = state_history_selection {
799                if available(SnapshotComponentType::AccountChangesets) {
800                    selections.insert(SnapshotComponentType::AccountChangesets, selection);
801                }
802                if available(SnapshotComponentType::StorageChangesets) {
803                    selections.insert(SnapshotComponentType::StorageChangesets, selection);
804                }
805            }
806            if self.with_senders && available(SnapshotComponentType::TransactionSenders) {
807                selections
808                    .insert(SnapshotComponentType::TransactionSenders, ComponentSelection::All);
809            }
810            if self.with_rocksdb && available(SnapshotComponentType::RocksdbIndices) {
811                selections.insert(SnapshotComponentType::RocksdbIndices, ComponentSelection::All);
812            }
813            return Ok(ResolvedComponents { selections, preset: None });
814        }
815
816        if self.non_interactive {
817            // The implicit minimal default would silently prune history from a full or archive
818            // node.
819            eyre::ensure!(
820                !self.prune_unlisted,
821                "--prune-unlisted with --non-interactive requires an explicit component selection: \
822                 --minimal, --full, --archive, or --with-* flags"
823            );
824            return Ok(ResolvedComponents {
825                selections: self.minimal_preset_selections(manifest),
826                preset: Some(SelectionPreset::Minimal),
827            });
828        }
829
830        // Interactive TUI
831        let minimal_preset = self.minimal_preset_selections(manifest);
832        let full_preset = self.full_preset_selections(manifest)?;
833        let SelectorOutput { selections, preset } =
834            run_selector(manifest.clone(), &minimal_preset, &full_preset)?;
835        let selected =
836            selections.into_iter().filter(|(_, sel)| *sel != ComponentSelection::None).collect();
837
838        Ok(ResolvedComponents { selections: selected, preset })
839    }
840
841    /// Builds the default minimal component selection for the manifest.
842    fn minimal_preset_selections(
843        &self,
844        manifest: &SnapshotManifest,
845    ) -> BTreeMap<SnapshotComponentType, ComponentSelection> {
846        self.pruning_preset_selections(manifest, SelectionPreset::Minimal)
847    }
848
849    /// Builds the default full-node component selection for the manifest.
850    fn full_preset_selections(
851        &self,
852        manifest: &SnapshotManifest,
853    ) -> Result<BTreeMap<SnapshotComponentType, ComponentSelection>> {
854        if DefaultPruningValues::get_global().full_bodies_history_use_pre_merge {
855            self.validate_manifest_chain(manifest)?;
856        }
857        Ok(self.pruning_preset_selections(manifest, SelectionPreset::Full))
858    }
859
860    /// Builds component selections from the configured pruning preset.
861    fn pruning_preset_selections(
862        &self,
863        manifest: &SnapshotManifest,
864        preset: SelectionPreset,
865    ) -> BTreeMap<SnapshotComponentType, ComponentSelection> {
866        let defaults = DefaultPruningValues::get_global();
867        let (prune_modes, bodies_history_use_pre_merge) = match preset {
868            SelectionPreset::Minimal => (&defaults.minimal_prune_modes, false),
869            SelectionPreset::Full => {
870                (&defaults.full_prune_modes, defaults.full_bodies_history_use_pre_merge)
871            }
872            SelectionPreset::Archive => unreachable!("archive selects every component"),
873        };
874        let mut selections = BTreeMap::new();
875
876        for &ty in &SnapshotComponentType::ALL {
877            if manifest.component(ty).is_none() {
878                continue;
879            }
880
881            let selection = self.pruning_selection_for_component(
882                ty,
883                manifest.block,
884                prune_modes,
885                bodies_history_use_pre_merge,
886            );
887            if selection != ComponentSelection::None {
888                selections.insert(ty, selection);
889            }
890        }
891
892        selections
893    }
894
895    /// Returns the component selection for one configured pruning preset.
896    fn pruning_selection_for_component(
897        &self,
898        ty: SnapshotComponentType,
899        snapshot_block: u64,
900        prune_modes: &PruneModes,
901        bodies_history_use_pre_merge: bool,
902    ) -> ComponentSelection {
903        if ty == SnapshotComponentType::Transactions && bodies_history_use_pre_merge {
904            return match self
905                .env
906                .chain
907                .ethereum_fork_activation(EthereumHardfork::Paris)
908                .block_number()
909            {
910                Some(paris) if snapshot_block >= paris => ComponentSelection::Since(paris),
911                Some(_) => ComponentSelection::None,
912                None => ComponentSelection::All,
913            }
914        }
915
916        let mode = match ty {
917            SnapshotComponentType::State | SnapshotComponentType::Headers => {
918                return ComponentSelection::All
919            }
920            SnapshotComponentType::Transactions => prune_modes.bodies_history,
921            SnapshotComponentType::TransactionSenders => prune_modes.sender_recovery,
922            SnapshotComponentType::Receipts => prune_modes.receipts,
923            SnapshotComponentType::AccountChangesets => prune_modes.account_history,
924            SnapshotComponentType::StorageChangesets => prune_modes.storage_history,
925            SnapshotComponentType::RocksdbIndices => return ComponentSelection::None,
926        };
927
928        selection_from_prune_mode(mode, snapshot_block)
929    }
930
931    /// Resolves the manifest source from CLI input or snapshot discovery.
932    async fn resolve_manifest_source(&self, chain_id: u64) -> Result<String> {
933        if let Some(path) = &self.manifest_path {
934            return Ok(path.display().to_string());
935        }
936
937        match &self.manifest_url {
938            Some(url) => Ok(url.clone()),
939            None => {
940                let defaults = DownloadDefaults::get_global();
941                if defaults.mainnet_only_discovery() && chain_id != MAINNET.chain.id() {
942                    eyre::bail!(
943                        "Snapshots are only auto-discovered for Ethereum mainnet.\n\n\
944                         Chain {chain_id} requires an explicit source:\n\
945                         \t--manifest-url <URL>\n\
946                         \t--manifest-path <PATH>\n\
947                         \t-u <SNAPSHOT-URL>\n\n\
948                         Use --list to inspect snapshots exposed by {}.",
949                        defaults.snapshot_source_url(),
950                    );
951                }
952
953                discover_manifest_url(chain_id).await
954            }
955        }
956    }
957
958    fn validate_manifest_chain(&self, manifest: &SnapshotManifest) -> Result<()> {
959        let chain_id = self.env.chain.chain().id();
960        eyre::ensure!(
961            manifest.chain_id == chain_id,
962            "Snapshot chain ID {} does not match selected chain ID {chain_id}",
963            manifest.chain_id
964        );
965        Ok(())
966    }
967}
968
969/// Resolves explicit `--with-*` / `--with-*-since` / `--with-*-distance` flags
970/// into a component selection.
971fn explicit_component_selection(
972    all: bool,
973    since: Option<u64>,
974    distance: Option<u64>,
975    snapshot_block: u64,
976) -> Option<ComponentSelection> {
977    if all {
978        Some(ComponentSelection::All)
979    } else if let Some(block) = since {
980        (block <= snapshot_block).then_some(ComponentSelection::Since(block))
981    } else {
982        distance.map(ComponentSelection::Distance)
983    }
984}
985
986/// Converts a prune mode into the matching component selection.
987fn selection_from_prune_mode(mode: Option<PruneMode>, snapshot_block: u64) -> ComponentSelection {
988    match mode {
989        None => ComponentSelection::All,
990        Some(PruneMode::Full) => ComponentSelection::None,
991        Some(PruneMode::Distance(d)) => ComponentSelection::Distance(d),
992        Some(PruneMode::Before(block)) => {
993            if snapshot_block >= block {
994                ComponentSelection::Since(block)
995            } else {
996                ComponentSelection::None
997            }
998        }
999    }
1000}
1001
1002/// Removes existing snapshot data that is managed by `reth download`.
1003fn clear_existing_datadir(target_dir: &Path, static_files_dir: Option<&Path>) -> Result<()> {
1004    info!(target: "reth::cli", dir = ?target_dir, "Clearing existing snapshot data");
1005    for entry in MANAGED_DATADIR_PATHS {
1006        let path = managed_datadir_path(entry, target_dir, static_files_dir);
1007        if !path.try_exists()? {
1008            continue;
1009        }
1010
1011        let metadata = fs::metadata(&path)?;
1012        if metadata.is_dir() {
1013            fs::remove_dir_all(&path)?;
1014        } else if metadata.is_file() {
1015            fs::remove_file(&path)?;
1016        }
1017    }
1018
1019    Ok(())
1020}
1021
1022/// Resolves one of [`MANAGED_DATADIR_PATHS`], honoring a custom static files directory.
1023fn managed_datadir_path(
1024    entry: &str,
1025    target_dir: &Path,
1026    static_files_dir: Option<&Path>,
1027) -> PathBuf {
1028    if entry == "static_files" {
1029        static_files_dir.map_or_else(|| target_dir.join(entry), Path::to_path_buf)
1030    } else {
1031        target_dir.join(entry)
1032    }
1033}
1034
1035/// If all data components (txs, receipts, changesets) are `All`, automatically
1036/// include hidden archive-only components when available in the manifest.
1037fn inject_archive_only_components(
1038    selections: &mut BTreeMap<SnapshotComponentType, ComponentSelection>,
1039    manifest: &SnapshotManifest,
1040    include_rocksdb: bool,
1041) {
1042    let is_all =
1043        |ty: SnapshotComponentType| selections.get(&ty).copied() == Some(ComponentSelection::All);
1044
1045    let is_archive = is_all(SnapshotComponentType::Transactions) &&
1046        is_all(SnapshotComponentType::Receipts) &&
1047        is_all(SnapshotComponentType::AccountChangesets) &&
1048        is_all(SnapshotComponentType::StorageChangesets);
1049
1050    if !is_archive {
1051        return;
1052    }
1053
1054    for component in
1055        [SnapshotComponentType::TransactionSenders, SnapshotComponentType::RocksdbIndices]
1056    {
1057        if component == SnapshotComponentType::RocksdbIndices && !include_rocksdb {
1058            continue;
1059        }
1060
1061        if manifest.component(component).is_some() {
1062            selections.insert(component, ComponentSelection::All);
1063        }
1064    }
1065}
1066
1067/// Selects the manifest's RocksDB indices, if it publishes them.
1068fn inject_rocksdb_indices(
1069    selections: &mut BTreeMap<SnapshotComponentType, ComponentSelection>,
1070    manifest: &SnapshotManifest,
1071) {
1072    if manifest.component(SnapshotComponentType::RocksdbIndices).is_some() {
1073        selections.insert(SnapshotComponentType::RocksdbIndices, ComponentSelection::All);
1074    }
1075}
1076
1077/// Returns `true` when RocksDB-backed index stages should be reset after download.
1078fn should_reset_index_stage_checkpoints(
1079    selections: &BTreeMap<SnapshotComponentType, ComponentSelection>,
1080) -> bool {
1081    !matches!(selections.get(&SnapshotComponentType::RocksdbIndices), Some(ComponentSelection::All))
1082}
1083
1084fn startup_node_command<C>(chain_spec: &C::ChainSpec) -> String
1085where
1086    C: ChainSpecParser,
1087    C::ChainSpec: EthChainSpec,
1088{
1089    startup_node_command_for_binary::<C>(&current_binary_name(), chain_spec)
1090}
1091
1092fn startup_node_command_for_binary<C>(binary_name: &str, chain_spec: &C::ChainSpec) -> String
1093where
1094    C: ChainSpecParser,
1095    C::ChainSpec: EthChainSpec,
1096{
1097    let mut command = format!("{binary_name} node");
1098
1099    if let Some(chain_arg) = startup_chain_arg::<C>(chain_spec) {
1100        command.push_str(" --chain ");
1101        command.push_str(&chain_arg);
1102    }
1103
1104    command
1105}
1106
1107fn current_binary_name() -> String {
1108    std::env::args_os()
1109        .next()
1110        .map(PathBuf::from)
1111        .and_then(|path| path.file_stem().map(|name| name.to_owned()))
1112        .and_then(|name| name.into_string().ok())
1113        .filter(|name| !name.is_empty())
1114        .unwrap_or_else(|| "reth".to_string())
1115}
1116
1117fn download_command() -> String {
1118    download_command_for_binary(&current_binary_name())
1119}
1120
1121fn download_command_for_binary(binary_name: &str) -> String {
1122    format!("{binary_name} download")
1123}
1124
1125fn startup_chain_arg<C>(chain_spec: &C::ChainSpec) -> Option<String>
1126where
1127    C: ChainSpecParser,
1128    C::ChainSpec: EthChainSpec,
1129{
1130    let current_chain = chain_spec.chain();
1131    let current_genesis_hash = chain_spec.genesis_hash();
1132    let default_chain = C::default_value().and_then(|chain_name| C::parse(chain_name).ok());
1133
1134    if default_chain.as_ref().is_some_and(|default_chain| {
1135        default_chain.chain() == current_chain &&
1136            default_chain.genesis_hash() == current_genesis_hash
1137    }) {
1138        return None;
1139    }
1140
1141    C::SUPPORTED_CHAINS
1142        .iter()
1143        .find_map(|chain_name| {
1144            let parsed_chain = C::parse(chain_name).ok()?;
1145            (parsed_chain.chain() == current_chain &&
1146                parsed_chain.genesis_hash() == current_genesis_hash)
1147                .then(|| (*chain_name).to_string())
1148        })
1149        .or_else(|| Some("<chain-or-chainspec>".to_string()))
1150}
1151
1152impl<C: ChainSpecParser> DownloadCommand<C> {
1153    /// Returns a reference to the environment arguments.
1154    pub const fn env(&self) -> &EnvironmentArgs<C> {
1155        &self.env
1156    }
1157
1158    /// Returns the underlying chain being used to run this command
1159    pub fn chain_spec(&self) -> Option<&Arc<C::ChainSpec>> {
1160        Some(&self.env.chain)
1161    }
1162
1163    /// Returns whether this command should print its modular archive plan and exit.
1164    pub const fn prints_plan_json(&self) -> bool {
1165        self.print_plan_json
1166    }
1167}
1168
1169/// A modular snapshot download after manifest and data-directory resolution.
1170#[derive(Debug)]
1171pub struct PreparedSnapshotDownload {
1172    /// Manifest selected by the command, with a normalized `base_url`.
1173    pub manifest: SnapshotManifest,
1174    /// Chain-resolved directory where Reth installs the snapshot.
1175    pub data_dir: PathBuf,
1176}
1177
1178struct ResolvedDownload {
1179    manifest: SnapshotManifest,
1180    selections: BTreeMap<SnapshotComponentType, ComponentSelection>,
1181    preset: Option<SelectionPreset>,
1182    planned: PlannedDownloads,
1183}
1184
1185const MAX_DOWNLOAD_RETRIES: u32 = 10;
1186const RETRY_BACKOFF_SECS: u64 = 5;
1187
1188#[cfg(test)]
1189mod tests {
1190    use super::*;
1191    use clap::{Args, Parser};
1192    use extract::CompressionFormat;
1193    use manifest::{ComponentManifest, OutputFileChecksum, SingleArchive};
1194    use reth_chainspec::{HOLESKY, MAINNET};
1195    use reth_ethereum_cli::chainspec::EthereumChainSpecParser;
1196
1197    #[derive(Parser)]
1198    struct CommandParser<T: Args> {
1199        #[command(flatten)]
1200        args: T,
1201    }
1202
1203    fn manifest_with_archive_only_components() -> SnapshotManifest {
1204        let mut components = BTreeMap::new();
1205        components.insert(
1206            SnapshotComponentType::TransactionSenders.key().to_string(),
1207            ComponentManifest::Single(SingleArchive {
1208                file: "transaction_senders.tar.zst".to_string(),
1209                size: 1,
1210                decompressed_size: 0,
1211                blake3: None,
1212                output_files: vec![],
1213            }),
1214        );
1215        components.insert(
1216            SnapshotComponentType::RocksdbIndices.key().to_string(),
1217            ComponentManifest::Single(SingleArchive {
1218                file: "rocksdb_indices.tar.zst".to_string(),
1219                size: 1,
1220                decompressed_size: 0,
1221                blake3: None,
1222                output_files: vec![],
1223            }),
1224        );
1225        SnapshotManifest {
1226            block: 0,
1227            chain_id: 1,
1228            storage_version: 2,
1229            timestamp: 0,
1230            base_url: Some("https://example.com".to_string()),
1231            reth_version: None,
1232            components,
1233            extensions: Default::default(),
1234        }
1235    }
1236
1237    #[test]
1238    fn test_download_defaults_builder() {
1239        let defaults = DownloadDefaults::default()
1240            .with_snapshot("https://example.com/snapshots (example)")
1241            .with_base_url("https://example.com");
1242
1243        assert_eq!(defaults.default_base_url, "https://example.com");
1244        assert_eq!(defaults.available_snapshots.len(), 3); // 2 defaults + 1 added
1245    }
1246
1247    #[test]
1248    fn test_download_defaults_replace_snapshots() {
1249        let defaults = DownloadDefaults::default().with_snapshots(vec![
1250            Cow::Borrowed("https://custom1.com"),
1251            Cow::Borrowed("https://custom2.com"),
1252        ]);
1253
1254        assert_eq!(defaults.available_snapshots.len(), 2);
1255        assert_eq!(defaults.available_snapshots[0], "https://custom1.com");
1256    }
1257
1258    #[test]
1259    fn test_long_help_generation() {
1260        let defaults = DownloadDefaults::default();
1261        let help = defaults.long_help();
1262
1263        assert!(help.contains("Available snapshot sources:"));
1264        assert!(help.contains("Ethereum mainnet"));
1265        assert!(help.contains("snapshots.reth.rs"));
1266        assert!(help.contains("publicnode.com"));
1267        assert!(help.contains("file://"));
1268    }
1269
1270    #[test]
1271    fn test_custom_snapshot_api_keeps_selected_chain_help() {
1272        let defaults = DownloadDefaults::default()
1273            .with_snapshot_api_url("https://snapshots.tempoxyz.dev/api/snapshots");
1274        let help = defaults.long_help();
1275
1276        assert_eq!(
1277            defaults.default_chain_aware_base_url.as_deref(),
1278            Some("https://snapshots.tempoxyz.dev")
1279        );
1280        assert!(help.contains("Browse available snapshots at https://snapshots.tempoxyz.dev"));
1281        assert!(help.contains("- https://snapshots.tempoxyz.dev (default)"));
1282        assert!(help.contains("selected chain"));
1283        assert!(!help.contains("Ethereum mainnet"));
1284        assert!(!help.contains("snapshots.reth.rs"));
1285    }
1286
1287    #[test]
1288    fn test_snapshot_source_url_sets_generated_references() {
1289        let defaults =
1290            DownloadDefaults::default().with_snapshot_source_url("https://snapshots.tempoxyz.dev/");
1291        let help = defaults.long_help();
1292
1293        assert_eq!(defaults.snapshot_api_url, "https://snapshots.tempoxyz.dev/api/snapshots");
1294        assert_eq!(defaults.default_base_url, "https://snapshots.tempoxyz.dev");
1295        assert_eq!(
1296            defaults.default_chain_aware_base_url.as_deref(),
1297            Some("https://snapshots.tempoxyz.dev")
1298        );
1299        assert_eq!(
1300            defaults.available_snapshots.iter().map(|source| source.as_ref()).collect::<Vec<_>>(),
1301            vec!["https://snapshots.tempoxyz.dev (default)"]
1302        );
1303        assert!(!defaults.mainnet_only_discovery());
1304        assert!(help.contains("Browse available snapshots at https://snapshots.tempoxyz.dev"));
1305        assert!(help.contains("from https://snapshots.tempoxyz.dev."));
1306    }
1307
1308    #[test]
1309    fn test_snapshot_api_url_trailing_slash_sets_source_url() {
1310        let defaults = DownloadDefaults::default()
1311            .with_snapshot_api_url("https://snapshots.tempoxyz.dev/api/snapshots/");
1312        let help = defaults.long_help();
1313
1314        assert_eq!(
1315            defaults.default_chain_aware_base_url.as_deref(),
1316            Some("https://snapshots.tempoxyz.dev")
1317        );
1318        assert!(help.contains("Browse available snapshots at https://snapshots.tempoxyz.dev"));
1319        assert!(help.contains("- https://snapshots.tempoxyz.dev (default)"));
1320    }
1321
1322    #[test]
1323    fn test_long_help_override() {
1324        let custom_help = "This is custom help text for downloading snapshots.";
1325        let defaults = DownloadDefaults::default().with_long_help(custom_help);
1326
1327        let help = defaults.long_help();
1328        assert_eq!(help, custom_help);
1329        assert!(!help.contains("Available snapshot sources:"));
1330    }
1331
1332    #[test]
1333    fn test_builder_chaining() {
1334        let defaults = DownloadDefaults::default()
1335            .with_base_url("https://custom.example.com")
1336            .with_snapshot("https://snapshot1.com")
1337            .with_snapshot("https://snapshot2.com")
1338            .with_long_help("Custom help for snapshots");
1339
1340        assert_eq!(defaults.default_base_url, "https://custom.example.com");
1341        assert_eq!(defaults.available_snapshots.len(), 4); // 2 defaults + 2 added
1342        assert_eq!(defaults.long_help, Some("Custom help for snapshots".to_string()));
1343    }
1344
1345    #[test]
1346    fn test_download_prune_unlisted_conflicts_with_force_list_and_url() {
1347        let parse = |args: &[&str]| {
1348            CommandParser::<DownloadCommand<EthereumChainSpecParser>>::try_parse_from(args)
1349        };
1350
1351        assert!(parse(&["reth", "--prune-unlisted"]).unwrap().args.prune_unlisted);
1352        assert!(parse(&["reth", "--prune-unlisted", "--force"]).is_err());
1353        assert!(parse(&["reth", "--prune-unlisted", "--list"]).is_err());
1354        assert!(
1355            parse(&["reth", "--prune-unlisted", "--url", "https://example.com/a.tar.zst"]).is_err()
1356        );
1357    }
1358
1359    #[test]
1360    fn test_download_resumable_defaults_to_true() {
1361        let args =
1362            CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from(["reth"]).args;
1363
1364        assert!(args.resumable);
1365    }
1366
1367    #[test]
1368    fn test_download_resumable_implicit_true() {
1369        let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1370            "reth",
1371            "--resumable",
1372        ])
1373        .args;
1374
1375        assert!(args.resumable);
1376    }
1377
1378    #[test]
1379    fn test_download_resumable_explicit_false() {
1380        let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1381            "reth",
1382            "--resumable=false",
1383        ])
1384        .args;
1385
1386        assert!(!args.resumable);
1387    }
1388
1389    #[test]
1390    fn test_download_print_plan_json_parses() {
1391        let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1392            "reth",
1393            "--manifest-path",
1394            "manifest.json",
1395            "--minimal",
1396            "--print-plan-json",
1397        ])
1398        .args;
1399
1400        assert!(args.prints_plan_json());
1401    }
1402
1403    #[test]
1404    fn test_download_print_plan_json_rejects_single_archive() {
1405        let result = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::try_parse_from([
1406            "reth",
1407            "--url",
1408            "https://example.com/snapshot.tar.zst",
1409            "--print-plan-json",
1410        ]);
1411
1412        assert!(result.is_err());
1413    }
1414
1415    #[test]
1416    fn minimal_component_selection_uses_configured_prune_modes() {
1417        let args =
1418            CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from(["reth"]).args;
1419        let history_distance = 64_864;
1420        let modes = PruneModes {
1421            sender_recovery: Some(PruneMode::Full),
1422            receipts: Some(PruneMode::Distance(128)),
1423            account_history: Some(PruneMode::Distance(history_distance)),
1424            storage_history: Some(PruneMode::Distance(history_distance)),
1425            bodies_history: Some(PruneMode::Distance(history_distance)),
1426            ..Default::default()
1427        };
1428
1429        assert_eq!(
1430            args.pruning_selection_for_component(
1431                SnapshotComponentType::Transactions,
1432                1_000_000,
1433                &modes,
1434                false,
1435            ),
1436            ComponentSelection::Distance(history_distance)
1437        );
1438        assert_eq!(
1439            args.pruning_selection_for_component(
1440                SnapshotComponentType::Receipts,
1441                1_000_000,
1442                &modes,
1443                false,
1444            ),
1445            ComponentSelection::Distance(128)
1446        );
1447        assert_eq!(
1448            args.pruning_selection_for_component(
1449                SnapshotComponentType::AccountChangesets,
1450                1_000_000,
1451                &modes,
1452                false,
1453            ),
1454            ComponentSelection::Distance(history_distance)
1455        );
1456        assert_eq!(
1457            args.pruning_selection_for_component(
1458                SnapshotComponentType::StorageChangesets,
1459                1_000_000,
1460                &modes,
1461                false,
1462            ),
1463            ComponentSelection::Distance(history_distance)
1464        );
1465        assert_eq!(
1466            args.pruning_selection_for_component(
1467                SnapshotComponentType::TransactionSenders,
1468                1_000_000,
1469                &modes,
1470                false,
1471            ),
1472            ComponentSelection::None
1473        );
1474    }
1475
1476    #[test]
1477    fn resolve_manifest_source_requires_explicit_source_for_non_mainnet_defaults() {
1478        let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1479            "reth", "--chain", "holesky",
1480        ])
1481        .args;
1482
1483        let err = tokio::runtime::Runtime::new()
1484            .unwrap()
1485            .block_on(args.resolve_manifest_source(HOLESKY.chain.id()))
1486            .unwrap_err();
1487
1488        let message = err.to_string();
1489        assert!(message.contains("only auto-discovered for Ethereum mainnet"));
1490        assert!(message.contains("--manifest-url <URL>"));
1491        assert!(message.contains("-u <SNAPSHOT-URL>"));
1492    }
1493
1494    #[test]
1495    fn resolve_manifest_source_allows_manifest_path_for_non_mainnet_defaults() {
1496        let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1497            "reth",
1498            "--chain",
1499            "holesky",
1500            "--manifest-path",
1501            "./manifest.json",
1502        ])
1503        .args;
1504
1505        let source = tokio::runtime::Runtime::new()
1506            .unwrap()
1507            .block_on(args.resolve_manifest_source(HOLESKY.chain.id()))
1508            .unwrap();
1509
1510        assert_eq!(source, "./manifest.json");
1511    }
1512
1513    #[test]
1514    fn test_compression_format_detection() {
1515        assert!(matches!(
1516            CompressionFormat::from_url("https://example.com/snapshot.tar.lz4"),
1517            Ok(CompressionFormat::Lz4)
1518        ));
1519        assert!(matches!(
1520            CompressionFormat::from_url("https://example.com/snapshot.tar.zst"),
1521            Ok(CompressionFormat::Zstd)
1522        ));
1523        assert!(matches!(
1524            CompressionFormat::from_url("file:///path/to/snapshot.tar.lz4"),
1525            Ok(CompressionFormat::Lz4)
1526        ));
1527        assert!(matches!(
1528            CompressionFormat::from_url("file:///path/to/snapshot.tar.zst"),
1529            Ok(CompressionFormat::Zstd)
1530        ));
1531        assert!(CompressionFormat::from_url("https://example.com/snapshot.tar.gz").is_err());
1532    }
1533
1534    #[test]
1535    fn inject_archive_only_components_for_archive_selection() {
1536        let manifest = manifest_with_archive_only_components();
1537        let mut selections = BTreeMap::new();
1538        selections.insert(SnapshotComponentType::Transactions, ComponentSelection::All);
1539        selections.insert(SnapshotComponentType::Receipts, ComponentSelection::All);
1540        selections.insert(SnapshotComponentType::AccountChangesets, ComponentSelection::All);
1541        selections.insert(SnapshotComponentType::StorageChangesets, ComponentSelection::All);
1542
1543        inject_archive_only_components(&mut selections, &manifest, true);
1544
1545        assert_eq!(
1546            selections.get(&SnapshotComponentType::TransactionSenders),
1547            Some(&ComponentSelection::All)
1548        );
1549        assert_eq!(
1550            selections.get(&SnapshotComponentType::RocksdbIndices),
1551            Some(&ComponentSelection::All)
1552        );
1553    }
1554
1555    #[test]
1556    fn inject_archive_only_components_without_rocksdb() {
1557        let manifest = manifest_with_archive_only_components();
1558        let mut selections = BTreeMap::new();
1559        selections.insert(SnapshotComponentType::Transactions, ComponentSelection::All);
1560        selections.insert(SnapshotComponentType::Receipts, ComponentSelection::All);
1561        selections.insert(SnapshotComponentType::AccountChangesets, ComponentSelection::All);
1562        selections.insert(SnapshotComponentType::StorageChangesets, ComponentSelection::All);
1563
1564        inject_archive_only_components(&mut selections, &manifest, false);
1565
1566        assert_eq!(
1567            selections.get(&SnapshotComponentType::TransactionSenders),
1568            Some(&ComponentSelection::All)
1569        );
1570        assert_eq!(selections.get(&SnapshotComponentType::RocksdbIndices), None);
1571    }
1572
1573    #[test]
1574    fn inject_rocksdb_indices_keeps_index_stages() {
1575        let manifest = manifest_with_archive_only_components();
1576        let mut selections = BTreeMap::new();
1577        selections
1578            .insert(SnapshotComponentType::AccountChangesets, ComponentSelection::Distance(10));
1579
1580        inject_rocksdb_indices(&mut selections, &manifest);
1581
1582        assert_eq!(
1583            selections.get(&SnapshotComponentType::RocksdbIndices),
1584            Some(&ComponentSelection::All)
1585        );
1586        assert_eq!(selections.get(&SnapshotComponentType::TransactionSenders), None);
1587        assert!(!should_reset_index_stage_checkpoints(&selections));
1588    }
1589
1590    #[test]
1591    fn inject_rocksdb_indices_skips_manifest_without_indices() {
1592        let mut manifest = manifest_with_archive_only_components();
1593        manifest.components.clear();
1594        let mut selections = BTreeMap::new();
1595
1596        inject_rocksdb_indices(&mut selections, &manifest);
1597
1598        assert!(selections.is_empty());
1599        assert!(should_reset_index_stage_checkpoints(&selections));
1600    }
1601
1602    #[test]
1603    fn should_reset_index_stage_checkpoints_without_rocksdb_indices() {
1604        let mut selections = BTreeMap::new();
1605        selections.insert(SnapshotComponentType::Transactions, ComponentSelection::All);
1606        assert!(should_reset_index_stage_checkpoints(&selections));
1607
1608        selections.insert(SnapshotComponentType::RocksdbIndices, ComponentSelection::All);
1609        assert!(!should_reset_index_stage_checkpoints(&selections));
1610    }
1611
1612    #[test]
1613    fn startup_node_command_omits_default_chain_arg() {
1614        let command =
1615            startup_node_command_for_binary::<EthereumChainSpecParser>("reth", MAINNET.as_ref());
1616
1617        assert_eq!(command, "reth node");
1618    }
1619
1620    #[test]
1621    fn startup_node_command_includes_non_default_chain_arg() {
1622        let command =
1623            startup_node_command_for_binary::<EthereumChainSpecParser>("reth", HOLESKY.as_ref());
1624
1625        assert_eq!(command, "reth node --chain holesky");
1626    }
1627
1628    #[test]
1629    fn startup_node_command_uses_running_binary_name() {
1630        let command =
1631            startup_node_command_for_binary::<EthereumChainSpecParser>("tempo", HOLESKY.as_ref());
1632
1633        assert_eq!(command, "tempo node --chain holesky");
1634    }
1635
1636    #[test]
1637    fn download_command_uses_binary_name() {
1638        assert_eq!(download_command_for_binary("tempo"), "tempo download");
1639    }
1640
1641    #[tokio::test]
1642    async fn test_download_explicit_source_and_datadir_allow_other_chains() {
1643        let dir = tempfile::tempdir().unwrap();
1644        let manifest_path = dir.path().join("manifest.json");
1645        let mut manifest = manifest_with_archive_only_components();
1646        manifest.chain_id = 42431;
1647        for component in manifest.components.values_mut() {
1648            if let ComponentManifest::Single(archive) = component {
1649                archive.output_files.push(OutputFileChecksum {
1650                    path: archive.file.clone(),
1651                    size: 1,
1652                    blake3: blake3::hash(b"x").to_hex().to_string(),
1653                });
1654            }
1655        }
1656        fs::write(&manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap();
1657        let manifest_url = format!("file://{}", manifest_path.display());
1658        let target = dir.path().join("download");
1659
1660        for source in ["--manifest-url", "--manifest-path"] {
1661            let location = if source == "--manifest-url" {
1662                manifest_url.as_str()
1663            } else {
1664                manifest_path.to_str().unwrap()
1665            };
1666            for selection in ["--archive", "--minimal", "--non-interactive", "--with-txs"] {
1667                let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1668                    "reth",
1669                    source,
1670                    location,
1671                    "--datadir",
1672                    target.to_str().unwrap(),
1673                    selection,
1674                ])
1675                .args;
1676                let (plan, prepared) = args.plan().await.unwrap();
1677                assert_eq!(plan.chain_id, 42431);
1678                assert_eq!(prepared.manifest.chain_id, 42431);
1679                assert_eq!(prepared.data_dir, target);
1680                assert!(!target.exists());
1681            }
1682        }
1683    }
1684
1685    #[tokio::test]
1686    async fn test_download_chain_derived_datadir_rejects_other_chains() {
1687        let dir = tempfile::tempdir().unwrap();
1688        let manifest_path = dir.path().join("manifest.json");
1689        let mut manifest = manifest_with_archive_only_components();
1690        manifest.chain_id = 42431;
1691        fs::write(&manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap();
1692
1693        for datadir in [vec![], vec!["--datadir", "default"]] {
1694            let mut cli_args =
1695                vec!["reth", "--manifest-path", manifest_path.to_str().unwrap(), "--archive"];
1696            cli_args.extend(datadir);
1697            let args =
1698                CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from(cli_args)
1699                    .args;
1700            assert_eq!(
1701                args.plan().await.unwrap_err().to_string(),
1702                "Snapshot chain ID 42431 does not match selected chain ID 1"
1703            );
1704        }
1705    }
1706
1707    #[test]
1708    fn test_download_full_chain_derived_pruning_rejects_other_chains() {
1709        let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1710            "reth",
1711            "--datadir",
1712            "download",
1713            "--manifest-url",
1714            "https://example.com/manifest.json",
1715            "--full",
1716        ])
1717        .args;
1718        let mut manifest = manifest_with_archive_only_components();
1719        manifest.chain_id = 42431;
1720        assert_eq!(
1721            args.full_preset_selections(&manifest).unwrap_err().to_string(),
1722            "Snapshot chain ID 42431 does not match selected chain ID 1"
1723        );
1724        manifest.chain_id = 1;
1725        assert!(args.full_preset_selections(&manifest).is_ok());
1726    }
1727}