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