Skip to main content

soroban_cli/commands/snapshot/
create.rs

1use async_compression::tokio::bufread::GzipDecoder;
2use bytesize::ByteSize;
3use clap::{Parser, ValueEnum};
4use futures::StreamExt;
5use humantime::format_duration;
6use itertools::{Either, Itertools};
7use sha2::{Digest, Sha256};
8use soroban_ledger_snapshot::LedgerSnapshot;
9use std::{
10    collections::HashSet,
11    fs,
12    io::{self},
13    path::PathBuf,
14    str::FromStr,
15    time::{Duration, Instant},
16};
17use stellar_xdr::{
18    self as xdr, AccountId, Asset, BucketEntry, ConfigSettingEntry, ContractExecutable, Frame,
19    Hash, LedgerEntryData, LedgerHeaderHistoryEntry, LedgerKey, Limited, Limits, ReadXdr,
20    ScAddress, ScContractInstance, ScVal,
21};
22use tokio::fs::OpenOptions;
23use tokio::io::{AsyncRead, AsyncReadExt, BufReader};
24use tokio_util::io::StreamReader;
25use url::Url;
26
27use crate::utils::XDR_DEPTH_LIMIT;
28use crate::{
29    commands::{config::data, global, HEADING_ARCHIVE},
30    config::{self, locator, network::passphrase},
31    print,
32    tx::builder,
33    utils::get_name_from_stellar_asset_contract_storage,
34};
35use crate::{
36    config::address::UnresolvedMuxedAccount,
37    utils::{http, url::redact_url},
38};
39
40#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, ValueEnum, Default)]
41pub enum Output {
42    #[default]
43    Json,
44}
45
46fn default_out_path() -> PathBuf {
47    PathBuf::new().join("snapshot.json")
48}
49
50/// Create a ledger snapshot using a history archive.
51///
52/// Filters (address, wasm-hash) specify what ledger entries to include.
53///
54/// Account addresses include the account, and trustlines.
55///
56/// Contract addresses include the related wasm, contract data.
57///
58/// If a contract is a Stellar asset contract, it includes the asset issuer's
59/// account and trust lines, but does not include all the trust lines of other
60/// accounts holding the asset. To include them specify the addresses of
61/// relevant accounts.
62///
63/// Any invalid contract id passed as `--address` will be ignored.
64///
65#[derive(Parser, Debug, Clone)]
66#[group(skip)]
67pub struct Cmd {
68    /// The ledger sequence number to snapshot. Defaults to latest history archived ledger.
69    #[arg(long)]
70    ledger: Option<u32>,
71
72    /// Account or contract address/alias to include in the snapshot.
73    #[arg(long = "address", help_heading = "Filter Options")]
74    address: Vec<String>,
75
76    /// WASM hashes to include in the snapshot.
77    #[arg(long = "wasm-hash", help_heading = "Filter Options")]
78    wasm_hashes: Vec<Hash>,
79
80    /// Format of the out file.
81    #[arg(long, value_enum, default_value_t)]
82    output: Output,
83
84    /// Out path that the snapshot is written to.
85    #[arg(long, default_value=default_out_path().into_os_string())]
86    out: PathBuf,
87
88    /// Archive URL
89    #[arg(long, help_heading = HEADING_ARCHIVE, env = "STELLAR_ARCHIVE_URL")]
90    archive_url: Option<Url>,
91
92    #[command(flatten)]
93    locator: locator::Args,
94
95    #[command(flatten)]
96    network: config::network::Args,
97}
98
99#[derive(thiserror::Error, Debug)]
100pub enum Error {
101    #[error("wasm hash invalid: {0}")]
102    WasmHashInvalid(String),
103
104    #[error("downloading history: {0}")]
105    DownloadingHistory(reqwest::Error),
106
107    #[error("downloading history: got status code {0}")]
108    DownloadingHistoryGotStatusCode(reqwest::StatusCode),
109
110    #[error("json decoding history: {0}")]
111    JsonDecodingHistory(serde_json::Error),
112
113    #[error("opening cached bucket to read: {0}")]
114    ReadOpeningCachedBucket(io::Error),
115
116    #[error("parsing bucket url: {0}")]
117    ParsingBucketUrl(url::ParseError),
118
119    #[error("getting bucket: {0}")]
120    GettingBucket(reqwest::Error),
121
122    #[error("getting bucket: got status code {0}")]
123    GettingBucketGotStatusCode(reqwest::StatusCode),
124
125    #[error("opening cached bucket to write: {0}")]
126    WriteOpeningCachedBucket(io::Error),
127
128    #[error("streaming bucket: {0}")]
129    StreamingBucket(io::Error),
130
131    #[error("read XDR frame bucket entry: {0}")]
132    ReadXdrFrameBucketEntry(xdr::Error),
133
134    #[error("renaming temporary downloaded file to final destination: {0}")]
135    RenameDownloadFile(io::Error),
136
137    #[error("getting bucket directory: {0}")]
138    GetBucketDir(data::Error),
139
140    #[error("reading history http stream: {0}")]
141    ReadHistoryHttpStream(reqwest::Error),
142
143    #[error("writing ledger snapshot: {0}")]
144    WriteLedgerSnapshot(soroban_ledger_snapshot::Error),
145
146    #[error(transparent)]
147    Join(#[from] tokio::task::JoinError),
148
149    #[error(transparent)]
150    Network(#[from] config::network::Error),
151
152    #[error(transparent)]
153    Locator(#[from] locator::Error),
154
155    #[error(transparent)]
156    Config(#[from] config::Error),
157
158    #[error("archive url not configured")]
159    ArchiveUrlNotConfigured,
160
161    #[error("parsing asset name: {0}")]
162    ParseAssetName(String),
163
164    #[error(transparent)]
165    Asset(#[from] builder::asset::Error),
166
167    #[error("ledger not found in archive")]
168    LedgerNotFound,
169
170    #[error("xdr parsing error: {0}")]
171    Xdr(#[from] xdr::Error),
172
173    #[error("corrupted bucket file: expected hash {expected}, got {actual}")]
174    CorruptedBucket { expected: String, actual: String },
175
176    #[error("decompressed size exceeds maximum of {max}")]
177    DecompressedSizeLimitExceeded { max: ByteSize },
178}
179
180/// Checkpoint frequency is usually 64 ledgers, but in local test nets it'll
181/// often by 8. There's no way to simply detect what frequency to expect ledgers
182/// at, so it is hardcoded at 64, and this value is used only to help the user
183/// select good ledger numbers when they select one that doesn't exist.
184const CHECKPOINT_FREQUENCY: u32 = 64;
185
186/// Maximum decompressed size for bucket files (10 GiB).
187const MAX_BUCKET_DECOMPRESSED_SIZE: u64 = 10 * 1024 * 1024 * 1024;
188
189/// Maximum decompressed size for ledger header files (100 MiB).
190const MAX_LEDGER_HEADER_DECOMPRESSED_SIZE: u64 = 100 * 1024 * 1024;
191
192impl Cmd {
193    #[allow(clippy::too_many_lines)]
194    pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
195        let print = print::Print::new(global_args.quiet);
196        let start = Instant::now();
197
198        let archive_url = self.archive_url()?;
199        let history = get_history(&print, &archive_url, self.ledger).await?;
200
201        let ledger = history.current_ledger;
202        let network_passphrase = &history.network_passphrase;
203        let network_id = Sha256::digest(network_passphrase);
204
205        print.infoln(format!("Ledger: {ledger}"));
206        print.infoln(format!("Network Passphrase: {network_passphrase}"));
207        print.infoln(format!("Network id: {}", hex::encode(network_id)));
208
209        // Get ledger close time and base reserve from archive
210        let (ledger_close_time, base_reserve) =
211            match get_ledger_metadata_from_archive(&print, &archive_url, ledger).await {
212                Ok((close_time, reserve)) => {
213                    print.infoln(format!("Ledger Close Time: {close_time}"));
214                    print.infoln(format!("Base Reserve: {reserve}"));
215                    (close_time, reserve)
216                }
217                Err(e) => {
218                    print.warnln(format!("Failed to get ledger metadata from archive: {e}"));
219                    print.infoln("Using default values: close_time=0, base_reserve=1");
220                    (0u64, 1u32) // Default values
221                }
222            };
223
224        // Prepare a flat list of buckets to read. They'll be ordered by their
225        // level so that they can iterated higher level to lower level.
226        let buckets = history
227            .current_buckets
228            .iter()
229            .flat_map(|h| [h.curr.clone(), h.snap.clone()])
230            .filter(|b| b != "0000000000000000000000000000000000000000000000000000000000000000")
231            .collect::<Vec<_>>();
232
233        // Pre-cache the buckets.
234        for (i, bucket) in buckets.iter().enumerate() {
235            cache_bucket(&print, &archive_url, i, bucket).await?;
236        }
237
238        // The snapshot is what will be written to file at the end. Fields will
239        // be updated while parsing the history archive.
240        let mut snapshot = LedgerSnapshot {
241            protocol_version: 0,
242            sequence_number: ledger,
243            timestamp: ledger_close_time,
244            network_id: network_id.into(),
245            base_reserve,
246            min_persistent_entry_ttl: 0,
247            min_temp_entry_ttl: 0,
248            max_entry_ttl: 0,
249            ledger_entries: Vec::new(),
250        };
251
252        // Track ledger keys seen, so that we can ignore old versions of
253        // entries. Entries can appear in both higher level and lower level
254        // buckets, and to get the latest version of the entry the version in
255        // the higher level bucket should be used.
256        let mut seen = HashSet::new();
257
258        #[allow(clippy::items_after_statements)]
259        #[derive(Default)]
260        struct SearchInputs {
261            account_ids: HashSet<AccountId>,
262            contract_ids: HashSet<ScAddress>,
263            wasm_hashes: HashSet<Hash>,
264        }
265        impl SearchInputs {
266            pub fn is_empty(&self) -> bool {
267                self.account_ids.is_empty()
268                    && self.contract_ids.is_empty()
269                    && self.wasm_hashes.is_empty()
270            }
271        }
272
273        // Search the buckets using the user inputs as the starting inputs.
274        let (account_ids, contract_ids): (HashSet<AccountId>, HashSet<ScAddress>) = self
275            .address
276            .iter()
277            .cloned()
278            .filter_map(|a| self.resolve_address_sync(&a, network_passphrase))
279            .partition_map(|a| a);
280
281        let mut current = SearchInputs {
282            account_ids,
283            contract_ids,
284            wasm_hashes: self.wasm_hashes.iter().cloned().collect(),
285        };
286        let mut next = SearchInputs::default();
287
288        loop {
289            if current.is_empty() {
290                break;
291            }
292
293            print.infoln(format!(
294                "Searching for {} accounts, {} contracts, {} wasms",
295                current.account_ids.len(),
296                current.contract_ids.len(),
297                current.wasm_hashes.len(),
298            ));
299
300            for (i, bucket) in buckets.iter().enumerate() {
301                // Defined where the bucket will be read from, either from cache on
302                // disk, or streamed from the archive.
303                let cache_path = cache_bucket(&print, &archive_url, i, bucket).await?;
304                let file = std::fs::OpenOptions::new()
305                    .read(true)
306                    .open(&cache_path)
307                    .map_err(Error::ReadOpeningCachedBucket)?;
308
309                let message = format!("Searching bucket {i} {bucket}");
310                print.searchln(format!("{message}…"));
311
312                if let Ok(metadata) = file.metadata() {
313                    print.clear_previous_line();
314                    print.searchln(format!("{message} ({})", ByteSize(metadata.len())));
315                }
316
317                // Stream the bucket entries from the bucket, identifying
318                // entries that match the filters, and including only the
319                // entries that match in the snapshot.
320                let limited = &mut Limited::new(file, Limits::depth(XDR_DEPTH_LIMIT));
321                let entries = Frame::<BucketEntry>::read_xdr_iter(limited);
322                let mut count_saved = 0;
323                for entry in entries {
324                    let Frame(entry) = entry.map_err(Error::ReadXdrFrameBucketEntry)?;
325                    let (key, val) = match entry {
326                        BucketEntry::Liveentry(l) | BucketEntry::Initentry(l) => {
327                            let k = l.to_key();
328                            (k, Some(l))
329                        }
330                        BucketEntry::Deadentry(k) => (k, None),
331                        BucketEntry::Metaentry(m) => {
332                            if m.ledger_version > snapshot.protocol_version {
333                                snapshot.protocol_version = m.ledger_version;
334                                print.infoln(format!(
335                                    "Protocol version: {}",
336                                    snapshot.protocol_version
337                                ));
338                            }
339                            continue;
340                        }
341                    };
342
343                    if seen.contains(&key) {
344                        continue;
345                    }
346
347                    let keep = match &key {
348                        LedgerKey::Account(k) => current.account_ids.contains(&k.account_id),
349                        LedgerKey::Trustline(k) => current.account_ids.contains(&k.account_id),
350                        LedgerKey::ContractData(k) => current.contract_ids.contains(&k.contract),
351                        LedgerKey::ContractCode(e) => current.wasm_hashes.contains(&e.hash),
352                        LedgerKey::ConfigSetting(_) => true,
353                        _ => false,
354                    };
355
356                    if !keep {
357                        continue;
358                    }
359
360                    seen.insert(key.clone());
361
362                    let Some(val) = val else {
363                        continue;
364                    };
365
366                    let include = match &val.data {
367                        LedgerEntryData::ConfigSetting(ConfigSettingEntry::StateArchival(
368                            state_archival,
369                        )) => {
370                            snapshot.min_persistent_entry_ttl = state_archival.min_persistent_ttl;
371                            snapshot.min_temp_entry_ttl = state_archival.min_temporary_ttl;
372                            snapshot.max_entry_ttl = state_archival.max_entry_ttl;
373                            false
374                        }
375
376                        LedgerEntryData::ContractData(e) => {
377                            // If a contract instance references contract
378                            // executable stored in another ledger entry, add
379                            // that ledger entry to the filter so that Wasm for
380                            // any filtered contract is collected too in the
381                            // second pass.
382                            if e.key == ScVal::LedgerKeyContractInstance {
383                                match &e.val {
384                                    ScVal::ContractInstance(ScContractInstance {
385                                        executable: ContractExecutable::Wasm(hash),
386                                        ..
387                                    }) if !current.wasm_hashes.contains(hash) => {
388                                        next.wasm_hashes.insert(hash.clone());
389                                        print.infoln(format!(
390                                            "Adding wasm {} to search",
391                                            hex::encode(hash)
392                                        ));
393                                    }
394                                    ScVal::ContractInstance(ScContractInstance {
395                                        executable: ContractExecutable::StellarAsset,
396                                        storage: Some(storage),
397                                    }) => {
398                                        if let Some(name) =
399                                            get_name_from_stellar_asset_contract_storage(storage)
400                                        {
401                                            let asset: builder::Asset = name.parse()?;
402                                            if let Some(issuer) = match asset
403                                                .resolve(&global_args.locator)?
404                                            {
405                                                Asset::Native => None,
406                                                Asset::CreditAlphanum4(a4) => Some(a4.issuer),
407                                                Asset::CreditAlphanum12(a12) => Some(a12.issuer),
408                                            } {
409                                                print.infoln(format!(
410                                                    "Adding asset issuer {issuer} to search"
411                                                ));
412                                                next.account_ids.insert(issuer);
413                                            }
414                                        }
415                                    }
416                                    _ => {}
417                                }
418                            }
419                            keep
420                        }
421                        _ => false,
422                    };
423                    if include {
424                        snapshot
425                            .ledger_entries
426                            .push((Box::new(key), (Box::new(val), Some(u32::MAX))));
427                        count_saved += 1;
428                    }
429                }
430                if count_saved > 0 {
431                    print.infoln(format!("Found {count_saved} entries"));
432                }
433            }
434            current = next;
435            next = SearchInputs::default();
436        }
437
438        // Write the snapshot to file.
439        snapshot
440            .write_file(&self.out)
441            .map_err(Error::WriteLedgerSnapshot)?;
442        print.saveln(format!(
443            "Saved {} entries to {:?}",
444            snapshot.ledger_entries.len(),
445            self.out
446        ));
447
448        let duration = Duration::from_secs(start.elapsed().as_secs());
449        print.checkln(format!("Completed in {}", format_duration(duration)));
450
451        Ok(())
452    }
453
454    fn archive_url(&self) -> Result<Url, Error> {
455        // Return the configured archive URL, or if one is not configured, guess
456        // at an appropriate archive URL given the network passphrase.
457        self.archive_url
458            .clone()
459            .or_else(|| {
460                self.network.get(&self.locator).ok().and_then(|network| {
461                    match network.network_passphrase.as_str() {
462                        passphrase::MAINNET => {
463                            Some("https://history.stellar.org/prd/core-live/core_live_001")
464                        }
465                        passphrase::TESTNET => {
466                            Some("https://history.stellar.org/prd/core-testnet/core_testnet_001")
467                        }
468                        passphrase::FUTURENET => Some("https://history-futurenet.stellar.org"),
469                        passphrase::LOCAL => Some("http://localhost:8000/archive"),
470                        _ => None,
471                    }
472                    .map(|s| Url::from_str(s).expect("archive url valid"))
473                })
474            })
475            .ok_or(Error::ArchiveUrlNotConfigured)
476    }
477
478    fn resolve_address_sync(
479        &self,
480        address: &str,
481        network_passphrase: &str,
482    ) -> Option<Either<AccountId, ScAddress>> {
483        if let Some(contract) = self.resolve_contract(address, network_passphrase) {
484            Some(Either::Right(contract))
485        } else {
486            self.resolve_account_sync(address).map(Either::Left)
487        }
488    }
489
490    // Resolve an account address to an account id. The address can be a
491    // G-address or a key name (as in `stellar keys address NAME`).
492    fn resolve_account_sync(&self, address: &str) -> Option<AccountId> {
493        let address: UnresolvedMuxedAccount = address.parse().ok()?;
494        let muxed_account = address.resolve_muxed_account(&self.locator, None).ok()?;
495        Some(muxed_account.account_id())
496    }
497
498    // Resolve a contract address to a contract id. The contract can be a
499    // C-address or a contract alias.
500    fn resolve_contract(&self, address: &str, network_passphrase: &str) -> Option<ScAddress> {
501        address.parse().ok().or_else(|| {
502            Some(ScAddress::Contract(stellar_xdr::ContractId(
503                self.locator
504                    .resolve_contract_id(address, network_passphrase)
505                    .ok()?
506                    .0
507                    .into(),
508            )))
509        })
510    }
511}
512
513/// Copy decompressed data from `reader` to `writer`, enforcing a maximum
514/// decompressed size. Returns an error if the decompressed output exceeds
515/// `max_bytes`.
516async fn copy_with_limit<R: AsyncRead + Unpin, W: tokio::io::AsyncWrite + Unpin>(
517    reader: R,
518    writer: &mut W,
519    max_bytes: u64,
520) -> Result<(), Error> {
521    let mut limited = reader.take(max_bytes);
522    tokio::io::copy(&mut limited, writer)
523        .await
524        .map_err(Error::StreamingBucket)?;
525
526    // If the underlying reader still has data, the limit was exceeded.
527    let mut decoder = limited.into_inner();
528    let mut overflow = [0u8; 1];
529    if decoder
530        .read(&mut overflow)
531        .await
532        .map_err(Error::StreamingBucket)?
533        > 0
534    {
535        return Err(Error::DecompressedSizeLimitExceeded {
536            max: ByteSize(max_bytes),
537        });
538    }
539    Ok(())
540}
541
542fn ledger_to_path_components(ledger: u32) -> (String, String, String, String) {
543    let ledger_hex = format!("{ledger:08x}");
544    let ledger_hex_0 = ledger_hex[0..=1].to_string();
545    let ledger_hex_1 = ledger_hex[2..=3].to_string();
546    let ledger_hex_2 = ledger_hex[4..=5].to_string();
547    (ledger_hex, ledger_hex_0, ledger_hex_1, ledger_hex_2)
548}
549
550async fn get_history(
551    print: &print::Print,
552    archive_url: &Url,
553    ledger: Option<u32>,
554) -> Result<History, Error> {
555    let archive_url = archive_url.to_string();
556    let archive_url = archive_url.strip_suffix('/').unwrap_or(&archive_url);
557    let history_url = if let Some(ledger) = ledger {
558        let (ledger_hex, ledger_hex_0, ledger_hex_1, ledger_hex_2) =
559            ledger_to_path_components(ledger);
560        format!("{archive_url}/history/{ledger_hex_0}/{ledger_hex_1}/{ledger_hex_2}/history-{ledger_hex}.json")
561    } else {
562        format!("{archive_url}/.well-known/stellar-history.json")
563    };
564    let history_url = Url::from_str(&history_url).unwrap();
565
566    print.globeln(format!(
567        "Downloading history {}",
568        redact_url(history_url.as_str())
569    ));
570
571    let response = http::client()
572        .get(history_url.as_str())
573        .send()
574        .await
575        .map_err(Error::DownloadingHistory)?;
576
577    if !response.status().is_success() {
578        // Check ledger is a checkpoint ledger and available in archives.
579        if let Some(ledger) = ledger {
580            let ledger_offset = (ledger + 1) % CHECKPOINT_FREQUENCY;
581
582            if ledger_offset != 0 {
583                print.errorln(format!(
584                    "Ledger {ledger} may not be a checkpoint ledger, try {} or {}",
585                    ledger - ledger_offset,
586                    ledger + (CHECKPOINT_FREQUENCY - ledger_offset),
587                ));
588            }
589        }
590        return Err(Error::DownloadingHistoryGotStatusCode(response.status()));
591    }
592
593    let body = response
594        .bytes()
595        .await
596        .map_err(Error::ReadHistoryHttpStream)?;
597
598    print.clear_previous_line();
599    print.globeln(format!(
600        "Downloaded history {}",
601        redact_url(history_url.as_str())
602    ));
603
604    serde_json::from_slice::<History>(&body).map_err(Error::JsonDecodingHistory)
605}
606
607async fn get_ledger_metadata_from_archive(
608    print: &print::Print,
609    archive_url: &Url,
610    ledger: u32,
611) -> Result<(u64, u32), Error> {
612    let archive_url = archive_url.to_string();
613    let archive_url = archive_url.strip_suffix('/').unwrap_or(&archive_url);
614
615    // Calculate the path to the ledger header file
616    let (ledger_hex, ledger_hex_0, ledger_hex_1, ledger_hex_2) = ledger_to_path_components(ledger);
617    let ledger_url = format!(
618        "{archive_url}/ledger/{ledger_hex_0}/{ledger_hex_1}/{ledger_hex_2}/ledger-{ledger_hex}.xdr.gz"
619    );
620
621    let ledger_url = Url::from_str(&ledger_url).map_err(Error::ParsingBucketUrl)?;
622
623    print.globeln(format!(
624        "Downloading ledger headers {}",
625        redact_url(ledger_url.as_str())
626    ));
627
628    let response = http::client()
629        .get(ledger_url.as_str())
630        .send()
631        .await
632        .map_err(Error::DownloadingHistory)?;
633
634    if !response.status().is_success() {
635        return Err(Error::DownloadingHistoryGotStatusCode(response.status()));
636    }
637
638    // Cache the ledger file to disk like bucket files
639    let ledger_dir = data::bucket_dir().map_err(Error::GetBucketDir)?;
640    let cache_path = ledger_dir.join(format!("ledger-{ledger_hex}.xdr"));
641    let dl_path = cache_path.with_extension("dl");
642
643    let stream = response
644        .bytes_stream()
645        .map(|result| result.map_err(std::io::Error::other));
646    let stream_reader = StreamReader::new(stream);
647    let buf_reader = BufReader::new(stream_reader);
648    let decoder = GzipDecoder::new(buf_reader);
649
650    let mut file = OpenOptions::new()
651        .create(true)
652        .truncate(true)
653        .write(true)
654        .open(&dl_path)
655        .await
656        .map_err(Error::WriteOpeningCachedBucket)?;
657
658    if let Err(e) = copy_with_limit(decoder, &mut file, MAX_LEDGER_HEADER_DECOMPRESSED_SIZE).await {
659        let _ = fs::remove_file(&dl_path);
660        return Err(e);
661    }
662
663    fs::rename(&dl_path, &cache_path).map_err(Error::RenameDownloadFile)?;
664    let _ = crate::config::locator::set_hardened_permissions(&cache_path);
665
666    print.clear_previous_line();
667    print.globeln(format!("Downloaded ledger headers for ledger {ledger}"));
668
669    // Now read the cached file
670    let file = std::fs::File::open(&cache_path).map_err(Error::ReadOpeningCachedBucket)?;
671    let limited = &mut Limited::new(file, Limits::depth(XDR_DEPTH_LIMIT));
672
673    // Find the specific ledger header entry we need
674    let entries = Frame::<LedgerHeaderHistoryEntry>::read_xdr_iter(limited);
675    for entry in entries {
676        let Frame(header_entry) = entry.map_err(Error::Xdr)?;
677
678        if header_entry.header.ledger_seq == ledger {
679            let close_time = header_entry.header.scp_value.close_time.0;
680            let base_reserve = header_entry.header.base_reserve;
681
682            return Ok((close_time, base_reserve));
683        }
684    }
685
686    Err(Error::LedgerNotFound)
687}
688
689fn validate_bucket_hash(cache_path: &PathBuf, expected_hash: &str) -> Result<(), Error> {
690    let file = std::fs::File::open(cache_path).map_err(Error::ReadOpeningCachedBucket)?;
691    let mut hasher = Sha256::new();
692    std::io::copy(&mut std::io::BufReader::new(file), &mut hasher)
693        .map_err(Error::ReadOpeningCachedBucket)?;
694    let actual_hash = hex::encode(hasher.finalize());
695
696    if actual_hash != expected_hash {
697        return Err(Error::CorruptedBucket {
698            expected: expected_hash.to_string(),
699            actual: actual_hash,
700        });
701    }
702
703    Ok(())
704}
705
706async fn cache_bucket(
707    print: &print::Print,
708    archive_url: &Url,
709    bucket_index: usize,
710    bucket: &str,
711) -> Result<PathBuf, Error> {
712    let bucket_dir = data::bucket_dir().map_err(Error::GetBucketDir)?;
713    let cache_path = bucket_dir.join(format!("bucket-{bucket}.xdr"));
714
715    // Validate cached bucket if it exists
716    if cache_path.exists() {
717        if validate_bucket_hash(&cache_path, bucket).is_err() {
718            print.warnln(format!(
719                "Cached bucket {bucket} is corrupted, re-downloading"
720            ));
721            std::fs::remove_file(&cache_path).ok();
722        } else {
723            return Ok(cache_path);
724        }
725    }
726
727    if !cache_path.exists() {
728        let bucket_0 = &bucket[0..=1];
729        let bucket_1 = &bucket[2..=3];
730        let bucket_2 = &bucket[4..=5];
731        let bucket_url =
732            format!("{archive_url}/bucket/{bucket_0}/{bucket_1}/{bucket_2}/bucket-{bucket}.xdr.gz");
733
734        print.globeln(format!("Downloading bucket {bucket_index} {bucket}…"));
735
736        let bucket_url = Url::from_str(&bucket_url).map_err(Error::ParsingBucketUrl)?;
737
738        let response = http::client()
739            .get(bucket_url.as_str())
740            .send()
741            .await
742            .map_err(Error::GettingBucket)?;
743
744        if !response.status().is_success() {
745            print.println("");
746            return Err(Error::GettingBucketGotStatusCode(response.status()));
747        }
748
749        if let Some(len) = response.content_length() {
750            print.clear_previous_line();
751            print.globeln(format!(
752                "Downloaded bucket {bucket_index} {bucket} ({})",
753                ByteSize(len)
754            ));
755        }
756
757        let stream = response
758            .bytes_stream()
759            .map(|result| result.map_err(std::io::Error::other));
760        let stream_reader = StreamReader::new(stream);
761        let buf_reader = BufReader::new(stream_reader);
762        let decoder = GzipDecoder::new(buf_reader);
763        let dl_path = cache_path.with_extension("dl");
764        let mut file = OpenOptions::new()
765            .create(true)
766            .truncate(true)
767            .write(true)
768            .open(&dl_path)
769            .await
770            .map_err(Error::WriteOpeningCachedBucket)?;
771
772        if let Err(e) = copy_with_limit(decoder, &mut file, MAX_BUCKET_DECOMPRESSED_SIZE).await {
773            let _ = fs::remove_file(&dl_path);
774            return Err(e);
775        }
776
777        fs::rename(&dl_path, &cache_path).map_err(Error::RenameDownloadFile)?;
778        let _ = crate::config::locator::set_hardened_permissions(&cache_path);
779    }
780    Ok(cache_path)
781}
782
783#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Deserialize)]
784#[serde(rename_all = "camelCase")]
785struct History {
786    current_ledger: u32,
787    current_buckets: Vec<HistoryBucket>,
788    network_passphrase: String,
789}
790
791#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Deserialize)]
792#[serde(rename_all = "camelCase")]
793struct HistoryBucket {
794    curr: String,
795    snap: String,
796}
797
798#[cfg(test)]
799mod test {
800    use super::*;
801
802    #[tokio::test]
803    async fn test_copy_with_limit_under_limit() {
804        let input: &[u8] = b"hello";
805        let mut output = Vec::new();
806        copy_with_limit(input, &mut output, 10).await.unwrap();
807        assert_eq!(output, b"hello");
808    }
809
810    #[tokio::test]
811    async fn test_copy_with_limit_exact_limit() {
812        let input: &[u8] = b"hello";
813        let mut output = Vec::new();
814        copy_with_limit(input, &mut output, 5).await.unwrap();
815        assert_eq!(output, b"hello");
816    }
817
818    #[tokio::test]
819    async fn test_copy_with_limit_over_limit() {
820        let input: &[u8] = b"hello world, this exceeds the limit";
821        let mut output = Vec::new();
822        let err = copy_with_limit(input, &mut output, 10).await.unwrap_err();
823        assert!(
824            matches!(err, Error::DecompressedSizeLimitExceeded { .. }),
825            "expected DecompressedSizeLimitExceeded, got: {err}"
826        );
827    }
828}