Skip to main content

reth_era_downloader/
client.rs

1use alloy_primitives::{hex, hex::ToHexExt};
2use bytes::Bytes;
3use eyre::{eyre, OptionExt};
4use futures_util::{stream::StreamExt, Stream, TryStreamExt};
5use reqwest::{Client, IntoUrl, Url};
6use reth_era::common::file_ops::EraFileType;
7use sha2::{Digest, Sha256};
8use std::{future::Future, path::Path, str::FromStr};
9use tokio::{
10    fs::{self, File},
11    io::{self, AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWriteExt},
12    try_join,
13};
14
15/// Downloaded index page filename
16const INDEX_HTML_FILE: &str = "index.html";
17
18/// Accesses the network over HTTP.
19pub trait HttpClient {
20    /// Makes an HTTP GET request to `url`. Returns a stream of response body bytes.
21    fn get<U: IntoUrl + Send + Sync>(
22        &self,
23        url: U,
24    ) -> impl Future<
25        Output = eyre::Result<impl Stream<Item = eyre::Result<Bytes>> + Send + Sync + Unpin>,
26    > + Send
27           + Sync;
28}
29
30impl HttpClient for Client {
31    async fn get<U: IntoUrl + Send + Sync>(
32        &self,
33        url: U,
34    ) -> eyre::Result<impl Stream<Item = eyre::Result<Bytes>> + Unpin> {
35        let response = Self::get(self, url).send().await?;
36
37        Ok(response.bytes_stream().map_err(|e| eyre::Error::new(e)))
38    }
39}
40
41/// An HTTP client with features for downloading ERA files from an external HTTP accessible
42/// endpoint.
43#[derive(Debug, Clone)]
44pub struct EraClient<Http> {
45    client: Http,
46    url: Url,
47    folder: Box<Path>,
48    era_type: EraFileType,
49}
50
51impl<Http: HttpClient + Clone> EraClient<Http> {
52    const CHECKSUMS: &'static str = "checksums.txt";
53
54    /// Constructs [`EraClient`] using `client` to download from `url` into `folder`.
55    ///
56    /// The file type is auto-detected from the URL. Use
57    /// [`with_era_type`](Self::with_era_type) to override.
58    pub fn new(client: Http, url: Url, folder: impl Into<Box<Path>>) -> Self {
59        let era_type = EraFileType::from_url(url.as_str());
60        Self { client, url, folder: folder.into(), era_type }
61    }
62
63    /// Override the auto-detected [`EraFileType`].
64    pub const fn with_era_type(mut self, era_type: EraFileType) -> Self {
65        self.era_type = era_type;
66        self
67    }
68
69    /// Performs a GET request on `url` and stores the response body into a file located within
70    /// the `folder`.
71    pub async fn download_to_file(&mut self, url: impl IntoUrl) -> eyre::Result<Box<Path>> {
72        let path = self.folder.to_path_buf();
73
74        let url = url.into_url()?;
75        let client = self.client.clone();
76        let file_name = url
77            .path_segments()
78            .ok_or_eyre("cannot-be-a-base")?
79            .next_back()
80            .ok_or_eyre("empty path segments")?;
81        let path = path.join(file_name);
82
83        if !self.is_downloaded(file_name, &path).await? {
84            let number = self
85                .file_name_to_number(file_name)
86                .ok_or_eyre("Cannot parse number from file name")?;
87
88            // Download to a temp path and rename in only on success, so an interrupted download
89            // never leaves a partial file that later looks complete.
90            let tmp_path = path.with_extension("tmp");
91
92            let mut tries = 1..3;
93            let mut actual_checksum: eyre::Result<_>;
94            loop {
95                actual_checksum = async {
96                    let mut file = File::create(&tmp_path).await?;
97                    let mut stream = client.get(url.clone()).await?;
98                    let mut hasher = Sha256::new();
99
100                    while let Some(item) = stream.next().await.transpose()? {
101                        io::copy(&mut item.as_ref(), &mut file).await?;
102                        hasher.update(item);
103                    }
104
105                    Ok(hasher.finalize().to_vec())
106                }
107                .await;
108
109                if actual_checksum.is_ok() || tries.next().is_none() {
110                    break;
111                }
112            }
113
114            if self.era_type.has_checksums() {
115                self.assert_checksum(number, actual_checksum?)
116                    .await
117                    .map_err(|e| eyre!("{e} for {file_name} at {}", path.display()))?;
118            } else {
119                // No checksum to validate against; surface a failed download before renaming.
120                actual_checksum?;
121            }
122
123            fs::rename(&tmp_path, &path).await?;
124        }
125
126        Ok(path.into_boxed_path())
127    }
128
129    /// Recovers index of file following the latest downloaded file from a different run.
130    pub async fn recover_index(&self) -> Option<usize> {
131        let mut max = None;
132
133        if let Ok(mut dir) = fs::read_dir(&self.folder).await {
134            while let Ok(Some(entry)) = dir.next_entry().await {
135                if let Some(name) = entry.file_name().to_str() &&
136                    self.is_matching_era_file(name) &&
137                    let Some(number) = self.file_name_to_number(name) &&
138                    (max.is_none() || matches!(max, Some(max) if number > max))
139                {
140                    max.replace(number + 1);
141                }
142            }
143        }
144
145        max
146    }
147
148    /// Deletes files that are outside-of the working range.
149    pub async fn delete_outside_range(&self, index: usize, max_files: usize) -> eyre::Result<()> {
150        let last = index + max_files;
151
152        if let Ok(mut dir) = fs::read_dir(&self.folder).await {
153            while let Ok(Some(entry)) = dir.next_entry().await {
154                if let Some(name) = entry.file_name().to_str() &&
155                    self.is_matching_era_file(name) &&
156                    let Some(number) = self.file_name_to_number(name) &&
157                    (number < index || number >= last)
158                {
159                    reth_fs_util::remove_file_if_exists(entry.path())?;
160                }
161            }
162        }
163
164        Ok(())
165    }
166
167    /// Returns a download URL for the file corresponding to `number`.
168    pub async fn url(&self, number: usize) -> eyre::Result<Option<Url>> {
169        Ok(self.number_to_file_name(number).await?.map(|name| self.url.join(&name)).transpose()?)
170    }
171
172    /// Returns the number of files in the `folder`.
173    pub async fn files_count(&self) -> usize {
174        let mut count = 0usize;
175
176        if let Ok(mut dir) = fs::read_dir(&self.folder).await {
177            while let Ok(Some(entry)) = dir.next_entry().await {
178                if let Some(ext) = entry.path().extension().and_then(|ext| ext.to_str()) &&
179                    self.era_type
180                        .extensions()
181                        .iter()
182                        .any(|valid| valid.trim_start_matches('.') == ext)
183                {
184                    count += 1;
185                }
186            }
187        }
188
189        count
190    }
191
192    /// Fetches the list of ERA1/ERA files from `url` and stores it in a file located within
193    /// `folder`.
194    /// For era files, checksum.txt file does not exist, so the checksum verification is
195    /// skipped.
196    pub async fn fetch_file_list(&self) -> eyre::Result<()> {
197        let index_path = self.folder.to_path_buf().join(INDEX_HTML_FILE);
198        let checksums_path = self.folder.to_path_buf().join(Self::CHECKSUMS);
199
200        // Only for files that ship checksums (era1, ere) we also download the checksums file.
201        if self.era_type.has_checksums() {
202            let checksums_url = self.url.join(Self::CHECKSUMS)?;
203            try_join!(
204                self.download_file_to_path(self.url.clone(), &index_path),
205                self.download_file_to_path(checksums_url, &checksums_path)
206            )?;
207        } else {
208            // Download only index file
209            self.download_file_to_path(self.url.clone(), &index_path).await?;
210        }
211
212        // Parse and extract era filenames from index.html
213        self.extract_era_filenames(&index_path).await?;
214
215        Ok(())
216    }
217
218    /// Extracts ERA filenames from `index.html` and writes them to the index file
219    async fn extract_era_filenames(&self, index_path: &Path) -> eyre::Result<()> {
220        let file = File::open(index_path).await?;
221        let reader = io::BufReader::new(file);
222        let mut lines = reader.lines();
223
224        let path = self.folder.to_path_buf().join("index");
225        let file = File::create(&path).await?;
226        let mut writer = io::BufWriter::new(file);
227
228        while let Some(line) = lines.next_line().await? {
229            if let Some(era) = extract_era_filename(&line, self.era_type.extensions()) {
230                writer.write_all(era.as_bytes()).await?;
231                writer.write_all(b"\n").await?;
232            }
233        }
234
235        writer.flush().await?;
236        Ok(())
237    }
238
239    // Helper to download a file to a specified path
240    async fn download_file_to_path(&self, url: Url, path: &Path) -> eyre::Result<()> {
241        let mut stream = self.client.get(url).await?;
242        let mut file = File::create(path).await?;
243
244        while let Some(item) = stream.next().await.transpose()? {
245            io::copy(&mut item.as_ref(), &mut file).await?;
246        }
247
248        Ok(())
249    }
250
251    /// Returns ERA1/ERA file name that is ordered at `number`.
252    pub async fn number_to_file_name(&self, number: usize) -> eyre::Result<Option<String>> {
253        let path = self.folder.to_path_buf().join("index");
254        let file = File::open(&path).await?;
255        let reader = io::BufReader::new(file);
256        let mut lines = reader.lines();
257        for _ in 0..number {
258            lines.next_line().await?;
259        }
260
261        Ok(lines.next_line().await?)
262    }
263
264    async fn is_downloaded(&self, name: &str, path: impl AsRef<Path>) -> eyre::Result<bool> {
265        let path = path.as_ref();
266
267        match File::open(path).await {
268            Ok(file) => {
269                if self.era_type.has_checksums() {
270                    let number = self
271                        .file_name_to_number(name)
272                        .ok_or_else(|| eyre!("Cannot parse ERA number from {name}"))?;
273
274                    let actual_checksum = checksum(file).await?;
275                    let is_verified = self.verify_checksum(number, actual_checksum).await?;
276
277                    if !is_verified {
278                        fs::remove_file(path).await?;
279                    }
280
281                    Ok(is_verified)
282                } else {
283                    // For era files there is no checksums.txt, so verification is skipped.
284                    Ok(true)
285                }
286            }
287            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
288            Err(e) => Err(e)?,
289        }
290    }
291
292    /// Returns `true` if `actual_checksum` matches expected checksum of the ERA1 file indexed by
293    /// `number` based on the [file list].
294    ///
295    /// [file list]: Self::fetch_file_list
296    async fn verify_checksum(&self, number: usize, actual_checksum: Vec<u8>) -> eyre::Result<bool> {
297        Ok(actual_checksum == self.expected_checksum(number).await?)
298    }
299
300    /// Returns `Ok` if `actual_checksum` matches expected checksum of the ERA1 file indexed by
301    /// `number` based on the [file list].
302    ///
303    /// [file list]: Self::fetch_file_list
304    async fn assert_checksum(&self, number: usize, actual_checksum: Vec<u8>) -> eyre::Result<()> {
305        let expected_checksum = self.expected_checksum(number).await?;
306
307        if actual_checksum == expected_checksum {
308            Ok(())
309        } else {
310            Err(eyre!(
311                "Checksum mismatch, got: {}, expected: {}",
312                actual_checksum.encode_hex(),
313                expected_checksum.encode_hex()
314            ))
315        }
316    }
317
318    /// Returns SHA-256 checksum for ERA1 file indexed by `number` based on the [file list].
319    ///
320    /// [file list]: Self::fetch_file_list
321    async fn expected_checksum(&self, number: usize) -> eyre::Result<Vec<u8>> {
322        let file = File::open(self.folder.join(Self::CHECKSUMS)).await?;
323        let reader = io::BufReader::new(file);
324        let mut lines = reader.lines();
325
326        for _ in 0..number {
327            lines.next_line().await?;
328        }
329        let expected_checksum =
330            lines.next_line().await?.ok_or_else(|| eyre!("Missing hash for number {number}"))?;
331        let expected_checksum = hex::decode(expected_checksum)?;
332
333        Ok(expected_checksum)
334    }
335
336    fn file_name_to_number(&self, file_name: &str) -> Option<usize> {
337        file_name.split('-').nth(1).and_then(|v| usize::from_str(v).ok())
338    }
339
340    /// Whether `file_name` is a downloaded ERA file of this client's configured type.
341    ///
342    /// Excludes partial (`*.tmp`) and sidecar files that share the `<network>-<number>-...` stem,
343    /// so they don't influence resume or cleanup.
344    fn is_matching_era_file(&self, file_name: &str) -> bool {
345        EraFileType::from_filename(file_name) == Some(self.era_type)
346    }
347}
348
349/// Extracts an era filename ending in one of `extensions` from a single index line.
350///
351/// `extensions` are tried in order; pass them longest-first so `.ere` never matches inside `.erae`.
352fn extract_era_filename<'a>(line: &'a str, extensions: &[&str]) -> Option<&'a str> {
353    for ext in extensions {
354        if let Some(j) = line.find(ext) &&
355            let Some(i) = line[..j].rfind(|c: char| !c.is_alphanumeric() && c != '-')
356        {
357            return Some(&line[i + 1..j + ext.len()]);
358        }
359    }
360    None
361}
362
363async fn checksum(mut reader: impl AsyncRead + Unpin) -> eyre::Result<Vec<u8>> {
364    let mut hasher = Sha256::new();
365
366    // Create a buffer to read data into, sized for performance.
367    let mut data = vec![0; 64 * 1024];
368
369    loop {
370        // Read data from the reader into the buffer.
371        let len = reader.read(&mut data).await?;
372        if len == 0 {
373            break;
374        } // Exit loop if no more data.
375
376        // Update the hash with the data read.
377        hasher.update(&data[..len]);
378    }
379
380    // Finalize the hash after all data has been processed.
381    let hash = hasher.finalize().to_vec();
382
383    Ok(hash)
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use std::path::PathBuf;
390    use test_case::test_case;
391
392    impl EraClient<Client> {
393        fn empty() -> Self {
394            Self::new(Client::new(), Url::from_str("file:///").unwrap(), PathBuf::new())
395        }
396    }
397
398    #[test_case("mainnet-00600-a81ae85f.era1", Some(600))]
399    #[test_case("mainnet-00000-a81ae85f.era1", Some(0))]
400    #[test_case("00000-a81ae85f.era1", None)]
401    #[test_case("", None)]
402    fn test_file_name_to_number(file_name: &str, expected_number: Option<usize>) {
403        let client = EraClient::empty();
404
405        let actual_number = client.file_name_to_number(file_name);
406
407        assert_eq!(actual_number, expected_number);
408    }
409
410    // `.erae` lines must yield the full `.erae` name, never the `.ere` prefix inside it.
411    #[test_case(
412        "<a href=\"mainnet-00000-a6860fef.erae\">", &[".erae", ".ere"],
413        Some("mainnet-00000-a6860fef.erae"); "erae anchor not clipped to ere"
414    )]
415    #[test_case(
416        "    \"name\": \"mainnet-00001-05c64fc4.erae\",", &[".erae", ".ere"],
417        Some("mainnet-00001-05c64fc4.erae"); "erae json entry"
418    )]
419    #[test_case(
420        "<a href=\"mainnet-00600-a81ae85f.era1\">", &[".era1"],
421        Some("mainnet-00600-a81ae85f.era1"); "era1 anchor"
422    )]
423    #[test_case("<a href=\"checksums.txt\">", &[".erae", ".ere"], None; "no era file on line")]
424    fn test_extract_era_filename(line: &str, exts: &[&str], expected: Option<&str>) {
425        assert_eq!(extract_era_filename(line, exts), expected);
426    }
427
428    #[test]
429    fn test_with_era_type_overrides_auto_detection() {
430        // URL without "era1" auto-detects as Era
431        let client = EraClient::new(
432            Client::new(),
433            Url::from_str("https://example.com/").unwrap(),
434            PathBuf::new(),
435        );
436        assert_eq!(client.era_type, EraFileType::Era);
437
438        // with_era_type overrides to Era1
439        let client = client.with_era_type(EraFileType::Era1);
440        assert_eq!(client.era_type, EraFileType::Era1);
441    }
442}