1use std::{
5 fmt::Display,
6 path::{Path, PathBuf},
7 str::FromStr,
8};
9
10use crate::prelude::*;
11use crate::{cli_shared::snapshot::parse::ParsedFilename, utils::net::download_file_with_retry};
12use crate::{networks::NetworkChain, utils::net::DownloadFileOption};
13use anyhow::bail;
14use chrono::NaiveDate;
15use url::Url;
16
17#[derive(
20 Debug,
21 Clone,
22 Copy,
23 Hash,
24 PartialEq,
25 Eq,
26 Default,
27 strum::EnumString, strum::Display, clap::ValueEnum, )]
31#[strum(serialize_all = "kebab-case")]
32pub enum TrustedVendor {
33 #[default]
34 Forest,
35}
36
37pub fn filename(
41 vendor: impl Display,
42 chain: impl Display,
43 date: NaiveDate,
44 height: ChainEpoch,
45 forest_format: bool,
46) -> String {
47 let vendor = vendor.to_string();
48 let chain = chain.to_string();
49 ParsedFilename::Full {
50 vendor: &vendor,
51 chain: &chain,
52 date,
53 height,
54 forest_format,
55 }
56 .to_string()
57}
58
59pub async fn fetch(
61 directory: &Path,
62 chain: &NetworkChain,
63 vendor: TrustedVendor,
64) -> anyhow::Result<PathBuf> {
65 let (url, _len, path) = peek(vendor, chain).await?;
66 let (date, height, forest_format) = ParsedFilename::parse_str(&path)
67 .context("unexpected path format")?
68 .date_and_height_and_forest();
69 let filename = filename(vendor, chain, date, height, forest_format);
70
71 tracing::info!("Downloading snapshot: {filename}");
72
73 download_file_with_retry(
74 &url,
75 directory,
76 &filename,
77 DownloadFileOption::Resumable,
78 None,
79 )
80 .await
81}
82
83pub async fn peek(
88 vendor: TrustedVendor,
89 chain: &NetworkChain,
90) -> anyhow::Result<(Url, u64, String)> {
91 let stable_url = stable_url(vendor, chain)?;
92 let response = reqwest::get(stable_url)
98 .await?
99 .error_for_status()
100 .context("server returned an error response")?;
101 let final_url = response.url().clone();
102 let cd_path = response
103 .headers()
104 .get(reqwest::header::CONTENT_DISPOSITION)
105 .and_then(parse_content_disposition);
106 Ok((
107 final_url,
108 response
109 .content_length()
110 .context("no content-length header")?,
111 cd_path.context("no content-disposition filepath")?,
112 ))
113}
114
115fn parse_content_disposition(value: &reqwest::header::HeaderValue) -> Option<String> {
119 use regex::Regex;
120 let re = Regex::new("filename=\"([^\"]+)\"").ok()?;
121 let cap = re.captures(value.to_str().ok()?)?;
122 Some(cap.get(1)?.as_str().to_owned())
123}
124
125macro_rules! define_urls {
127 ($($vis:vis const $name:ident: &str = $value:literal;)* $(,)?) => {
128 $($vis const $name: &str = $value;)*
129
130 #[cfg(test)]
131 const ALL_URLS: &[&str] = [
132 $($name,)*
133 ].as_slice();
134 };
135}
136
137define_urls!(
138 const FOREST_MAINNET_COMPRESSED: &str = "https://forest-archive.chainsafe.dev/latest/mainnet/";
139 const FOREST_CALIBNET_COMPRESSED: &str =
140 "https://forest-archive.chainsafe.dev/latest/calibnet/";
141);
142
143pub fn stable_url(vendor: TrustedVendor, chain: &NetworkChain) -> anyhow::Result<Url> {
144 let s = match (vendor, chain) {
145 (TrustedVendor::Forest, NetworkChain::Mainnet) => FOREST_MAINNET_COMPRESSED,
146 (TrustedVendor::Forest, NetworkChain::Calibnet) => FOREST_CALIBNET_COMPRESSED,
147 (TrustedVendor::Forest, NetworkChain::Butterflynet | NetworkChain::Devnet(_)) => {
148 bail!("unsupported chain {chain}")
149 }
150 };
151 Ok(Url::from_str(s).unwrap())
152}
153
154#[test]
155fn parse_stable_urls() {
156 for url in ALL_URLS {
157 let _did_not_panic = Url::from_str(url).unwrap();
158 }
159}
160
161mod parse {
162 use std::{fmt::Display, str::FromStr};
169
170 use anyhow::{anyhow, bail};
171 use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
172 use nom::{
173 Err, Parser,
174 branch::alt,
175 bytes::complete::{tag, take_until},
176 character::complete::digit1,
177 combinator::{map_res, recognize},
178 error::ErrorKind,
179 error_position,
180 multi::many1,
181 };
182
183 use crate::db::car::forest::FOREST_CAR_FILE_EXTENSION;
184 use crate::prelude::*;
185
186 #[derive(PartialEq, Debug, Clone, Hash)]
187 pub(super) enum ParsedFilename<'a> {
188 Short {
189 date: NaiveDate,
190 time: NaiveTime,
191 height: ChainEpoch,
192 },
193 Full {
194 vendor: &'a str,
195 chain: &'a str,
196 date: NaiveDate,
197 height: ChainEpoch,
198 forest_format: bool,
199 },
200 }
201
202 impl Display for ParsedFilename<'_> {
203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204 match self {
205 ParsedFilename::Short { date, time, height } => f.write_fmt(format_args!(
206 "{height}_{}.car.zst",
207 NaiveDateTime::new(*date, *time).format("%Y_%m_%dT%H_%M_%SZ")
208 )),
209 ParsedFilename::Full {
210 vendor,
211 chain,
212 date,
213 height,
214 forest_format,
215 } => f.write_fmt(format_args!(
216 "{vendor}_snapshot_{chain}_{}_height_{height}{}.car.zst",
217 date.format("%Y-%m-%d"),
218 if *forest_format { ".forest" } else { "" }
219 )),
220 }
221 }
222 }
223
224 impl<'a> ParsedFilename<'a> {
225 pub fn date_and_height_and_forest(&self) -> (NaiveDate, i64, bool) {
226 match self {
227 ParsedFilename::Short { date, height, .. } => (*date, *height, false),
228 ParsedFilename::Full {
229 date,
230 height,
231 forest_format,
232 ..
233 } => (*date, *height, *forest_format),
234 }
235 }
236
237 pub fn parse_str(input: &'a str) -> anyhow::Result<Self> {
238 enter_nom(alt((short, full)), input)
239 }
240 }
241
242 fn number<T>(input: &str) -> nom::IResult<&str, T>
244 where
245 T: FromStr,
246 {
247 map_res(recognize(many1(digit1)), T::from_str).parse(input)
248 }
249
250 fn ymd(separator: &str) -> impl Fn(&str) -> nom::IResult<&str, NaiveDate> + '_ {
252 move |input| {
253 let (rest, (year, _, month, _, day)) =
254 (number, tag(separator), number, tag(separator), number).parse(input)?;
255 match NaiveDate::from_ymd_opt(year, month, day) {
256 Some(date) => Ok((rest, date)),
257 None => Err(Err::Error(error_position!(input, ErrorKind::Verify))),
258 }
259 }
260 }
261
262 fn hms(separator: &str) -> impl Fn(&str) -> nom::IResult<&str, NaiveTime> + '_ {
264 move |input| {
265 let (rest, (hour, _, minute, _, second)) =
266 (number, tag(separator), number, tag(separator), number).parse(input)?;
267 match NaiveTime::from_hms_opt(hour, minute, second) {
268 Some(date) => Ok((rest, date)),
269 None => Err(Err::Error(error_position!(input, ErrorKind::Verify))),
270 }
271 }
272 }
273
274 fn full(input: &str) -> nom::IResult<&str, ParsedFilename<'_>> {
275 let (rest, (vendor, _snapshot_, chain, _, date, _height_, height, car_zst)) = (
276 take_until("_snapshot_"),
277 tag("_snapshot_"),
278 take_until("_"),
279 tag("_"),
280 ymd("-"),
281 tag("_height_"),
282 number,
283 alt((tag(".car.zst"), tag(FOREST_CAR_FILE_EXTENSION))),
284 )
285 .parse(input)?;
286 Ok((
287 rest,
288 ParsedFilename::Full {
289 vendor,
290 chain,
291 date,
292 height,
293 forest_format: car_zst == FOREST_CAR_FILE_EXTENSION,
294 },
295 ))
296 }
297
298 fn short(input: &str) -> nom::IResult<&str, ParsedFilename<'_>> {
299 let (rest, (height, _, date, _, time, _)) = (
300 number,
301 tag("_"),
302 ymd("_"),
303 tag("T"),
304 hms("_"),
305 tag("Z.car.zst"),
306 )
307 .parse(input)?;
308 Ok((rest, ParsedFilename::Short { date, time, height }))
309 }
310
311 fn enter_nom<'a, T>(
312 mut parser: impl nom::Parser<&'a str, Output = T, Error = nom::error::Error<&'a str>>,
313 input: &'a str,
314 ) -> anyhow::Result<T> {
315 let (rest, t) = parser
316 .parse(input)
317 .map_err(|e| anyhow!("Parser error: {e}"))?;
318 if !rest.is_empty() {
319 bail!("Unexpected trailing input: {rest}")
320 }
321 Ok(t)
322 }
323
324 #[cfg(test)]
325 mod tests {
326 use super::*;
327
328 #[test]
329 fn test_serialization() {
330 for (text, value) in [
331 (
332 "forest_snapshot_mainnet_2023-05-30_height_2905376.car.zst",
333 ParsedFilename::full("forest", "mainnet", 2023, 5, 30, 2905376, false),
334 ),
335 (
336 "forest_snapshot_calibnet_2023-05-30_height_604419.car.zst",
337 ParsedFilename::full("forest", "calibnet", 2023, 5, 30, 604419, false),
338 ),
339 (
340 "forest_snapshot_mainnet_2023-05-30_height_2905376.forest.car.zst",
341 ParsedFilename::full("forest", "mainnet", 2023, 5, 30, 2905376, true),
342 ),
343 (
344 "forest_snapshot_calibnet_2023-05-30_height_604419.forest.car.zst",
345 ParsedFilename::full("forest", "calibnet", 2023, 5, 30, 604419, true),
346 ),
347 (
348 "2905920_2023_05_30T22_00_00Z.car.zst",
349 ParsedFilename::short(2905920, 2023, 5, 30, 22, 0, 0),
350 ),
351 (
352 "605520_2023_05_31T00_13_00Z.car.zst",
353 ParsedFilename::short(605520, 2023, 5, 31, 0, 13, 0),
354 ),
355 (
356 "filecoin_snapshot_calibnet_2023-06-13_height_643680.car.zst",
357 ParsedFilename::full("filecoin", "calibnet", 2023, 6, 13, 643680, false),
358 ),
359 (
360 "venus_snapshot_pineconenet_2045-01-01_height_2.car.zst",
361 ParsedFilename::full("venus", "pineconenet", 2045, 1, 1, 2, false),
362 ),
363 (
364 "filecoin_snapshot_calibnet_2023-06-13_height_643680.forest.car.zst",
365 ParsedFilename::full("filecoin", "calibnet", 2023, 6, 13, 643680, true),
366 ),
367 (
368 "venus_snapshot_pineconenet_2045-01-01_height_2.forest.car.zst",
369 ParsedFilename::full("venus", "pineconenet", 2045, 1, 1, 2, true),
370 ),
371 ] {
372 assert_eq!(
373 value,
374 ParsedFilename::parse_str(text).unwrap(),
375 "mismatch in deserialize"
376 );
377 assert_eq!(value.to_string(), text, "mismatch in serialize");
378 }
379 }
380
381 #[test]
382 fn test_wrong_ext() {
383 ParsedFilename::parse_str("forest_snapshot_mainnet_2023-05-30_height_2905376.car.zstt")
384 .unwrap_err();
385 ParsedFilename::parse_str(
386 "forest_snapshot_mainnet_2023-05-30_height_2905376.car.zst.tmp",
387 )
388 .unwrap_err();
389 }
390
391 impl ParsedFilename<'static> {
392 fn short(
395 height: ChainEpoch,
396 year: i32,
397 month: u32,
398 day: u32,
399 hour: u32,
400 min: u32,
401 sec: u32,
402 ) -> Self {
403 Self::Short {
404 date: NaiveDate::from_ymd_opt(year, month, day).unwrap(),
405 time: NaiveTime::from_hms_opt(hour, min, sec).unwrap(),
406 height,
407 }
408 }
409 }
410
411 impl<'a> ParsedFilename<'a> {
412 fn full(
415 vendor: &'a str,
416 chain: &'a str,
417 year: i32,
418 month: u32,
419 day: u32,
420 height: ChainEpoch,
421 forest_format: bool,
422 ) -> Self {
423 Self::Full {
424 vendor,
425 chain,
426 date: NaiveDate::from_ymd_opt(year, month, day).unwrap(),
427 height,
428 forest_format,
429 }
430 }
431 }
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::parse_content_disposition;
438 use reqwest::header::HeaderValue;
439
440 #[test]
441 fn content_disposition_forest() {
442 assert_eq!(
443 parse_content_disposition(&HeaderValue::from_static(
444 "attachment; filename*=UTF-8''forest_snapshot_calibnet_2023-09-14_height_911888.forest.car.zst; \
445 filename=\"forest_snapshot_calibnet_2023-09-14_height_911888.forest.car.zst\""
446 )).unwrap(),
447 "forest_snapshot_calibnet_2023-09-14_height_911888.forest.car.zst"
448 );
449 }
450}