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#[derive(Parser, Debug, Clone)]
66#[group(skip)]
67pub struct Cmd {
68 #[arg(long)]
70 ledger: Option<u32>,
71
72 #[arg(long = "address", help_heading = "Filter Options")]
74 address: Vec<String>,
75
76 #[arg(long = "wasm-hash", help_heading = "Filter Options")]
78 wasm_hashes: Vec<Hash>,
79
80 #[arg(long, value_enum, default_value_t)]
82 output: Output,
83
84 #[arg(long, default_value=default_out_path().into_os_string())]
86 out: PathBuf,
87
88 #[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
180const CHECKPOINT_FREQUENCY: u32 = 64;
185
186const MAX_BUCKET_DECOMPRESSED_SIZE: u64 = 10 * 1024 * 1024 * 1024;
188
189const 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 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) }
222 };
223
224 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 for (i, bucket) in buckets.iter().enumerate() {
235 cache_bucket(&print, &archive_url, i, bucket).await?;
236 }
237
238 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 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 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 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 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 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 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 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 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 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
513async 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 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 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 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 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 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 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 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}