Skip to main content

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