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
15const INDEX_HTML_FILE: &str = "index.html";
17
18pub trait HttpClient {
20 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#[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 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 pub const fn with_era_type(mut self, era_type: EraFileType) -> Self {
65 self.era_type = era_type;
66 self
67 }
68
69 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 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 actual_checksum?;
121 }
122
123 fs::rename(&tmp_path, &path).await?;
124 }
125
126 Ok(path.into_boxed_path())
127 }
128
129 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 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 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 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 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 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 self.download_file_to_path(self.url.clone(), &index_path).await?;
210 }
211
212 self.extract_era_filenames(&index_path).await?;
214
215 Ok(())
216 }
217
218 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 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 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 Ok(true)
285 }
286 }
287 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
288 Err(e) => Err(e)?,
289 }
290 }
291
292 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 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 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 fn is_matching_era_file(&self, file_name: &str) -> bool {
345 EraFileType::from_filename(file_name) == Some(self.era_type)
346 }
347}
348
349fn 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 let mut data = vec![0; 64 * 1024];
368
369 loop {
370 let len = reader.read(&mut data).await?;
372 if len == 0 {
373 break;
374 } hasher.update(&data[..len]);
378 }
379
380 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 #[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 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 let client = client.with_era_type(EraFileType::Era1);
440 assert_eq!(client.era_type, EraFileType::Era1);
441 }
442}