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 reth_fs_util::FsPathError;
8use sha2::{Digest, Sha256};
9use std::{collections::HashMap, future::Future, path::Path, str::FromStr, sync::Arc};
10use tokio::{
11 fs::{self, File},
12 io::{self, AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWriteExt},
13 sync::Mutex,
14 try_join,
15};
16
17const INDEX_HTML_FILE: &str = "index.html";
19
20pub trait HttpClient {
22 fn get<U: IntoUrl + Send + Sync>(
24 &self,
25 url: U,
26 ) -> impl Future<
27 Output = eyre::Result<impl Stream<Item = eyre::Result<Bytes>> + Send + Sync + Unpin>,
28 > + Send
29 + Sync;
30}
31
32impl HttpClient for Client {
33 async fn get<U: IntoUrl + Send + Sync>(
34 &self,
35 url: U,
36 ) -> eyre::Result<impl Stream<Item = eyre::Result<Bytes>> + Unpin> {
37 let response = Self::get(self, url).send().await?;
38
39 Ok(response.bytes_stream().map_err(|e| eyre::Error::new(e)))
40 }
41}
42
43#[derive(Debug, Clone)]
46pub struct EraClient<Http> {
47 client: Http,
48 url: Url,
49 folder: Box<Path>,
50 era_type: EraFileType,
51 file_lists: Arc<Mutex<HashMap<EraFileType, FileList>>>,
52}
53
54impl<Http: HttpClient + Clone> EraClient<Http> {
55 const CHECKSUMS: &'static str = "checksums.txt";
56
57 pub fn new(client: Http, url: Url, folder: impl Into<Box<Path>>) -> Self {
62 let era_type = EraFileType::from_url(url.as_str());
63 Self { client, url, folder: folder.into(), era_type, file_lists: Default::default() }
64 }
65
66 pub const fn with_era_type(mut self, era_type: EraFileType) -> Self {
68 self.era_type = era_type;
69 self
70 }
71
72 pub async fn download_to_file(&mut self, url: impl IntoUrl) -> eyre::Result<Box<Path>> {
75 let path = self.folder.to_path_buf();
76
77 let url = url.into_url()?;
78 let client = self.client.clone();
79 let file_name = url
80 .path_segments()
81 .ok_or_eyre("cannot-be-a-base")?
82 .next_back()
83 .ok_or_eyre("empty path segments")?;
84 let path = path.join(file_name);
85
86 if !self.is_downloaded(file_name, &path).await? {
87 let number = self
88 .file_name_to_number(file_name)
89 .ok_or_eyre("Cannot parse number from file name")?;
90
91 let tmp_path = path.with_extension("tmp");
94
95 let mut tries = 1..3;
96 let mut actual_checksum: eyre::Result<_>;
97 loop {
98 actual_checksum = async {
99 let mut file = File::create(&tmp_path).await?;
100 let mut stream = client.get(url.clone()).await?;
101 let mut hasher = Sha256::new();
102
103 while let Some(item) = stream.next().await.transpose()? {
104 io::copy(&mut item.as_ref(), &mut file).await?;
105 hasher.update(item);
106 }
107
108 Ok(hasher.finalize().to_vec())
109 }
110 .await;
111
112 if actual_checksum.is_ok() || tries.next().is_none() {
113 break;
114 }
115 }
116
117 if self.era_type.has_checksums() {
118 self.assert_checksum(number, actual_checksum?)
119 .await
120 .map_err(|e| eyre!("{e} for {file_name} at {}", path.display()))?;
121 } else {
122 actual_checksum?;
124 }
125
126 fs::rename(&tmp_path, &path).await?;
127 }
128
129 Ok(path.into_boxed_path())
130 }
131
132 pub async fn recover_index(&self) -> Option<usize> {
134 let mut max = None;
135
136 if let Ok(mut dir) = fs::read_dir(&self.folder).await {
137 while let Ok(Some(entry)) = dir.next_entry().await {
138 if let Some(name) = entry.file_name().to_str() &&
139 self.is_matching_era_file(name) &&
140 let Some(number) = self.file_name_to_number(name) &&
141 (max.is_none() || matches!(max, Some(max) if number > max))
142 {
143 max.replace(number + 1);
144 }
145 }
146 }
147
148 max
149 }
150
151 pub async fn delete_outside_range(&self, index: usize, max_files: usize) -> eyre::Result<()> {
153 let last = index + max_files;
154
155 if let Ok(mut dir) = fs::read_dir(&self.folder).await {
156 while let Ok(Some(entry)) = dir.next_entry().await {
157 if let Some(name) = entry.file_name().to_str() &&
158 self.is_matching_era_file(name) &&
159 let Some(number) = self.file_name_to_number(name) &&
160 (number < index || number >= last)
161 {
162 reth_fs_util::remove_file_if_exists(entry.path())?;
163 }
164 }
165 }
166
167 Ok(())
168 }
169
170 pub async fn url(&self, number: usize) -> eyre::Result<Option<Url>> {
172 Ok(self.number_to_file_name(number).await?.map(|name| self.url.join(&name)).transpose()?)
173 }
174
175 pub async fn files_count(&self) -> usize {
177 let mut count = 0usize;
178
179 if let Ok(mut dir) = fs::read_dir(&self.folder).await {
180 while let Ok(Some(entry)) = dir.next_entry().await {
181 if let Some(ext) = entry.path().extension().and_then(|ext| ext.to_str()) &&
182 self.era_type
183 .extensions()
184 .iter()
185 .any(|valid| valid.trim_start_matches('.') == ext)
186 {
187 count += 1;
188 }
189 }
190 }
191
192 count
193 }
194
195 pub async fn fetch_file_list(&self) -> eyre::Result<()> {
206 let mut file_lists = self.file_lists.lock().await;
207 let index_path = self.folder.to_path_buf().join(INDEX_HTML_FILE);
208 let checksums_path = self.folder.to_path_buf().join(Self::CHECKSUMS);
209
210 if self.era_type.has_checksums() {
212 let checksums_url = self.url.join(Self::CHECKSUMS)?;
213 try_join!(
214 self.download_file_to_path(self.url.clone(), &index_path),
215 self.download_file_to_path(checksums_url, &checksums_path)
216 )?;
217 } else {
218 self.download_file_to_path(self.url.clone(), &index_path).await?;
220 }
221
222 let file_names = self.extract_era_filenames(&index_path).await?;
224 let checksums = if self.era_type.has_checksums() {
225 fs::read(&checksums_path)
226 .await
227 .map_err(|error| FsPathError::read(error, &checksums_path))?
228 .split_inclusive(|byte| *byte == b'\n')
229 .map(|line| {
230 let line = if let Some(line) = line.strip_suffix(b"\n") {
231 line.strip_suffix(b"\r").unwrap_or(line)
232 } else {
233 line
234 };
235 line.to_vec()
236 })
237 .collect()
238 } else {
239 Vec::new()
240 };
241 file_lists.insert(self.era_type, FileList { file_names, checksums });
242 Ok(())
243 }
244
245 async fn extract_era_filenames(&self, index_path: &Path) -> eyre::Result<Vec<String>> {
247 let file = File::open(index_path).await?;
248 let reader = io::BufReader::new(file);
249 let mut lines = reader.lines();
250
251 let path = self.folder.to_path_buf().join("index");
252 let file = File::create(&path).await?;
253 let mut writer = io::BufWriter::new(file);
254 let mut file_names = Vec::new();
255
256 while let Some(line) = lines.next_line().await? {
257 if let Some(era) = extract_era_filename(&line, self.era_type.extensions()) {
258 writer.write_all(era.as_bytes()).await?;
259 writer.write_all(b"\n").await?;
260 file_names.push(era.to_owned());
261 }
262 }
263
264 writer.flush().await?;
265 Ok(file_names)
266 }
267
268 async fn download_file_to_path(&self, url: Url, path: &Path) -> eyre::Result<()> {
270 let mut stream = self.client.get(url).await?;
271 let mut file = File::create(path).await?;
272
273 while let Some(item) = stream.next().await.transpose()? {
274 io::copy(&mut item.as_ref(), &mut file).await?;
275 }
276
277 Ok(())
278 }
279
280 pub async fn number_to_file_name(&self, number: usize) -> eyre::Result<Option<String>> {
282 if let Some(file_list) = self.file_lists.lock().await.get(&self.era_type) {
283 return Ok(file_list.file_names.get(number).cloned());
284 }
285 let path = self.folder.to_path_buf().join("index");
286 let file = File::open(&path).await?;
287 let reader = io::BufReader::new(file);
288 let mut lines = reader.lines();
289 for _ in 0..number {
290 lines.next_line().await?;
291 }
292
293 Ok(lines.next_line().await?)
294 }
295
296 async fn is_downloaded(&self, name: &str, path: impl AsRef<Path>) -> eyre::Result<bool> {
297 let path = path.as_ref();
298
299 match File::open(path).await {
300 Ok(file) => {
301 if self.era_type.has_checksums() {
302 let number = self
303 .file_name_to_number(name)
304 .ok_or_else(|| eyre!("Cannot parse ERA number from {name}"))?;
305
306 let actual_checksum = checksum(file).await?;
307 let is_verified = self.verify_checksum(number, actual_checksum).await?;
308
309 if !is_verified {
310 fs::remove_file(path).await?;
311 }
312
313 Ok(is_verified)
314 } else {
315 Ok(true)
317 }
318 }
319 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
320 Err(e) => Err(e)?,
321 }
322 }
323
324 async fn verify_checksum(&self, number: usize, actual_checksum: Vec<u8>) -> eyre::Result<bool> {
329 Ok(actual_checksum == self.expected_checksum(number).await?)
330 }
331
332 async fn assert_checksum(&self, number: usize, actual_checksum: Vec<u8>) -> eyre::Result<()> {
337 let expected_checksum = self.expected_checksum(number).await?;
338
339 if actual_checksum == expected_checksum {
340 Ok(())
341 } else {
342 Err(eyre!(
343 "Checksum mismatch, got: {}, expected: {}",
344 actual_checksum.encode_hex(),
345 expected_checksum.encode_hex()
346 ))
347 }
348 }
349
350 async fn expected_checksum(&self, number: usize) -> eyre::Result<Vec<u8>> {
354 if let Some(file_list) = self.file_lists.lock().await.get(&self.era_type) {
355 let checksum = file_list
356 .checksums
357 .get(number)
358 .ok_or_else(|| eyre!("Missing hash for number {number}"))?;
359 return Ok(hex::decode(checksum)?);
360 }
361 let file = File::open(self.folder.join(Self::CHECKSUMS)).await?;
362 let reader = io::BufReader::new(file);
363 let mut lines = reader.lines();
364
365 for _ in 0..number {
366 lines.next_line().await?;
367 }
368 let expected_checksum =
369 lines.next_line().await?.ok_or_else(|| eyre!("Missing hash for number {number}"))?;
370 let expected_checksum = hex::decode(expected_checksum)?;
371
372 Ok(expected_checksum)
373 }
374
375 fn file_name_to_number(&self, file_name: &str) -> Option<usize> {
376 file_name.split('-').nth(1).and_then(|v| usize::from_str(v).ok())
377 }
378
379 fn is_matching_era_file(&self, file_name: &str) -> bool {
384 EraFileType::from_filename(file_name) == Some(self.era_type)
385 }
386}
387
388#[derive(Debug)]
390struct FileList {
391 file_names: Vec<String>,
392 checksums: Vec<Vec<u8>>,
393}
394
395fn extract_era_filename<'a>(line: &'a str, extensions: &[&str]) -> Option<&'a str> {
399 for ext in extensions {
400 if let Some(j) = line.find(ext) &&
401 let Some(i) = line[..j].rfind(|c: char| !c.is_alphanumeric() && c != '-')
402 {
403 return Some(&line[i + 1..j + ext.len()]);
404 }
405 }
406 None
407}
408
409async fn checksum(mut reader: impl AsyncRead + Unpin) -> eyre::Result<Vec<u8>> {
410 let mut hasher = Sha256::new();
411
412 let mut data = vec![0; 64 * 1024];
414
415 loop {
416 let len = reader.read(&mut data).await?;
418 if len == 0 {
419 break;
420 } hasher.update(&data[..len]);
424 }
425
426 let hash = hasher.finalize().to_vec();
428
429 Ok(hash)
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use std::{
436 path::PathBuf,
437 sync::{
438 atomic::{AtomicBool, AtomicUsize, Ordering},
439 Arc,
440 },
441 };
442 use test_case::test_case;
443
444 impl EraClient<Client> {
445 fn empty() -> Self {
446 Self::new(Client::new(), Url::from_str("file:///").unwrap(), PathBuf::new())
447 }
448 }
449
450 #[test_case("mainnet-00600-a81ae85f.era1", Some(600))]
451 #[test_case("mainnet-00000-a81ae85f.era1", Some(0))]
452 #[test_case("00000-a81ae85f.era1", None)]
453 #[test_case("", None)]
454 fn test_file_name_to_number(file_name: &str, expected_number: Option<usize>) {
455 let client = EraClient::empty();
456
457 let actual_number = client.file_name_to_number(file_name);
458
459 assert_eq!(actual_number, expected_number);
460 }
461
462 #[test_case(
464 "<a href=\"mainnet-00000-a6860fef.erae\">", &[".erae", ".ere"],
465 Some("mainnet-00000-a6860fef.erae"); "erae anchor not clipped to ere"
466 )]
467 #[test_case(
468 " \"name\": \"mainnet-00001-05c64fc4.erae\",", &[".erae", ".ere"],
469 Some("mainnet-00001-05c64fc4.erae"); "erae json entry"
470 )]
471 #[test_case(
472 "<a href=\"mainnet-00600-a81ae85f.era1\">", &[".era1"],
473 Some("mainnet-00600-a81ae85f.era1"); "era1 anchor"
474 )]
475 #[test_case("<a href=\"checksums.txt\">", &[".erae", ".ere"], None; "no era file on line")]
476 fn test_extract_era_filename(line: &str, exts: &[&str], expected: Option<&str>) {
477 assert_eq!(extract_era_filename(line, exts), expected);
478 }
479
480 #[test]
481 fn test_with_era_type_overrides_auto_detection() {
482 let client = EraClient::new(
484 Client::new(),
485 Url::from_str("https://example.com/").unwrap(),
486 PathBuf::new(),
487 );
488 assert_eq!(client.era_type, EraFileType::Era);
489
490 let client = client.with_era_type(EraFileType::Era1);
492 assert_eq!(client.era_type, EraFileType::Era1);
493 }
494
495 #[derive(Clone, Debug, Default)]
496 struct ListingClient {
497 index: Arc<Mutex<String>>,
498 checksums: Arc<Mutex<Vec<u8>>>,
499 fail: Arc<AtomicBool>,
500 calls: Arc<AtomicUsize>,
501 }
502
503 impl HttpClient for ListingClient {
504 async fn get<U: IntoUrl + Send + Sync>(
505 &self,
506 url: U,
507 ) -> eyre::Result<impl Stream<Item = eyre::Result<Bytes>> + Send + Sync + Unpin> {
508 self.calls.fetch_add(1, Ordering::Relaxed);
509 if self.fail.load(Ordering::Relaxed) {
510 return Err(eyre!("metadata fetch failed"));
511 }
512 let url = url.into_url()?;
513 let body = if url.path().ends_with("checksums.txt") {
514 self.checksums.lock().await.clone()
515 } else {
516 self.index.lock().await.clone().into_bytes()
517 };
518 Ok(futures_util::stream::iter([Ok(Bytes::from(body))]))
519 }
520 }
521
522 #[tokio::test]
523 async fn file_list_refresh_updates_clones_and_preserves_lazy_errors() {
524 let folder = tempfile::tempdir().unwrap();
525 let http = ListingClient::default();
526 *http.index.lock().await = "<a href=\"mainnet-00000-original.era1\">\n".to_owned();
527 *http.checksums.lock().await = b"00\r\n\xff\n".to_vec();
528 let client = EraClient::new(
529 http.clone(),
530 Url::parse("https://example.com/era1/").unwrap(),
531 folder.path(),
532 );
533 let clone = client.clone();
534 client.fetch_file_list().await.unwrap();
535 assert_eq!(
536 clone.number_to_file_name(0).await.unwrap().as_deref(),
537 Some("mainnet-00000-original.era1")
538 );
539 assert_eq!(clone.expected_checksum(0).await.unwrap(), vec![0]);
540 assert_eq!(
541 clone.expected_checksum(1).await.unwrap_err().downcast_ref::<hex::FromHexError>(),
542 Some(&hex::FromHexError::OddLength)
543 );
544
545 *http.index.lock().await =
546 "<a href=\"mainnet-00000-revised-name.era1\">\n<a href=\"mainnet-00001-next.era1\">\n"
547 .to_owned();
548 *http.checksums.lock().await = b"02\n03\n".to_vec();
549 http.fail.store(true, Ordering::Relaxed);
550 assert_eq!(
551 client.fetch_file_list().await.unwrap_err().to_string(),
552 "metadata fetch failed"
553 );
554 assert_eq!(
555 clone.number_to_file_name(0).await.unwrap().as_deref(),
556 Some("mainnet-00000-original.era1")
557 );
558 assert_eq!(clone.expected_checksum(0).await.unwrap(), vec![0]);
559
560 http.fail.store(false, Ordering::Relaxed);
561 client.fetch_file_list().await.unwrap();
562 assert_eq!(
563 clone.number_to_file_name(0).await.unwrap().as_deref(),
564 Some("mainnet-00000-revised-name.era1")
565 );
566 assert_eq!(
567 clone.number_to_file_name(1).await.unwrap().as_deref(),
568 Some("mainnet-00001-next.era1")
569 );
570 assert_eq!(clone.number_to_file_name(2).await.unwrap(), None);
571 assert_eq!(clone.expected_checksum(0).await.unwrap(), vec![2]);
572 assert_eq!(clone.expected_checksum(1).await.unwrap(), vec![3]);
573 assert_eq!(
574 clone.expected_checksum(2).await.unwrap_err().to_string(),
575 "Missing hash for number 2"
576 );
577 }
578
579 #[tokio::test]
580 async fn changing_archive_type_uses_a_separate_file_list() {
581 let folder = tempfile::tempdir().unwrap();
582 let http = ListingClient::default();
583 *http.index.lock().await = "<a href=\"mainnet-00000-original.era1\">\n".to_owned();
584 *http.checksums.lock().await = b"00\n".to_vec();
585 let client = EraClient::new(
586 http.clone(),
587 Url::parse("https://example.com/era1/").unwrap(),
588 folder.path(),
589 );
590 client.fetch_file_list().await.unwrap();
591 let era_client = client.clone().with_era_type(EraFileType::Era);
592 *http.index.lock().await = "<a href=\"mainnet-00000-consensus.era\">\n".to_owned();
593 era_client.fetch_file_list().await.unwrap();
594 assert_eq!(
595 era_client.number_to_file_name(0).await.unwrap().as_deref(),
596 Some("mainnet-00000-consensus.era")
597 );
598 assert_eq!(
599 client.number_to_file_name(0).await.unwrap().as_deref(),
600 Some("mainnet-00000-original.era1")
601 );
602 assert_eq!(client.expected_checksum(0).await.unwrap(), vec![0]);
603 }
604
605 #[tokio::test]
606 async fn lookups_without_fetch_use_existing_sidecars() {
607 let folder = tempfile::tempdir().unwrap();
608 reth_fs_util::write(folder.path().join("index"), b"first\nsecond-longer-name\n").unwrap();
609 reth_fs_util::write(folder.path().join(EraClient::<Client>::CHECKSUMS), b"00\n01\n")
610 .unwrap();
611 let client = EraClient::new(
612 Client::new(),
613 Url::parse("https://example.com/era1/").unwrap(),
614 folder.path(),
615 );
616 assert_eq!(
617 client.number_to_file_name(1).await.unwrap().as_deref(),
618 Some("second-longer-name")
619 );
620 assert_eq!(client.number_to_file_name(2).await.unwrap(), None);
621 assert_eq!(client.expected_checksum(1).await.unwrap(), vec![1]);
622 assert_eq!(
623 client.expected_checksum(2).await.unwrap_err().to_string(),
624 "Missing hash for number 2"
625 );
626 }
627
628 #[tokio::test]
629 async fn concurrent_refreshes_do_not_interleave_sidecars() {
630 let folder = tempfile::tempdir().unwrap();
631 let http = ListingClient::default();
632 *http.index.lock().await = "<a href=\"mainnet-00000-original.era1\">\n".to_owned();
633 *http.checksums.lock().await = b"00\n".to_vec();
634 let client = EraClient::new(
635 http.clone(),
636 Url::parse("https://example.com/era1/").unwrap(),
637 folder.path(),
638 );
639 let clone = client.clone();
640 let index_guard = http.index.lock().await;
641 let mut first = Box::pin(client.fetch_file_list());
642 assert!(futures::poll!(&mut first).is_pending());
643 assert_eq!(http.calls.load(Ordering::Relaxed), 2);
644 let mut second = Box::pin(clone.fetch_file_list());
645 assert!(futures::poll!(&mut second).is_pending());
646 assert_eq!(
647 http.calls.load(Ordering::Relaxed),
648 2,
649 "second refresh must wait before downloading sidecars"
650 );
651 drop(index_guard);
652 let (first, second) = tokio::join!(first, second);
653 first.unwrap();
654 second.unwrap();
655 assert_eq!(http.calls.load(Ordering::Relaxed), 4);
656 assert_eq!(clone.expected_checksum(0).await.unwrap(), vec![0]);
657 }
658}