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