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 forc_tracing::println_action_green;
15use index_file::IndexFile;
16use serde::{Deserialize, Serialize};
17use std::{
18 fmt::Display,
19 fs,
20 path::{Path, PathBuf},
21 str::FromStr,
22 time::Duration,
23};
24
25pub const REG_DIR_NAME: &str = "registry";
27
28#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
30pub struct Source {
31 pub name: String,
33 pub version: semver::Version,
35 pub namespace: Namespace,
38}
39
40#[derive(Clone, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)]
42pub struct Pinned {
43 pub source: Source,
45 pub cid: Cid,
47}
48
49pub struct GithubRegistryResolver {
55 repo_org: String,
57 repo_name: String,
59 chunk_size: usize,
65 namespace: Namespace,
68 branch_name: String,
70}
71
72#[derive(Clone, Debug)]
74pub enum PinnedParseError {
75 Prefix,
76 PackageName,
77 PackageVersion,
78 Cid,
79 Namespace,
80}
81
82impl GithubRegistryResolver {
83 pub const DEFAULT_GITHUB_ORG: &str = "FuelLabs";
85 pub const DEFAULT_REPO_NAME: &str = "forc.pub-index";
87 pub const DEFAULT_CHUNKING_SIZE: usize = 2;
89 const DEFAULT_BRANCH_NAME: &str = "master";
91 const DEFAULT_TIMEOUT_MS: u64 = 10000;
94
95 pub fn new(
96 repo_org: String,
97 repo_name: String,
98 chunk_size: usize,
99 namespace: Namespace,
100 branch_name: String,
101 ) -> Self {
102 Self {
103 repo_org,
104 repo_name,
105 chunk_size,
106 namespace,
107 branch_name,
108 }
109 }
110
111 pub fn with_default_github(namespace: Namespace) -> Self {
114 Self {
115 repo_org: Self::DEFAULT_GITHUB_ORG.to_string(),
116 repo_name: Self::DEFAULT_REPO_NAME.to_string(),
117 chunk_size: Self::DEFAULT_CHUNKING_SIZE,
118 namespace,
119 branch_name: Self::DEFAULT_BRANCH_NAME.to_string(),
120 }
121 }
122
123 pub fn namespace(&self) -> &Namespace {
127 &self.namespace
128 }
129
130 pub fn branch_name(&self) -> &str {
134 &self.branch_name
135 }
136
137 pub fn chunk_size(&self) -> usize {
141 self.chunk_size
142 }
143
144 pub fn repo_org(&self) -> &str {
149 &self.repo_org
150 }
151
152 pub fn repo_name(&self) -> &str {
157 &self.repo_name
158 }
159}
160
161impl Pinned {
162 pub const PREFIX: &str = "registry";
163}
164
165impl Display for Pinned {
166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 write!(
169 f,
170 "{}+{}?{}#{}!{}",
171 Self::PREFIX,
172 self.source.name,
173 self.source.version,
174 self.cid.0,
175 self.source.namespace
176 )
177 }
178}
179
180impl FromStr for Pinned {
181 type Err = PinnedParseError;
182
183 fn from_str(s: &str) -> Result<Self, Self::Err> {
184 let s = s.trim();
186
187 let prefix_plus = format!("{}+", Self::PREFIX);
189 if s.find(&prefix_plus).is_some_and(|loc| loc != 0) {
190 return Err(PinnedParseError::Prefix);
191 }
192
193 let without_prefix = &s[prefix_plus.len()..];
194
195 let pkg_name = without_prefix
197 .split('?')
198 .next()
199 .ok_or(PinnedParseError::PackageName)?;
200
201 let without_package_name = &without_prefix[pkg_name.len() + "?".len()..];
202 let mut s_iter = without_package_name.split('#');
203
204 let pkg_version = s_iter.next().ok_or(PinnedParseError::PackageVersion)?;
206 let pkg_version =
207 semver::Version::from_str(pkg_version).map_err(|_| PinnedParseError::PackageVersion)?;
208
209 let cid_and_namespace = s_iter.next().ok_or(PinnedParseError::Cid)?;
211 let mut s_iter = cid_and_namespace.split('!');
212
213 let cid = s_iter.next().ok_or(PinnedParseError::Cid)?;
214 if !validate_cid(cid) {
215 return Err(PinnedParseError::Cid);
216 }
217 let cid = Cid::from_str(cid).map_err(|_| PinnedParseError::Cid)?;
218
219 let namespace = s_iter
222 .next()
223 .filter(|ns| !ns.is_empty())
224 .map_or_else(|| Namespace::Flat, |ns| Namespace::Domain(ns.to_string()));
225
226 let source = Source {
227 name: pkg_name.to_string(),
228 version: pkg_version,
229 namespace,
230 };
231
232 Ok(Self { source, cid })
233 }
234}
235
236impl Display for Source {
237 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238 write!(f, "{}+{}", self.name, self.version)
239 }
240}
241fn registry_dir() -> PathBuf {
242 forc_util::user_forc_directory().join(REG_DIR_NAME)
243}
244
245fn registry_with_namespace_dir(namespace: &Namespace) -> PathBuf {
246 let base = registry_dir();
247 match namespace {
248 Namespace::Flat => base,
249 Namespace::Domain(ns) => base.join(ns),
250 }
251}
252
253fn registry_package_dir(
254 namespace: &Namespace,
255 pkg_name: &str,
256 pkg_version: &semver::Version,
257) -> PathBuf {
258 registry_with_namespace_dir(namespace).join(format!("{pkg_name}-{pkg_version}"))
259}
260
261fn registry_package_dir_name(name: &str, pkg_version: &semver::Version) -> String {
263 use std::hash::{Hash, Hasher};
264 fn hash_version(pkg_version: &semver::Version) -> u64 {
265 let mut hasher = std::collections::hash_map::DefaultHasher::new();
266 pkg_version.hash(&mut hasher);
267 hasher.finish()
268 }
269 let package_ver_hash = hash_version(pkg_version);
270 format!("{name}-{package_ver_hash:x}")
271}
272
273fn validate_cid(cid: &str) -> bool {
282 let cid = cid.trim();
283 let starts_with_qm = cid.starts_with("Qm");
284 starts_with_qm && cid.len() == 46
285}
286
287fn tmp_registry_package_dir(
300 fetch_id: u64,
301 name: &str,
302 version: &semver::Version,
303 namespace: &Namespace,
304) -> PathBuf {
305 let repo_dir_name = format!(
306 "{:x}-{}",
307 fetch_id,
308 registry_package_dir_name(name, version)
309 );
310 registry_with_namespace_dir(namespace)
311 .join("tmp")
312 .join(repo_dir_name)
313}
314
315impl source::Pin for Source {
316 type Pinned = Pinned;
317 fn pin(&self, ctx: source::PinCtx) -> anyhow::Result<(Self::Pinned, PathBuf)> {
318 let pkg_name = ctx.name;
319 let cid = futures::executor::block_on(async {
320 with_tmp_fetch_index(ctx.fetch_id(), pkg_name, self, |index_file| async move {
321 let version = &self.version;
322 let pkg_entry = index_file
323 .get(version)
324 .ok_or_else(|| anyhow!("No {} found for {}", version, pkg_name))?;
325 let cid = Cid::from_str(pkg_entry.source_cid());
326 Ok(cid)
327 })
328 .await
329 })??;
330 let path = registry_package_dir(&self.namespace, pkg_name, &self.version);
331 let pinned = Pinned {
332 source: self.clone(),
333 cid,
334 };
335 Ok((pinned, path))
336 }
337}
338
339impl source::Fetch for Pinned {
340 fn fetch(&self, ctx: source::PinCtx, path: &Path) -> anyhow::Result<PackageManifestFile> {
341 let mut lock = forc_util::path_lock(path)?;
343 {
350 let _guard = lock.write()?;
351 if !path.exists() {
352 println_action_green(
353 "Fetching",
354 &format!(
355 "{} {}",
356 ansiterm::Style::new().bold().paint(ctx.name),
357 self.source.version
358 ),
359 );
360 futures::executor::block_on(async {
361 let node = match ctx.ipfs_node() {
365 node if node == &IPFSNode::public() => &IPFSNode::fuel(),
366 node => node,
367 };
368 fetch(ctx.fetch_id(), self, node).await
369 })?;
370 }
371 }
372 let path = {
373 let _guard = lock.read()?;
374 manifest::find_within(path, ctx.name())
375 .ok_or_else(|| anyhow!("failed to find package `{}` in {}", ctx.name(), self))?
376 };
377 PackageManifestFile::from_file(path)
378 }
379}
380
381impl source::DepPath for Pinned {
382 fn dep_path(&self, _name: &str) -> anyhow::Result<source::DependencyPath> {
383 bail!("dep_path: registry dependencies are not yet supported");
384 }
385}
386
387impl From<Pinned> for source::Pinned {
388 fn from(p: Pinned) -> Self {
389 Self::Registry(p)
390 }
391}
392
393fn resolve_to_cid(index_file: &IndexFile, pinned: &Pinned) -> anyhow::Result<Cid> {
396 let other_versions = index_file
397 .versions()
398 .filter(|ver| **ver != pinned.source.version)
399 .map(|ver| format!("{}.{}.{}", ver.major, ver.minor, ver.patch))
400 .collect::<Vec<_>>()
401 .join(",");
402
403 let package_entry = index_file.get(&pinned.source.version).ok_or_else(|| {
404 anyhow!(
405 "Version {} not found for {}. Other available versions: [{}]",
406 pinned.source.version,
407 pinned.source.name,
408 other_versions
409 )
410 })?;
411
412 let cid = Cid::from_str(package_entry.source_cid()).with_context(|| {
413 format!(
414 "Invalid CID {}v{}: `{}`",
415 package_entry.name(),
416 package_entry.version(),
417 package_entry.source_cid()
418 )
419 })?;
420 if package_entry.yanked() {
421 bail!(
422 "Version {} of {} is yanked. Other avaiable versions: [{}]",
423 pinned.source.version,
424 pinned.source.name,
425 other_versions
426 );
427 }
428 Ok(cid)
429}
430
431async fn fetch(fetch_id: u64, pinned: &Pinned, ipfs_node: &IPFSNode) -> anyhow::Result<PathBuf> {
432 let path = with_tmp_fetch_index(
433 fetch_id,
434 &pinned.source.name,
435 &pinned.source,
436 |index_file| async move {
437 let path = registry_package_dir(
438 &pinned.source.namespace,
439 &pinned.source.name,
440 &pinned.source.version,
441 );
442 if path.exists() {
443 let _ = fs::remove_dir_all(&path);
444 }
445 fs::create_dir_all(&path)?;
446
447 let cid = resolve_to_cid(&index_file, pinned)?;
448
449 match ipfs_node {
450 IPFSNode::Local => {
451 println_action_green("Fetching", "with local IPFS node");
452 cid.fetch_with_client(&ipfs_client(), &path).await?;
453 }
454 IPFSNode::WithUrl(gateway_url) => {
455 println_action_green(
456 "Fetching",
457 &format!("from {}. Note: This can take several minutes.", gateway_url),
458 );
459 cid.fetch_with_gateway_url(gateway_url, &path).await?;
460 }
461 }
462
463 Ok(path)
464 },
465 )
466 .await?;
467 Ok(path)
468}
469
470async fn with_tmp_fetch_index<F, O, Fut>(
471 fetch_id: u64,
472 pkg_name: &str,
473 source: &Source,
474 f: F,
475) -> anyhow::Result<O>
476where
477 F: FnOnce(IndexFile) -> Fut,
478 Fut: std::future::Future<Output = anyhow::Result<O>>,
479{
480 let tmp_dir = tmp_registry_package_dir(fetch_id, pkg_name, &source.version, &source.namespace);
481 if tmp_dir.exists() {
482 let _ = std::fs::remove_dir_all(&tmp_dir);
483 }
484
485 let _cleanup_guard = scopeguard::guard(&tmp_dir, |dir| {
488 let _ = std::fs::remove_dir_all(dir);
489 });
490
491 let github_resolver = GithubRegistryResolver::with_default_github(source.namespace.clone());
492
493 let path = location_from_root(github_resolver.chunk_size, &source.namespace, pkg_name)
494 .display()
495 .to_string();
496 let index_repo_owner = github_resolver.repo_org();
497 let index_repo_name = github_resolver.repo_name();
498 let reference = format!("refs/heads/{}", github_resolver.branch_name());
499 let github_endpoint = format!(
500 "https://raw.githubusercontent.com/{index_repo_owner}/{index_repo_name}/{reference}/{path}"
501 );
502 let client = reqwest::Client::new();
503 let timeout_duration = Duration::from_millis(GithubRegistryResolver::DEFAULT_TIMEOUT_MS);
504 let index_response = client
505 .get(github_endpoint)
506 .timeout(timeout_duration)
507 .send()
508 .await
509 .map_err(|e| {
510 anyhow!(
511 "Failed to send request to github to obtain package index file from registry {e}"
512 )
513 })?
514 .error_for_status()
515 .map_err(|_| anyhow!("Failed to fetch {pkg_name}"))?;
516
517 let contents = index_response.text().await?;
518 let index_file: IndexFile = serde_json::from_str(&contents).with_context(|| {
519 format!(
520 "Unable to deserialize a github registry lookup response. Body was: \"{}\"",
521 contents
522 )
523 })?;
524
525 let res = f(index_file).await?;
526 Ok(res)
527}
528
529#[cfg(test)]
530mod tests {
531 use super::{file_location::Namespace, resolve_to_cid, Pinned, Source};
532 use crate::source::{
533 ipfs::Cid,
534 reg::index_file::{IndexFile, PackageEntry},
535 };
536 use std::str::FromStr;
537
538 #[test]
539 fn parse_pinned_entry_without_namespace() {
540 let pinned_str = "registry+core?0.0.1#QmdMVqLqpba2mMB5AUjYCxubC6tLGevQFunpBkbC2UbrKS!";
541 let pinned = Pinned::from_str(pinned_str).unwrap();
542
543 let expected_source = Source {
544 name: "core".to_string(),
545 version: semver::Version::new(0, 0, 1),
546 namespace: Namespace::Flat,
547 };
548
549 let cid = Cid::from_str("QmdMVqLqpba2mMB5AUjYCxubC6tLGevQFunpBkbC2UbrKS").unwrap();
550
551 let expected_pinned = Pinned {
552 source: expected_source,
553 cid,
554 };
555
556 assert_eq!(pinned, expected_pinned)
557 }
558
559 #[test]
560 fn parse_pinned_entry_with_namespace() {
561 let pinned_str =
562 "registry+core?0.0.1#QmdMVqLqpba2mMB5AUjYCxubC6tLGevQFunpBkbC2UbrKS!fuelnamespace";
563 let pinned = Pinned::from_str(pinned_str).unwrap();
564
565 let expected_source = Source {
566 name: "core".to_string(),
567 version: semver::Version::new(0, 0, 1),
568 namespace: Namespace::Domain("fuelnamespace".to_string()),
569 };
570
571 let cid = Cid::from_str("QmdMVqLqpba2mMB5AUjYCxubC6tLGevQFunpBkbC2UbrKS").unwrap();
572
573 let expected_pinned = Pinned {
574 source: expected_source,
575 cid,
576 };
577
578 assert_eq!(pinned, expected_pinned)
579 }
580
581 #[test]
582 fn test_resolve_to_cid() {
583 let mut index_file = IndexFile::default();
584
585 let valid_cid = "QmdMVqLqpba2mMB5AUjYCxubC6tLGevQFunpBkbC2UbrKS";
587 let valid_version = semver::Version::new(1, 0, 0);
588 let valid_entry = PackageEntry::new(
589 "test_package".to_string(),
590 valid_version.clone(),
591 valid_cid.to_string(),
592 None, vec![], false, );
596 index_file.insert(valid_entry);
597
598 let yanked_cid = "QmdMVqLqpba2mMB5AUjYCxubC6tLGevQFunpBkbC2UbrKR";
600 let yanked_version = semver::Version::new(0, 9, 0);
601 let yanked_entry = PackageEntry::new(
602 "test_package".to_string(),
603 yanked_version.clone(),
604 yanked_cid.to_string(),
605 None, vec![], true, );
609 index_file.insert(yanked_entry);
610
611 let other_cid = "QmdMVqLqpba2mMB5AUjYCxubC6tLGevQFunpBkbC2UbrKT";
613 let other_version = semver::Version::new(1, 1, 0);
614 let other_entry = PackageEntry::new(
615 "test_package".to_string(),
616 other_version.clone(),
617 other_cid.to_string(),
618 None, vec![], false, );
622 index_file.insert(other_entry);
623
624 let valid_source = Source {
626 name: "test_package".to_string(),
627 version: valid_version.clone(),
628 namespace: Namespace::Flat,
629 };
630 let valid_pinned = Pinned {
631 source: valid_source,
632 cid: Cid::from_str(valid_cid).unwrap(),
633 };
634
635 let result = resolve_to_cid(&index_file, &valid_pinned);
636 assert!(result.is_ok());
637 let valid_cid = Cid::from_str(valid_cid).unwrap();
638 assert_eq!(result.unwrap(), valid_cid);
639
640 let nonexistent_version = semver::Version::new(2, 0, 0);
642 let nonexistent_source = Source {
643 name: "test_package".to_string(),
644 version: nonexistent_version,
645 namespace: Namespace::Flat,
646 };
647 let nonexistent_pinned = Pinned {
648 source: nonexistent_source,
649 cid: valid_cid,
651 };
652
653 let result = resolve_to_cid(&index_file, &nonexistent_pinned);
654 assert!(result.is_err());
655 let error_msg = result.unwrap_err().to_string();
656 assert!(error_msg.contains("Version 2.0.0 not found"));
657 assert!(
658 error_msg.contains("Other available versions: [1.1.0,0.9.0,1.0.0]")
659 || error_msg.contains("Other available versions: [0.9.0,1.0.0,1.1.0]")
660 || error_msg.contains("Other available versions: [1.0.0,0.9.0,1.1.0]")
661 || error_msg.contains("Other available versions: [0.9.0,1.1.0,1.0.0]")
662 || error_msg.contains("Other available versions: [1.0.0,1.1.0,0.9.0]")
663 || error_msg.contains("Other available versions: [1.1.0,1.0.0,0.9.0]")
664 );
665
666 let yanked_source = Source {
668 name: "test_package".to_string(),
669 version: yanked_version.clone(),
670 namespace: Namespace::Flat,
671 };
672 let yanked_pinned = Pinned {
673 source: yanked_source,
674 cid: Cid::from_str(yanked_cid).unwrap(),
675 };
676
677 let result = resolve_to_cid(&index_file, &yanked_pinned);
678 assert!(result.is_err());
679 let error_msg = result.unwrap_err().to_string();
680 assert!(error_msg.contains("Version 0.9.0 of test_package is yanked"));
681 assert!(
682 error_msg.contains("Other avaiable versions: [1.1.0,1.0.0]")
683 || error_msg.contains("Other avaiable versions: [1.0.0,1.1.0]")
684 );
685 }
686}