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