Skip to main content

forest/cli_shared/
snapshot.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4use 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/// Who hosts the snapshot on the web?
18/// See [`stable_url`].
19#[derive(
20    Debug,
21    Clone,
22    Copy,
23    Hash,
24    PartialEq,
25    Eq,
26    Default,
27    strum::EnumString, // impl std::str::FromStr
28    strum::Display,    // impl Display
29    clap::ValueEnum,   // allow values to be enumerated and parsed by clap
30)]
31#[strum(serialize_all = "kebab-case")]
32pub enum TrustedVendor {
33    #[default]
34    Forest,
35}
36
37/// Create a filename in the "full" format. See [`parse`].
38// Common between export, and [`fetch`].
39// Keep in sync with the CLI documentation for the `snapshot` sub-command.
40pub 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
59/// Returns the path to the downloaded file.
60pub 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
83/// Returns
84/// - The final URL after redirection(s)
85/// - The size of the snapshot from this vendor on this chain
86/// - The filename of the snapshot
87pub async fn peek(
88    vendor: TrustedVendor,
89    chain: &NetworkChain,
90) -> anyhow::Result<(Url, u64, String)> {
91    let stable_url = stable_url(vendor, chain)?;
92    // issue an actual GET, so the content length will be of the body
93    // (we never actually fetch the body)
94    // if we issue a HEAD, the content-length will be zero for our stable URLs
95    // (this is a bug, maybe in reqwest - HEAD _should_ give us the length)
96    // (probably because the stable URLs are all double-redirects 301 -> 302 -> 200)
97    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
115// Extract file paths from content-disposition values:
116//   "attachment; filename=\"911520_2023_09_14T06_13_00Z.car.zst\""
117// => "911520_2023_09_14T06_13_00Z.car.zst"
118fn 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
125/// Also defines an `ALL_URLS` constant for test purposes
126macro_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    //! Vendors publish filenames with two formats:
163    //! `filecoin_snapshot_calibnet_2023-06-13_height_643680.car.zst` "full" and
164    //! `632400_2023_06_09T08_13_00Z.car.zst` "short".
165    //!
166    //! This module contains utilities for parsing and printing these formats.
167
168    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    /// Parse a number using its [`FromStr`] implementation.
243    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    /// Create a parser for `YYYY-MM-DD` etc
251    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    /// Create a parser for `HH_MM_SS` etc
263    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            /// # Panics
393            /// - If `ymd`/`hms` aren't valid
394            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            /// # Panics
413            /// - If `ymd` isn't valid
414            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}