1pub mod file_location;
2pub mod index_file;
3
4use super::IPFSNode;
5use crate::{
6 manifest::{self, GenericManifestFile, PackageManifestFile},
7 source::{
8 self,
9 ipfs::{ipfs_client, Cid},
10 },
11};
12use anyhow::{anyhow, bail, Context};
13use file_location::{location_from_root, Namespace};
14use flate2::read::GzDecoder;
15use forc_tracing::println_action_green;
16use index_file::IndexFile;
17use serde::{Deserialize, Serialize};
18use std::{
19 fmt::Display,
20 fs,
21 path::{Path, PathBuf},
22 str::FromStr,
23 thread,
24 time::Duration,
25};
26use tar::Archive;
27
28pub const REG_DIR_NAME: &str = "registry";
30
31#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
33pub struct Source {
34 pub name: String,
36 pub version: semver::Version,
38 pub namespace: Namespace,
41}
42
43#[derive(Clone, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)]
45pub struct Pinned {
46 pub source: Source,
48 pub cid: Cid,
50}
51
52pub struct GithubRegistryResolver {
58 repo_org: String,
60 repo_name: String,
62 chunk_size: usize,
68 namespace: Namespace,
71 branch_name: String,
73}
74
75#[derive(Clone, Debug)]
77pub enum PinnedParseError {
78 Prefix,
79 PackageName,
80 PackageVersion,
81 Cid,
82 Namespace,
83}
84
85impl GithubRegistryResolver {
86 pub const DEFAULT_GITHUB_ORG: &str = "FuelLabs";
88 pub const DEFAULT_REPO_NAME: &str = "forc.pub-index";
90 pub const DEFAULT_CHUNKING_SIZE: usize = 2;
92 const DEFAULT_BRANCH_NAME: &str = "master";
94 const DEFAULT_TIMEOUT_MS: u64 = 10000;
97
98 pub fn new(
99 repo_org: String,
100 repo_name: String,
101 chunk_size: usize,
102 namespace: Namespace,
103 branch_name: String,
104 ) -> Self {
105 Self {
106 repo_org,
107 repo_name,
108 chunk_size,
109 namespace,
110 branch_name,
111 }
112 }
113
114 pub fn with_default_github(namespace: Namespace) -> Self {
117 Self {
118 repo_org: Self::DEFAULT_GITHUB_ORG.to_string(),
119 repo_name: Self::DEFAULT_REPO_NAME.to_string(),
120 chunk_size: Self::DEFAULT_CHUNKING_SIZE,
121 namespace,
122 branch_name: Self::DEFAULT_BRANCH_NAME.to_string(),
123 }
124 }
125
126 pub fn namespace(&self) -> &Namespace {
130 &self.namespace
131 }
132
133 pub fn branch_name(&self) -> &str {
137 &self.branch_name
138 }
139
140 pub fn chunk_size(&self) -> usize {
144 self.chunk_size
145 }
146
147 pub fn repo_org(&self) -> &str {
152 &self.repo_org
153 }
154
155 pub fn repo_name(&self) -> &str {
160 &self.repo_name
161 }
162}
163
164impl Pinned {
165 pub const PREFIX: &str = "registry";
166}
167
168impl Display for Pinned {
169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 write!(
172 f,
173 "{}+{}?{}#{}!{}",
174 Self::PREFIX,
175 self.source.name,
176 self.source.version,
177 self.cid.0,
178 self.source.namespace
179 )
180 }
181}
182
183impl FromStr for Pinned {
184 type Err = PinnedParseError;
185
186 fn from_str(s: &str) -> Result<Self, Self::Err> {
187 let s = s.trim();
189
190 let prefix_plus = format!("{}+", Self::PREFIX);
192 if s.find(&prefix_plus).is_some_and(|loc| loc != 0) {
193 return Err(PinnedParseError::Prefix);
194 }
195
196 let without_prefix = &s[prefix_plus.len()..];
197
198 let pkg_name = without_prefix
200 .split('?')
201 .next()
202 .ok_or(PinnedParseError::PackageName)?;
203
204 let without_package_name = &without_prefix[pkg_name.len() + "?".len()..];
205 let mut s_iter = without_package_name.split('#');
206
207 let pkg_version = s_iter.next().ok_or(PinnedParseError::PackageVersion)?;
209 let pkg_version =
210 semver::Version::from_str(pkg_version).map_err(|_| PinnedParseError::PackageVersion)?;
211
212 let cid_and_namespace = s_iter.next().ok_or(PinnedParseError::Cid)?;
214 let mut s_iter = cid_and_namespace.split('!');
215
216 let cid = s_iter.next().ok_or(PinnedParseError::Cid)?;
217 if !validate_cid(cid) {
218 return Err(PinnedParseError::Cid);
219 }
220 let cid = Cid::from_str(cid).map_err(|_| PinnedParseError::Cid)?;
221
222 let namespace = s_iter
225 .next()
226 .filter(|ns| !ns.is_empty())
227 .map_or_else(|| Namespace::Flat, |ns| Namespace::Domain(ns.to_string()));
228
229 let source = Source {
230 name: pkg_name.to_string(),
231 version: pkg_version,
232 namespace,
233 };
234
235 Ok(Self { source, cid })
236 }
237}
238
239impl Display for Source {
240 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241 write!(f, "{}+{}", self.name, self.version)
242 }
243}
244#[cfg(not(test))]
245fn registry_dir() -> PathBuf {
246 forc_util::user_forc_directory().join(REG_DIR_NAME)
247}
248
249#[cfg(test)]
250fn registry_dir() -> PathBuf {
251 use once_cell::sync::Lazy;
252 use std::sync::Mutex;
253
254 static TEST_REGISTRY_DIR: Lazy<Mutex<Option<PathBuf>>> = Lazy::new(|| Mutex::new(None));
255
256 let mut dir = TEST_REGISTRY_DIR.lock().unwrap();
257 if let Some(ref path) = *dir {
258 path.clone()
259 } else {
260 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir for tests");
261 let path = temp_dir.path().join(REG_DIR_NAME);
262 std::fs::create_dir_all(&path).expect("Failed to create test registry dir");
263 let leaked_path = temp_dir.keep().join(REG_DIR_NAME);
265 *dir = Some(leaked_path.clone());
266 leaked_path
267 }
268}
269
270fn registry_with_namespace_dir(namespace: &Namespace) -> PathBuf {
271 let base = registry_dir();
272 match namespace {
273 Namespace::Flat => base,
274 Namespace::Domain(ns) => base.join(ns),
275 }
276}
277
278fn registry_package_dir(
279 namespace: &Namespace,
280 pkg_name: &str,
281 pkg_version: &semver::Version,
282) -> PathBuf {
283 registry_with_namespace_dir(namespace).join(format!("{pkg_name}-{pkg_version}"))
284}
285
286fn registry_package_dir_name(name: &str, pkg_version: &semver::Version) -> String {
288 use std::hash::{Hash, Hasher};
289 fn hash_version(pkg_version: &semver::Version) -> u64 {
290 let mut hasher = std::collections::hash_map::DefaultHasher::new();
291 pkg_version.hash(&mut hasher);
292 hasher.finish()
293 }
294 let package_ver_hash = hash_version(pkg_version);
295 format!("{name}-{package_ver_hash:x}")
296}
297
298fn validate_cid(cid: &str) -> bool {
307 let cid = cid.trim();
308 let starts_with_qm = cid.starts_with("Qm");
309 starts_with_qm && cid.len() == 46
310}
311
312fn tmp_registry_package_dir(
325 fetch_id: u64,
326 name: &str,
327 version: &semver::Version,
328 namespace: &Namespace,
329) -> PathBuf {
330 let repo_dir_name = format!(
331 "{:x}-{}",
332 fetch_id,
333 registry_package_dir_name(name, version)
334 );
335 registry_with_namespace_dir(namespace)
336 .join("tmp")
337 .join(repo_dir_name)
338}
339
340impl source::Pin for Source {
341 type Pinned = Pinned;
342 fn pin(&self, ctx: source::PinCtx) -> anyhow::Result<(Self::Pinned, PathBuf)> {
343 let pkg_name = ctx.name.to_string();
344 let fetch_id = ctx.fetch_id();
345 let source = self.clone();
346 let pkg_name = pkg_name.clone();
347
348 let cid = block_on_any_runtime(async move {
349 with_tmp_fetch_index(fetch_id, &pkg_name, &source, |index_file| {
350 let version = source.version.clone();
351 let pkg_name = pkg_name.clone();
352 async move {
353 let pkg_entry = index_file
354 .get(&version)
355 .ok_or_else(|| anyhow!("No {} found for {}", version, pkg_name))?;
356 Cid::from_str(pkg_entry.source_cid()).map_err(anyhow::Error::from)
357 }
358 })
359 .await
360 })?;
361
362 let path = registry_package_dir(&self.namespace, ctx.name, &self.version);
363 let pinned = Pinned {
364 source: self.clone(),
365 cid,
366 };
367 Ok((pinned, path))
368 }
369}
370
371impl source::Fetch for Pinned {
372 fn fetch(&self, ctx: source::PinCtx, path: &Path) -> anyhow::Result<PackageManifestFile> {
373 let mut lock = forc_util::path_lock(path)?;
375 {
382 let _guard = lock.write()?;
383 if !path.exists() {
384 println_action_green(
385 "Fetching",
386 &format!(
387 "{} {}",
388 ansiterm::Style::new().bold().paint(ctx.name),
389 self.source.version
390 ),
391 );
392 let pinned = self.clone();
393 let fetch_id = ctx.fetch_id();
394 let ipfs_node = ctx.ipfs_node().clone();
395
396 block_on_any_runtime(async move {
397 let node = match ipfs_node {
401 node if node == IPFSNode::public() => IPFSNode::fuel(),
402 node => node,
403 };
404 fetch(fetch_id, &pinned, &node).await
405 })?;
406 }
407 }
408 let path = {
409 let _guard = lock.read()?;
410 manifest::find_within(path, ctx.name())
411 .ok_or_else(|| anyhow!("failed to find package `{}` in {}", ctx.name(), self))?
412 };
413 PackageManifestFile::from_file(path)
414 }
415}
416
417impl source::DepPath for Pinned {
418 fn dep_path(&self, _name: &str) -> anyhow::Result<source::DependencyPath> {
419 bail!("dep_path: registry dependencies are not yet supported");
420 }
421}
422
423impl From<Pinned> for source::Pinned {
424 fn from(p: Pinned) -> Self {
425 Self::Registry(p)
426 }
427}
428
429fn resolve_to_cid(index_file: &IndexFile, pinned: &Pinned) -> anyhow::Result<Cid> {
432 let other_versions = index_file
433 .versions()
434 .filter(|ver| **ver != pinned.source.version)
435 .map(|ver| format!("{}.{}.{}", ver.major, ver.minor, ver.patch))
436 .collect::<Vec<_>>()
437 .join(",");
438
439 let package_entry = index_file.get(&pinned.source.version).ok_or_else(|| {
440 anyhow!(
441 "Version {} not found for {}. Other available versions: [{}]",
442 pinned.source.version,
443 pinned.source.name,
444 other_versions
445 )
446 })?;
447
448 let cid = Cid::from_str(package_entry.source_cid()).with_context(|| {
449 format!(
450 "Invalid CID {}v{}: `{}`",
451 package_entry.name(),
452 package_entry.version(),
453 package_entry.source_cid()
454 )
455 })?;
456 if package_entry.yanked() {
457 bail!(
458 "Version {} of {} is yanked. Other available versions: [{}]",
459 pinned.source.version,
460 pinned.source.name,
461 other_versions
462 );
463 }
464 Ok(cid)
465}
466
467async fn fetch(fetch_id: u64, pinned: &Pinned, ipfs_node: &IPFSNode) -> anyhow::Result<PathBuf> {
468 let path = with_tmp_fetch_index(
469 fetch_id,
470 &pinned.source.name,
471 &pinned.source,
472 |index_file| async move {
473 let path = registry_package_dir(
474 &pinned.source.namespace,
475 &pinned.source.name,
476 &pinned.source.version,
477 );
478 if path.exists() {
479 let _ = fs::remove_dir_all(&path);
480 }
481
482 let cid = resolve_to_cid(&index_file, pinned)?;
483
484 fs::create_dir_all(&path)?;
486
487 let cleanup_guard = scopeguard::guard(&path, |path| {
489 if path.exists() {
490 let _ = fs::remove_dir_all(path);
491 }
492 });
493
494 let ipfs_result = match ipfs_node {
496 IPFSNode::Local => {
497 println_action_green("Fetching", "with local IPFS node");
498 cid.fetch_with_client(&ipfs_client(), &path).await
499 }
500 IPFSNode::WithUrl(gateway_url) => {
501 println_action_green(
502 "Fetching",
503 &format!("from {gateway_url}. Note: This can take several minutes."),
504 );
505 cid.fetch_with_gateway_url(gateway_url, &path).await
506 }
507 };
508
509 let fetch_result = if let Err(ipfs_error) = ipfs_result {
511 println_action_green("Warning", &format!("IPFS fetch failed: {ipfs_error}"));
512 fetch_from_s3(pinned, &path).await.with_context(|| {
513 format!("Both IPFS and CDN fallback failed. IPFS error: {ipfs_error}")
514 })
515 } else {
516 Ok(())
517 };
518
519 match fetch_result {
520 Ok(()) => {
521 scopeguard::ScopeGuard::into_inner(cleanup_guard);
523 }
524 Err(e) => {
525 return Err(e);
527 }
528 }
529
530 Ok(path)
531 },
532 )
533 .await?;
534 Ok(path)
535}
536
537async fn fetch_from_s3(pinned: &Pinned, path: &Path) -> anyhow::Result<()> {
539 let client = reqwest::Client::builder()
540 .timeout(std::time::Duration::from_secs(180))
541 .build()
542 .context("Failed to create HTTP client")?;
543
544 let cdn_url = format!("https://cdn.forc.pub/{}", pinned.cid.0);
546
547 println_action_green(
548 "Fetching",
549 &format!("from {cdn_url}. Note: This can take several minutes."),
550 );
551
552 let source_response = client
554 .get(&cdn_url)
555 .send()
556 .await
557 .context("Failed to download source code from CDN")?;
558
559 if !source_response.status().is_success() {
560 bail!(
561 "Failed to download source from CDN: HTTP {}",
562 source_response.status()
563 );
564 }
565
566 let bytes = source_response
567 .bytes()
568 .await
569 .context("Failed to read source code bytes")?;
570
571 extract_s3_archive(&bytes, path, &pinned.cid)?;
573
574 Ok(())
575}
576
577fn extract_s3_archive(bytes: &[u8], dst: &Path, cid: &Cid) -> anyhow::Result<()> {
579 let dst_dir = dst.join(cid.0.to_string());
581 fs::create_dir_all(&dst_dir)?;
582
583 let tar = GzDecoder::new(bytes);
585 let mut archive = Archive::new(tar);
586
587 for entry in archive.entries()? {
589 let mut entry = entry?;
590 entry.unpack_in(&dst_dir)?;
591 }
592 Ok(())
593}
594
595async fn with_tmp_fetch_index<F, O, Fut>(
596 fetch_id: u64,
597 pkg_name: &str,
598 source: &Source,
599 f: F,
600) -> anyhow::Result<O>
601where
602 F: FnOnce(IndexFile) -> Fut,
603 Fut: std::future::Future<Output = anyhow::Result<O>>,
604{
605 let tmp_dir = tmp_registry_package_dir(fetch_id, pkg_name, &source.version, &source.namespace);
606 if tmp_dir.exists() {
607 let _ = std::fs::remove_dir_all(&tmp_dir);
608 }
609
610 let _cleanup_guard = scopeguard::guard(&tmp_dir, |dir| {
613 let _ = std::fs::remove_dir_all(dir);
614 });
615
616 let github_resolver = GithubRegistryResolver::with_default_github(source.namespace.clone());
617
618 let path = location_from_root(github_resolver.chunk_size, &source.namespace, pkg_name)
619 .display()
620 .to_string();
621 let index_repo_owner = github_resolver.repo_org();
622 let index_repo_name = github_resolver.repo_name();
623 let reference = format!("refs/heads/{}", github_resolver.branch_name());
624 let github_endpoint = format!(
625 "https://raw.githubusercontent.com/{index_repo_owner}/{index_repo_name}/{reference}/{path}"
626 );
627 let client = reqwest::Client::new();
628 let timeout_duration = Duration::from_millis(GithubRegistryResolver::DEFAULT_TIMEOUT_MS);
629 let index_response = client
630 .get(github_endpoint)
631 .timeout(timeout_duration)
632 .send()
633 .await
634 .map_err(|e| {
635 anyhow!(
636 "Failed to send request to github to obtain package index file from registry {e}"
637 )
638 })?
639 .error_for_status()
640 .map_err(|_| anyhow!("Failed to fetch {pkg_name}"))?;
641
642 let contents = index_response.text().await?;
643 let index_file: IndexFile = serde_json::from_str(&contents).with_context(|| {
644 format!("Unable to deserialize a github registry lookup response. Body was: \"{contents}\"")
645 })?;
646
647 let res = f(index_file).await?;
648 Ok(res)
649}
650
651pub(crate) fn block_on_any_runtime<F>(future: F) -> F::Output
657where
658 F: std::future::Future + Send + 'static,
659 F::Output: Send + 'static,
660{
661 if tokio::runtime::Handle::try_current().is_ok() {
662 thread::spawn(move || {
664 let rt = tokio::runtime::Builder::new_current_thread()
665 .enable_all()
666 .build()
667 .unwrap();
668 rt.block_on(future)
669 })
670 .join()
671 .unwrap()
672 } else {
673 let rt = tokio::runtime::Builder::new_current_thread()
675 .enable_all()
676 .build()
677 .unwrap();
678 rt.block_on(future)
679 }
680}
681
682#[cfg(test)]
683mod tests {
684 use super::{
685 block_on_any_runtime, fetch, file_location::Namespace, registry_package_dir,
686 resolve_to_cid, Pinned, Source,
687 };
688 use crate::source::{
689 ipfs::Cid,
690 reg::index_file::{IndexFile, PackageEntry},
691 IPFSNode,
692 };
693 use std::{fs, str::FromStr};
694
695 #[test]
696 fn parse_pinned_entry_without_namespace() {
697 let pinned_str = "registry+core?0.0.1#QmdMVqLqpba2mMB5AUjYCxubC6tLGevQFunpBkbC2UbrKS!";
698 let pinned = Pinned::from_str(pinned_str).unwrap();
699
700 let expected_source = Source {
701 name: "core".to_string(),
702 version: semver::Version::new(0, 0, 1),
703 namespace: Namespace::Flat,
704 };
705
706 let cid = Cid::from_str("QmdMVqLqpba2mMB5AUjYCxubC6tLGevQFunpBkbC2UbrKS").unwrap();
707
708 let expected_pinned = Pinned {
709 source: expected_source,
710 cid,
711 };
712
713 assert_eq!(pinned, expected_pinned)
714 }
715
716 #[test]
717 fn parse_pinned_entry_with_namespace() {
718 let pinned_str =
719 "registry+core?0.0.1#QmdMVqLqpba2mMB5AUjYCxubC6tLGevQFunpBkbC2UbrKS!fuelnamespace";
720 let pinned = Pinned::from_str(pinned_str).unwrap();
721
722 let expected_source = Source {
723 name: "core".to_string(),
724 version: semver::Version::new(0, 0, 1),
725 namespace: Namespace::Domain("fuelnamespace".to_string()),
726 };
727
728 let cid = Cid::from_str("QmdMVqLqpba2mMB5AUjYCxubC6tLGevQFunpBkbC2UbrKS").unwrap();
729
730 let expected_pinned = Pinned {
731 source: expected_source,
732 cid,
733 };
734
735 assert_eq!(pinned, expected_pinned)
736 }
737
738 #[test]
739 fn test_resolve_to_cid() {
740 let mut index_file = IndexFile::default();
741
742 let valid_cid = "QmdMVqLqpba2mMB5AUjYCxubC6tLGevQFunpBkbC2UbrKS";
744 let valid_version = semver::Version::new(1, 0, 0);
745 let valid_entry = PackageEntry::new(
746 "test_package".to_string(),
747 valid_version.clone(),
748 valid_cid.to_string(),
749 None, vec![], false, );
753 index_file.insert(valid_entry);
754
755 let yanked_cid = "QmdMVqLqpba2mMB5AUjYCxubC6tLGevQFunpBkbC2UbrKR";
757 let yanked_version = semver::Version::new(0, 9, 0);
758 let yanked_entry = PackageEntry::new(
759 "test_package".to_string(),
760 yanked_version.clone(),
761 yanked_cid.to_string(),
762 None, vec![], true, );
766 index_file.insert(yanked_entry);
767
768 let other_cid = "QmdMVqLqpba2mMB5AUjYCxubC6tLGevQFunpBkbC2UbrKT";
770 let other_version = semver::Version::new(1, 1, 0);
771 let other_entry = PackageEntry::new(
772 "test_package".to_string(),
773 other_version.clone(),
774 other_cid.to_string(),
775 None, vec![], false, );
779 index_file.insert(other_entry);
780
781 let valid_source = Source {
783 name: "test_package".to_string(),
784 version: valid_version.clone(),
785 namespace: Namespace::Flat,
786 };
787 let valid_pinned = Pinned {
788 source: valid_source,
789 cid: Cid::from_str(valid_cid).unwrap(),
790 };
791
792 let result = resolve_to_cid(&index_file, &valid_pinned);
793 assert!(result.is_ok());
794 let valid_cid = Cid::from_str(valid_cid).unwrap();
795 assert_eq!(result.unwrap(), valid_cid);
796
797 let nonexistent_version = semver::Version::new(2, 0, 0);
799 let nonexistent_source = Source {
800 name: "test_package".to_string(),
801 version: nonexistent_version,
802 namespace: Namespace::Flat,
803 };
804 let nonexistent_pinned = Pinned {
805 source: nonexistent_source,
806 cid: valid_cid,
808 };
809
810 let result = resolve_to_cid(&index_file, &nonexistent_pinned);
811 assert!(result.is_err());
812 let error_msg = result.unwrap_err().to_string();
813 assert!(error_msg.contains("Version 2.0.0 not found"));
814 assert!(
815 error_msg.contains("Other available versions: [1.1.0,0.9.0,1.0.0]")
816 || error_msg.contains("Other available versions: [0.9.0,1.0.0,1.1.0]")
817 || error_msg.contains("Other available versions: [1.0.0,0.9.0,1.1.0]")
818 || error_msg.contains("Other available versions: [0.9.0,1.1.0,1.0.0]")
819 || error_msg.contains("Other available versions: [1.0.0,1.1.0,0.9.0]")
820 || error_msg.contains("Other available versions: [1.1.0,1.0.0,0.9.0]")
821 );
822
823 let yanked_source = Source {
825 name: "test_package".to_string(),
826 version: yanked_version.clone(),
827 namespace: Namespace::Flat,
828 };
829 let yanked_pinned = Pinned {
830 source: yanked_source,
831 cid: Cid::from_str(yanked_cid).unwrap(),
832 };
833
834 let result = resolve_to_cid(&index_file, &yanked_pinned);
835 assert!(result.is_err());
836 let error_msg = result.unwrap_err().to_string();
837 assert!(error_msg.contains("Version 0.9.0 of test_package is yanked"));
838 assert!(
839 error_msg.contains("Other available versions: [1.1.0,1.0.0]")
840 || error_msg.contains("Other available versions: [1.0.0,1.1.0]")
841 );
842 }
843
844 #[test]
845 fn test_fetch_directory_cleanup_on_failure() {
846 block_on_any_runtime(async {
849 let pinned = Pinned {
850 source: Source {
851 name: "nonexistent_test_package".to_string(),
852 version: semver::Version::new(1, 0, 0),
853 namespace: Namespace::Flat,
854 },
855 cid: Cid::from_str("QmdMVqLqpba2mMB5AUjYCxubC6tLGevQFunpBkbC2UbrKS").unwrap(),
857 };
858
859 let expected_path = registry_package_dir(
861 &pinned.source.namespace,
862 &pinned.source.name,
863 &pinned.source.version,
864 );
865
866 if expected_path.exists() {
868 let _ = fs::remove_dir_all(&expected_path);
869 }
870 assert!(!expected_path.exists());
871
872 let fetch_id = 12345;
875 let ipfs_node = IPFSNode::WithUrl("https://invalid-url.com".to_string());
876
877 let result = fetch(fetch_id, &pinned, &ipfs_node).await;
878
879 assert!(result.is_err());
881 let error_msg = result.unwrap_err().to_string();
882 assert!(error_msg.contains("Failed to fetch nonexistent_test_package"));
883
884 assert!(
886 !expected_path.exists(),
887 "Directory should not exist after fetch failure, but it exists at: {}",
888 expected_path.display()
889 );
890 });
891 }
892}