1use futures_util::stream::{FuturesUnordered, StreamExt};
7use pulith_resource::{RequestedResource, ResolvedResource};
8use pulith_source::{PlannedSources, ResolvedSourceCandidate, SelectionStrategy, SourceSpec};
9use std::path::Path;
10use std::sync::Arc;
11
12use crate::config::{DownloadSource, MultiSourceOptions, SourceSelectionStrategy};
13use crate::error::{Error, Result};
14use crate::fetch::fetcher::{FetchReceipt, FetchSource, Fetcher};
15use crate::net::http::HttpClient;
16
17pub struct MultiSourceFetcher<C: HttpClient> {
19 fetcher: Arc<Fetcher<C>>,
20}
21
22impl<C: HttpClient + 'static> MultiSourceFetcher<C> {
23 pub fn new(fetcher: Arc<Fetcher<C>>) -> Self {
25 Self { fetcher }
26 }
27
28 pub async fn fetch_multi_source_with_receipt(
30 &self,
31 sources: Vec<DownloadSource>,
32 destination: &Path,
33 options: MultiSourceOptions,
34 ) -> Result<FetchReceipt> {
35 if sources.is_empty() {
36 return Err(Error::InvalidState("No sources provided".into()));
37 }
38
39 match options.strategy {
40 SourceSelectionStrategy::Priority => {
41 self.fetch_priority(sources, destination, options).await
42 }
43 SourceSelectionStrategy::RaceAll => {
44 self.fetch_race(sources, destination, options).await
45 }
46 SourceSelectionStrategy::FastestFirst => {
47 self.fetch_fastest(sources, destination, options).await
48 }
49 SourceSelectionStrategy::Geographic => {
50 self.fetch_geographic(sources, destination, options).await
51 }
52 }
53 }
54
55 async fn fetch_priority(
57 &self,
58 mut sources: Vec<DownloadSource>,
59 destination: &Path,
60 _options: MultiSourceOptions,
61 ) -> Result<FetchReceipt> {
62 for source in sources.drain(..) {
63 match self
64 .try_source(&source, destination, &crate::FetchOptions::default())
65 .await
66 {
67 Ok(path) => return Ok(path),
68 Err(_) => continue,
69 }
70 }
71 Err(Error::Network("All sources failed".to_string()))
72 }
73
74 async fn fetch_race(
76 &self,
77 sources: Vec<DownloadSource>,
78 destination: &Path,
79 _options: MultiSourceOptions,
80 ) -> Result<FetchReceipt> {
81 let mut futures = FuturesUnordered::new();
82
83 for source in sources {
84 let fetcher = self.fetcher.clone();
85 let dest = destination.to_path_buf();
86 let future = async move {
87 fetcher
88 .fetch_with_receipt(&source.url, &dest, crate::FetchOptions::default())
89 .await
90 };
91 futures.push(Box::pin(future));
92 }
93
94 while let Some(result) = futures.next().await {
95 if let Ok(path) = result {
96 return Ok(path);
97 }
98 }
99
100 Err(Error::Network("All sources failed".to_string()))
101 }
102
103 async fn fetch_fastest(
105 &self,
106 sources: Vec<DownloadSource>,
107 destination: &Path,
108 _options: MultiSourceOptions,
109 ) -> Result<FetchReceipt> {
110 self.fetch_priority(sources, destination, _options).await
113 }
114
115 async fn fetch_geographic(
117 &self,
118 sources: Vec<DownloadSource>,
119 destination: &Path,
120 _options: MultiSourceOptions,
121 ) -> Result<FetchReceipt> {
122 self.fetch_priority(sources, destination, _options).await
125 }
126
127 async fn try_source(
129 &self,
130 source: &DownloadSource,
131 destination: &Path,
132 options: &crate::FetchOptions,
133 ) -> Result<FetchReceipt> {
134 let mut fetch_options = options.clone();
136 fetch_options.checksum = source.checksum;
137
138 self.fetcher
140 .fetch_with_receipt(&source.url, destination, fetch_options)
141 .await
142 }
143
144 pub async fn fetch_planned_sources_with_receipt(
146 &self,
147 planned: &PlannedSources,
148 destination: &Path,
149 options: &crate::FetchOptions,
150 ) -> Result<FetchReceipt> {
151 let candidates = planned.candidates();
152 if candidates.is_empty() {
153 return Err(Error::InvalidState(
154 "No planned source candidates provided".into(),
155 ));
156 }
157
158 match planned.strategy() {
159 SelectionStrategy::OrderedFallback | SelectionStrategy::Exhaustive => {
160 self.fetch_candidate_sequence(candidates, destination, options)
161 .await
162 }
163 SelectionStrategy::Race => {
164 self.fetch_candidate_race(candidates, destination, options)
165 .await
166 }
167 }
168 }
169
170 pub async fn fetch_source_spec_with_receipt(
172 &self,
173 spec: SourceSpec,
174 strategy: SelectionStrategy,
175 destination: &Path,
176 options: &crate::FetchOptions,
177 ) -> Result<FetchReceipt> {
178 let planned = spec.plan(strategy);
179 self.fetch_planned_sources_with_receipt(&planned, destination, options)
180 .await
181 }
182
183 pub async fn fetch_requested_resource_with_receipt(
185 &self,
186 resource: &RequestedResource,
187 strategy: SelectionStrategy,
188 destination: &Path,
189 options: &crate::FetchOptions,
190 ) -> Result<FetchReceipt> {
191 let planned = PlannedSources::from_requested_resource(resource, strategy)
192 .map_err(|error| Error::InvalidState(error.to_string()))?;
193 self.fetch_planned_sources_with_receipt(&planned, destination, options)
194 .await
195 }
196
197 pub async fn fetch_resolved_resource_with_receipt(
199 &self,
200 resource: &ResolvedResource,
201 strategy: SelectionStrategy,
202 destination: &Path,
203 options: &crate::FetchOptions,
204 ) -> Result<FetchReceipt> {
205 let planned = PlannedSources::from_resolved_resource(resource, strategy)
206 .map_err(|error| Error::InvalidState(error.to_string()))?;
207 self.fetch_planned_sources_with_receipt(&planned, destination, options)
208 .await
209 }
210
211 async fn fetch_candidate_sequence(
212 &self,
213 candidates: &[ResolvedSourceCandidate],
214 destination: &Path,
215 options: &crate::FetchOptions,
216 ) -> Result<FetchReceipt> {
217 let mut last_error = None;
218 for candidate in candidates {
219 match self.try_candidate(candidate, destination, options).await {
220 Ok(path) => return Ok(path),
221 Err(error) => last_error = Some(error),
222 }
223 }
224
225 Err(last_error
226 .unwrap_or_else(|| Error::Network("All planned candidates failed".to_string())))
227 }
228
229 async fn fetch_candidate_race(
230 &self,
231 candidates: &[ResolvedSourceCandidate],
232 destination: &Path,
233 options: &crate::FetchOptions,
234 ) -> Result<FetchReceipt> {
235 let mut futures = FuturesUnordered::new();
236
237 for candidate in candidates.iter().cloned() {
238 let fetcher = self.fetcher.clone();
239 let dest = destination.to_path_buf();
240 let options = options.clone();
241 futures.push(Box::pin(async move {
242 match candidate {
243 ResolvedSourceCandidate::Url(url) => {
244 fetcher
245 .fetch_with_receipt(url.as_url().as_ref(), &dest, options)
246 .await
247 }
248 ResolvedSourceCandidate::LocalPath(path) => copy_local_candidate(&path, &dest),
249 ResolvedSourceCandidate::Git { .. } => Err(Error::InvalidState(
250 "git candidates are not executable by pulith-fetch yet".to_string(),
251 )),
252 }
253 }));
254 }
255
256 let mut last_error = None;
257 while let Some(result) = futures.next().await {
258 match result {
259 Ok(path) => return Ok(path),
260 Err(error) => last_error = Some(error),
261 }
262 }
263
264 Err(last_error
265 .unwrap_or_else(|| Error::Network("All planned candidates failed".to_string())))
266 }
267
268 async fn try_candidate(
269 &self,
270 candidate: &ResolvedSourceCandidate,
271 destination: &Path,
272 options: &crate::FetchOptions,
273 ) -> Result<FetchReceipt> {
274 match candidate {
275 ResolvedSourceCandidate::Url(url) => {
276 self.fetcher
277 .fetch_with_receipt(url.as_url().as_ref(), destination, options.clone())
278 .await
279 }
280 ResolvedSourceCandidate::LocalPath(path) => copy_local_candidate(path, destination),
281 ResolvedSourceCandidate::Git { .. } => Err(Error::InvalidState(
282 "git candidates are not executable by pulith-fetch yet".to_string(),
283 )),
284 }
285 }
286}
287
288fn copy_local_candidate(source: &Path, destination: &Path) -> Result<FetchReceipt> {
289 if source.is_dir() {
290 return Err(Error::InvalidState(
291 "local directory candidates are not executable by pulith-fetch".to_string(),
292 ));
293 }
294
295 let dest_dir = destination.parent().unwrap_or_else(|| Path::new("."));
296 std::fs::create_dir_all(dest_dir).map_err(|source| {
297 Error::Fs(pulith_fs::Error::Write {
298 path: dest_dir.to_path_buf(),
299 source,
300 })
301 })?;
302
303 let file_name = destination
304 .file_name()
305 .unwrap_or_else(|| std::ffi::OsStr::new("download"));
306 let staging_dir = tempfile::Builder::new()
307 .prefix(".pulith-local-copy.")
308 .tempdir_in(dest_dir)
309 .map_err(|error| Error::Network(error.to_string()))?;
310 let staging_path = staging_dir.path().join(file_name);
311
312 std::fs::copy(source, &staging_path).map_err(|source| {
313 Error::Fs(pulith_fs::Error::Write {
314 path: staging_path.clone(),
315 source,
316 })
317 })?;
318
319 replace_destination_file(&staging_path, destination)?;
320
321 let size = std::fs::metadata(destination)
322 .map_err(|error| Error::Network(error.to_string()))?
323 .len();
324 Ok(FetchReceipt {
325 source: FetchSource::LocalPath(source.to_path_buf()),
326 destination: destination.to_path_buf(),
327 bytes_downloaded: size,
328 total_bytes: Some(size),
329 sha256_hex: None,
330 })
331}
332
333fn replace_destination_file(staged_path: &Path, destination: &Path) -> Result<()> {
334 if let Ok(metadata) = std::fs::symlink_metadata(destination) {
335 if metadata.file_type().is_dir() {
336 return Err(Error::DestinationIsDirectory);
337 }
338
339 std::fs::remove_file(destination).map_err(|source| {
340 Error::Fs(pulith_fs::Error::Write {
341 path: destination.to_path_buf(),
342 source,
343 })
344 })?;
345 }
346
347 std::fs::rename(staged_path, destination).map_err(|source| {
348 Error::Fs(pulith_fs::Error::Write {
349 path: destination.to_path_buf(),
350 source,
351 })
352 })?;
353
354 Ok(())
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use crate::error::Error;
361 use crate::net::http::BoxStream;
362 use crate::{DownloadSource, MultiSourceOptions, SourceSelectionStrategy};
363 use bytes::Bytes;
364 use pulith_resource::{
365 RequestedResource, ResolvedLocator, ResolvedVersion, ResourceId, ResourceLocator,
366 ResourceSpec, ValidUrl,
367 };
368 use pulith_source::{
369 HttpAssetSource, LocalSource, RemoteSource, SelectionStrategy, SourceDefinition, SourceSet,
370 SourceSpec,
371 };
372 use std::path::PathBuf;
373 use std::sync::Arc;
374
375 #[derive(Debug)]
377 struct MockError(String);
378
379 impl std::fmt::Display for MockError {
380 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
381 write!(f, "{}", self.0)
382 }
383 }
384
385 impl std::error::Error for MockError {}
386
387 struct MockHttpClient {
389 should_fail: bool,
390 }
391
392 impl MockHttpClient {
393 fn new() -> Self {
394 Self { should_fail: false }
395 }
396 }
397
398 impl HttpClient for MockHttpClient {
399 type Error = MockError;
400
401 async fn stream(
402 &self,
403 _url: &str,
404 _headers: &[(String, String)],
405 ) -> std::result::Result<
406 BoxStream<'static, std::result::Result<Bytes, Self::Error>>,
407 Self::Error,
408 > {
409 if self.should_fail {
410 Err(MockError("Stream failed".to_string()))
411 } else {
412 let stream = futures_util::stream::once(async { Ok(Bytes::from("test data")) });
413 Ok(Box::pin(stream)
414 as BoxStream<
415 'static,
416 std::result::Result<Bytes, Self::Error>,
417 >)
418 }
419 }
420
421 async fn head(&self, _url: &str) -> std::result::Result<Option<u64>, Self::Error> {
422 if self.should_fail {
423 Err(MockError("HEAD request failed".to_string()))
424 } else {
425 Ok(Some(1024))
426 }
427 }
428 }
429
430 #[tokio::test]
431 async fn test_multi_source_fetcher_new() {
432 let client = MockHttpClient::new();
434 let fetcher = Arc::new(Fetcher::new(client, "/tmp"));
436 let _multi_fetcher = MultiSourceFetcher::new(fetcher);
437 }
438
439 #[tokio::test]
440 async fn test_fetch_multi_source_empty_sources() {
441 let client = MockHttpClient::new();
442 let fetcher = Arc::new(Fetcher::new(client, "/tmp"));
443 let multi_fetcher = MultiSourceFetcher::new(fetcher);
444
445 let sources = Vec::new();
446 let destination = std::path::Path::new("/tmp/test");
447 let options = MultiSourceOptions {
448 sources: Vec::new(),
449 strategy: SourceSelectionStrategy::Priority,
450 verify_consistency: false,
451 per_source_timeout: None,
452 };
453
454 let result = multi_fetcher
455 .fetch_multi_source_with_receipt(sources, destination, options)
456 .await;
457 assert!(result.is_err());
458 match result.unwrap_err() {
459 Error::InvalidState(msg) => assert_eq!(msg, "No sources provided"),
460 _ => panic!("Expected InvalidState error"),
461 }
462 }
463
464 #[tokio::test]
465 async fn test_fetch_multi_source_priority_strategy() {
466 let client = MockHttpClient::new();
467 let fetcher = Arc::new(Fetcher::new(client, "/tmp"));
468 let multi_fetcher = MultiSourceFetcher::new(fetcher);
469
470 let sources = vec![
471 DownloadSource::new("http://example1.com".to_string()),
472 DownloadSource::new("http://example2.com".to_string()),
473 ];
474 let destination = std::path::Path::new("/tmp/test");
475 let options = MultiSourceOptions {
476 sources: sources.clone(),
477 strategy: SourceSelectionStrategy::Priority,
478 verify_consistency: false,
479 per_source_timeout: None,
480 };
481
482 let result = multi_fetcher
483 .fetch_multi_source_with_receipt(sources, destination, options)
484 .await;
485 assert!(result.is_err() || result.is_ok());
488 }
489
490 #[tokio::test]
491 async fn test_fetch_multi_source_race_all_strategy() {
492 let client = MockHttpClient::new();
493 let fetcher = Arc::new(Fetcher::new(client, "/tmp"));
494 let multi_fetcher = MultiSourceFetcher::new(fetcher);
495
496 let sources = vec![
497 DownloadSource::new("http://example1.com".to_string()),
498 DownloadSource::new("http://example2.com".to_string()),
499 ];
500 let destination = std::path::Path::new("/tmp/test");
501 let options = MultiSourceOptions {
502 sources: sources.clone(),
503 strategy: SourceSelectionStrategy::RaceAll,
504 verify_consistency: false,
505 per_source_timeout: None,
506 };
507
508 let result = multi_fetcher
509 .fetch_multi_source_with_receipt(sources, destination, options)
510 .await;
511 assert!(result.is_err() || result.is_ok());
514 }
515
516 #[tokio::test]
517 async fn test_fetch_multi_source_fastest_first_strategy() {
518 let client = MockHttpClient::new();
519 let fetcher = Arc::new(Fetcher::new(client, "/tmp"));
520 let multi_fetcher = MultiSourceFetcher::new(fetcher);
521
522 let sources = vec![
523 DownloadSource::new("http://example1.com".to_string()),
524 DownloadSource::new("http://example2.com".to_string()),
525 ];
526 let destination = std::path::Path::new("/tmp/test");
527 let options = MultiSourceOptions {
528 sources: sources.clone(),
529 strategy: SourceSelectionStrategy::FastestFirst,
530 verify_consistency: false,
531 per_source_timeout: None,
532 };
533
534 let result = multi_fetcher
535 .fetch_multi_source_with_receipt(sources, destination, options)
536 .await;
537 assert!(result.is_err() || result.is_ok());
540 }
541
542 #[tokio::test]
543 async fn test_fetch_multi_source_geographic_strategy() {
544 let client = MockHttpClient::new();
545 let fetcher = Arc::new(Fetcher::new(client, "/tmp"));
546 let multi_fetcher = MultiSourceFetcher::new(fetcher);
547
548 let sources = vec![
549 DownloadSource::new("http://us.example.com".to_string()),
550 DownloadSource::new("http://eu.example.com".to_string()),
551 ];
552 let destination = std::path::Path::new("/tmp/test");
553 let options = MultiSourceOptions {
554 sources: sources.clone(),
555 strategy: SourceSelectionStrategy::Geographic,
556 verify_consistency: false,
557 per_source_timeout: None,
558 };
559
560 let result = multi_fetcher
561 .fetch_multi_source_with_receipt(sources, destination, options)
562 .await;
563 assert!(result.is_err() || result.is_ok());
566 }
567
568 #[tokio::test]
569 async fn test_fetch_planned_sources_with_http_candidates() {
570 let temp = tempfile::tempdir().unwrap();
571 let client = MockHttpClient::new();
572 let fetcher = Arc::new(Fetcher::new(client, temp.path().join("workspace")));
573 let multi_fetcher = MultiSourceFetcher::new(fetcher);
574
575 let planned = SourceSpec::new(
576 SourceSet::new(vec![
577 SourceDefinition::Remote(RemoteSource::HttpAsset(HttpAssetSource {
578 url: ValidUrl::parse("https://example.com/file").unwrap(),
579 file_name: None,
580 })),
581 SourceDefinition::Remote(RemoteSource::HttpAsset(HttpAssetSource {
582 url: ValidUrl::parse("https://mirror.example.com/file").unwrap(),
583 file_name: None,
584 })),
585 ])
586 .unwrap(),
587 )
588 .plan(SelectionStrategy::OrderedFallback);
589
590 let destination = temp.path().join("downloads").join("artifact.bin");
591 let result = multi_fetcher
592 .fetch_planned_sources_with_receipt(
593 &planned,
594 &destination,
595 &crate::FetchOptions::default(),
596 )
597 .await;
598
599 assert!(result.is_ok());
600 assert!(destination.exists());
601 }
602
603 #[tokio::test]
604 async fn test_fetch_source_spec_with_receipt_plans_and_fetches() {
605 let temp = tempfile::tempdir().unwrap();
606 let client = MockHttpClient::new();
607 let fetcher = Arc::new(Fetcher::new(client, temp.path().join("workspace")));
608 let multi_fetcher = MultiSourceFetcher::new(fetcher);
609
610 let spec = SourceSpec::new(
611 SourceSet::new(vec![SourceDefinition::Remote(RemoteSource::HttpAsset(
612 HttpAssetSource {
613 url: ValidUrl::parse("https://example.com/file").unwrap(),
614 file_name: None,
615 },
616 ))])
617 .unwrap(),
618 );
619
620 let destination = temp.path().join("downloads").join("artifact.bin");
621 let result = multi_fetcher
622 .fetch_source_spec_with_receipt(
623 spec,
624 SelectionStrategy::OrderedFallback,
625 &destination,
626 &crate::FetchOptions::default(),
627 )
628 .await;
629
630 assert!(result.is_ok());
631 assert!(destination.exists());
632 }
633
634 #[tokio::test]
635 async fn test_fetch_planned_sources_with_local_candidate() {
636 let destination_root = tempfile::tempdir().unwrap();
637 let source_root = tempfile::tempdir().unwrap();
638 let client = MockHttpClient::new();
639 let fetcher = Arc::new(Fetcher::new(
640 client,
641 destination_root.path().join("workspace"),
642 ));
643 let multi_fetcher = MultiSourceFetcher::new(fetcher);
644
645 let source_path = source_root.path().join("local.bin");
646 std::fs::write(&source_path, b"local-data").unwrap();
647 let destination = destination_root
648 .path()
649 .join("downloads")
650 .join("artifact.bin");
651
652 let planned = SourceSpec::new(
653 SourceSet::new(vec![SourceDefinition::Local(LocalSource {
654 path: source_path,
655 })])
656 .unwrap(),
657 )
658 .plan(SelectionStrategy::OrderedFallback);
659
660 let result = multi_fetcher
661 .fetch_planned_sources_with_receipt(
662 &planned,
663 &destination,
664 &crate::FetchOptions::default(),
665 )
666 .await;
667
668 assert!(result.is_ok());
669 assert_eq!(std::fs::read(destination).unwrap(), b"local-data");
670 }
671
672 #[tokio::test]
673 async fn test_fetch_planned_sources_with_repeated_local_candidates_in_same_directory() {
674 let destination_root = tempfile::tempdir().unwrap();
675 let source_root = tempfile::tempdir().unwrap();
676 let client = MockHttpClient::new();
677 let fetcher = Arc::new(Fetcher::new(
678 client,
679 destination_root.path().join("workspace"),
680 ));
681 let multi_fetcher = MultiSourceFetcher::new(fetcher);
682 let destination_dir = destination_root.path().join("downloads");
683
684 for (name, payload) in [("artifact-v1.bin", b"v1"), ("artifact-v2.bin", b"v2")] {
685 let source_path = source_root.path().join(name);
686 std::fs::write(&source_path, payload).unwrap();
687 let destination = destination_dir.join(name);
688
689 let planned = SourceSpec::new(
690 SourceSet::new(vec![SourceDefinition::Local(LocalSource {
691 path: source_path.clone(),
692 })])
693 .unwrap(),
694 )
695 .plan(SelectionStrategy::OrderedFallback);
696
697 let result = multi_fetcher
698 .fetch_planned_sources_with_receipt(
699 &planned,
700 &destination,
701 &crate::FetchOptions::default(),
702 )
703 .await;
704
705 assert!(result.is_ok());
706 assert_eq!(std::fs::read(destination).unwrap(), payload);
707 }
708 }
709
710 #[tokio::test]
711 async fn test_fetch_resolved_resource_with_receipt() {
712 let destination_root = tempfile::tempdir().unwrap();
713 let source_root = tempfile::tempdir().unwrap();
714 let client = MockHttpClient::new();
715 let fetcher = Arc::new(Fetcher::new(
716 client,
717 destination_root.path().join("workspace"),
718 ));
719 let multi_fetcher = MultiSourceFetcher::new(fetcher);
720
721 let source_path = source_root.path().join("runtime.zip");
722 std::fs::write(&source_path, b"archive-bytes").unwrap();
723 let resource = RequestedResource::new(ResourceSpec::new(
724 ResourceId::parse("example/runtime").unwrap(),
725 ResourceLocator::LocalPath(source_path),
726 ))
727 .resolve(
728 ResolvedVersion::new("1.0.0").unwrap(),
729 ResolvedLocator::LocalPath(PathBuf::from("/local/runtime.zip")),
730 None,
731 );
732
733 let destination = destination_root
734 .path()
735 .join("downloads")
736 .join("runtime.zip");
737 let result = multi_fetcher
738 .fetch_resolved_resource_with_receipt(
739 &resource,
740 SelectionStrategy::OrderedFallback,
741 &destination,
742 &crate::FetchOptions::default(),
743 )
744 .await;
745
746 assert!(result.is_ok());
747 assert_eq!(std::fs::read(destination).unwrap(), b"archive-bytes");
748 }
749
750 #[tokio::test]
751 async fn test_fetch_requested_resource_with_receipt() {
752 let destination_root = tempfile::tempdir().unwrap();
753 let source_root = tempfile::tempdir().unwrap();
754 let client = MockHttpClient::new();
755 let fetcher = Arc::new(Fetcher::new(
756 client,
757 destination_root.path().join("workspace"),
758 ));
759 let multi_fetcher = MultiSourceFetcher::new(fetcher);
760
761 let source_path = source_root.path().join("runtime.zip");
762 std::fs::write(&source_path, b"archive-bytes-requested").unwrap();
763 let resource = RequestedResource::new(ResourceSpec::new(
764 ResourceId::parse("example/runtime").unwrap(),
765 ResourceLocator::LocalPath(source_path),
766 ));
767
768 let destination = destination_root
769 .path()
770 .join("downloads")
771 .join("runtime-requested.zip");
772 let result = multi_fetcher
773 .fetch_requested_resource_with_receipt(
774 &resource,
775 SelectionStrategy::OrderedFallback,
776 &destination,
777 &crate::FetchOptions::default(),
778 )
779 .await;
780
781 assert!(result.is_ok());
782 assert_eq!(
783 std::fs::read(destination).unwrap(),
784 b"archive-bytes-requested"
785 );
786 }
787}