forc_pkg/source/reg/
mod.rs

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
28/// Name of the folder containing fetched registry sources.
29pub const REG_DIR_NAME: &str = "registry";
30
31/// A package from the official registry.
32#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
33pub struct Source {
34    /// The name of the specified package.
35    pub name: String,
36    /// The base version specified for the package.
37    pub version: semver::Version,
38    /// The namespace this package resides in, if no there is no namespace in
39    /// registry setup, this will be `None`.
40    pub namespace: Namespace,
41}
42
43/// A pinned instance of the registry source.
44#[derive(Clone, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)]
45pub struct Pinned {
46    /// The registry package with base version.
47    pub source: Source,
48    /// The corresponding CID for this registry entry.
49    pub cid: Cid,
50}
51
52/// A resolver for registry index hosted as a github repo.
53///
54/// Given a package name and a version, a `GithubRegistryResolver` will be able
55/// to resolve, fetch, pin a package through using the index hosted on a github
56/// repository.
57pub struct GithubRegistryResolver {
58    /// Name of the github organization holding the registry index repository.
59    repo_org: String,
60    /// Name of git repository holding the registry index.
61    repo_name: String,
62    /// The number of letters used to chunk package name.
63    ///
64    /// Example:
65    /// If set to 2, and package name is "foobar", the index file location
66    /// will be ".../fo/ob/ar/foobar".
67    chunk_size: usize,
68    /// Type of the namespacing is needed to determine whether to add domain at
69    /// the beginning of the file location.
70    namespace: Namespace,
71    /// Branch name of the registry repo, the resolver is going to be using.
72    branch_name: String,
73}
74
75/// Error returned upon failed parsing of `Pinned::from_str`.
76#[derive(Clone, Debug)]
77pub enum PinnedParseError {
78    Prefix,
79    PackageName,
80    PackageVersion,
81    Cid,
82    Namespace,
83}
84
85impl GithubRegistryResolver {
86    /// Default github organization name that holds the registry git repo.
87    pub const DEFAULT_GITHUB_ORG: &str = "FuelLabs";
88    /// Default name of the repository that holds the registry git repo.
89    pub const DEFAULT_REPO_NAME: &str = "forc.pub-index";
90    /// Default chunking size of the repository that holds registry git repo.
91    pub const DEFAULT_CHUNKING_SIZE: usize = 2;
92    /// Default branch name for the repository repo.
93    const DEFAULT_BRANCH_NAME: &str = "master";
94    /// Default timeout for each github look-up request. If exceeded request is
95    /// dropped.
96    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    /// Returns a `GithubRegistryResolver` that automatically uses
115    /// `Self::DEFAULT_GITHUB_ORG` and `Self::DEFAULT_REPO_NAME`.
116    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    /// Returns the namespace associated with this `GithubRegistryResolver`.
127    ///
128    /// See `[GithubRegistryResolver::namespace]` for details.
129    pub fn namespace(&self) -> &Namespace {
130        &self.namespace
131    }
132
133    /// Returns the branch name used by this `GithubRegistryResolver`.
134    ///
135    /// See `[GithubRegistryResolver::branch_name]` for details.
136    pub fn branch_name(&self) -> &str {
137        &self.branch_name
138    }
139
140    /// Returns the chunk size used by this `GithubRegistryResolver`.
141    ///
142    /// See `[GithubRegistryResolver::chunk_size]` for details.
143    pub fn chunk_size(&self) -> usize {
144        self.chunk_size
145    }
146
147    /// Returns the owner of the repo this `GithubRegistryResolver` configured
148    /// to fetch from.
149    ///
150    /// See `[GithubRegistryResolver::repo_org]` for details.
151    pub fn repo_org(&self) -> &str {
152        &self.repo_org
153    }
154
155    /// Returns the name of the repo this `GithubRegistryResolver` configured
156    /// to fetch from.
157    ///
158    /// See `[GithubRegistryResolver::repo_name]` for details.
159    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        // registry+<package_name>?v<version>#<cid>!namespace
171        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        // registry+<package_name>?v<version>#<cid>!<namespace>
188        let s = s.trim();
189
190        // Check for "registry+" at the start.
191        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        // Parse the package name.
199        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        // Parse the package version
208        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        // Parse the CID and namespace.
213        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        // If there is a namespace string after ! and if it is not empty
223        // get a `Namespace::Domain` otherwise return a `Namespace::Flat`.
224        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        // Keep the temp dir alive by leaking it (only for tests)
264        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
286/// The name to use for a package's identifier entry under the user's forc directory.
287fn 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
298/// Validates if the cid string is valid by checking the initial 2 letters and
299/// length.
300///
301/// For CIDs to be marked as valid:
302/// 1. Must start with `Qm`.
303/// 2. Must be 46 chars long.
304///
305/// For more details see: https://docs.ipfs.tech/concepts/content-addressing/#version-0-v0
306fn 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
312/// A temporary directory that we can use for cloning a registry-sourced package's index file and discovering
313/// the corresponding CID for that package.
314///
315/// The resulting directory is:
316///
317/// ```ignore
318/// $HOME/.forc/registry/cache/tmp/<fetch_id>-name-<version_hash>
319/// ```
320///
321/// A unique `fetch_id` may be specified to avoid contention over the registry directory in the
322/// case that multiple processes or threads may be building different projects that may require
323/// fetching the same dependency.
324fn 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        // Co-ordinate access to the registry checkout directory using an advisory file lock.
374        let mut lock = forc_util::path_lock(path)?;
375        // TODO: Here we assume that if the local path already exists, that it contains the
376        // full and correct source for that registry entry and hasn't been tampered with. This is
377        // probably fine for most cases as users should never be touching these
378        // directories, however we should add some code to validate this. E.g. can we
379        // recreate the ipfs cid by hashing the directory or something along these lines?
380        // https://github.com/FuelLabs/sway/issues/7075
381        {
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                    // If the user is trying to use public IPFS node with
398                    // registry sources. Use fuel operated ipfs node
399                    // instead.
400                    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
429/// Resolve a CID from index file and pinned package. Basically goes through
430/// the index file to find corresponding entry described by the pinned instance.
431fn 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            // Create directory only after we've validated the package exists in the index
485            fs::create_dir_all(&path)?;
486
487            // Use a cleanup guard to ensure directory is removed if fetch fails
488            let cleanup_guard = scopeguard::guard(&path, |path| {
489                if path.exists() {
490                    let _ = fs::remove_dir_all(path);
491                }
492            });
493
494            // Try IPFS first, fallback to CDN if it fails
495            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            // If IPFS fails, try CDN fallback
510            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                    // Fetch successful, defuse the cleanup guard so directory is preserved
522                    scopeguard::ScopeGuard::into_inner(cleanup_guard);
523                }
524                Err(e) => {
525                    // Fetch failed, cleanup guard will automatically remove the directory
526                    return Err(e);
527                }
528            }
529
530            Ok(path)
531        },
532    )
533    .await?;
534    Ok(path)
535}
536
537/// Fetches package from CDN as a fallback when IPFS fails
538async 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    // Construct CDN URL directly from IPFS hash
545    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    // Download directly from CDN
553    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 the tarball to the destination path
572    extract_s3_archive(&bytes, path, &pinned.cid)?;
573
574    Ok(())
575}
576
577/// Extracts CDN archive to destination path
578fn extract_s3_archive(bytes: &[u8], dst: &Path, cid: &Cid) -> anyhow::Result<()> {
579    // Create the destination directory with CID name (to match IPFS behavior)
580    let dst_dir = dst.join(cid.0.to_string());
581    fs::create_dir_all(&dst_dir)?;
582
583    // Decompress and extract the tar.gz archive
584    let tar = GzDecoder::new(bytes);
585    let mut archive = Archive::new(tar);
586
587    // Extract all entries
588    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    // Add a guard to ensure cleanup happens if we got out of scope whether by
611    // returning or panicking.
612    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
651/// Execute an async block on a Tokio runtime.
652///
653/// If we are already in a runtime, this will spawn a new OS thread to create a new runtime.
654///
655/// If we are not in a runtime, a new runtime is created and the future is blocked on.
656pub(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        // In a runtime context. Spawn a new thread to run the async code.
663        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        // Not in a runtime context. Okay to create a new runtime and block.
674        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        // Add a regular version with a valid CID
743        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,   // no abi_cid
750            vec![], // no dependencies
751            false,  // not yanked
752        );
753        index_file.insert(valid_entry);
754
755        // Add a yanked version
756        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,   // no abi_cid
763            vec![], // no dependencies
764            true,   // yanked
765        );
766        index_file.insert(yanked_entry);
767
768        // Add another version just to have multiple available
769        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,   // no abi_cid
776            vec![], // no dependencies
777            false,  // not yanked
778        );
779        index_file.insert(other_entry);
780
781        // Test Case 1: Successful resolution
782        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        // Test Case 2: Error when version doesn't exist
798        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            // this cid just a placeholder, as this version does not exists
807            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        // Test Case 3: Error when version is yanked
824        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        // The test itself doesn't need to assert anything about the result,
847        // the assertions inside the async block are what matter
848        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                // Valid CID format but this will fail because the package doesn't exist in the index
856                cid: Cid::from_str("QmdMVqLqpba2mMB5AUjYCxubC6tLGevQFunpBkbC2UbrKS").unwrap(),
857            };
858
859            // Get the expected package directory path
860            let expected_path = registry_package_dir(
861                &pinned.source.namespace,
862                &pinned.source.name,
863                &pinned.source.version,
864            );
865
866            // Ensure the directory doesn't exist initially
867            if expected_path.exists() {
868                let _ = fs::remove_dir_all(&expected_path);
869            }
870            assert!(!expected_path.exists());
871
872            // Call the actual fetch function with an IPFS node that will fail
873            // This will fail during index lookup (the package doesn't exist in registry)
874            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            // Verify that fetch failed (package not found in index)
880            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            // Most importantly, verify that no directory was created or if it was created, it got cleaned up
885            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}