1mod auth;
2
3use crate::manifest::GenericManifestFile;
4use crate::{
5 manifest::{self, PackageManifestFile},
6 source,
7};
8use anyhow::{anyhow, bail, Context, Result};
9use forc_tracing::println_action_green;
10use forc_util::git_checkouts_directory;
11use serde::{Deserialize, Serialize};
12use std::fmt::Display;
13use std::{
14 collections::hash_map,
15 fmt, fs,
16 path::{Path, PathBuf},
17 str::FromStr,
18};
19
20#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
21pub struct Url {
22 url: gix_url::Url,
23}
24
25#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
27pub struct Source {
28 pub repo: Url,
30 pub reference: Reference,
32}
33
34impl Display for Source {
35 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
36 write!(f, "{} {}", self.repo, self.reference)
37 }
38}
39
40#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
45pub enum Reference {
46 Branch(String),
47 Tag(String),
48 Rev(String),
49 DefaultBranch,
50}
51
52#[derive(Clone, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)]
54pub struct Pinned {
55 pub source: Source,
57 pub commit_hash: String,
59}
60
61#[derive(Clone, Debug)]
63pub enum PinnedParseError {
64 Prefix,
65 Url,
66 Reference,
67 CommitHash,
68}
69
70type HeadWithTime = (String, i64);
72
73const DEFAULT_REMOTE_NAME: &str = "origin";
74
75#[derive(Serialize, Deserialize)]
80pub struct SourceIndex {
81 pub git_reference: Reference,
83 pub head_with_time: HeadWithTime,
84}
85
86impl SourceIndex {
87 pub fn new(time: i64, git_reference: Reference, commit_hash: String) -> SourceIndex {
88 SourceIndex {
89 git_reference,
90 head_with_time: (commit_hash, time),
91 }
92 }
93}
94
95impl Reference {
96 pub fn resolve(&self, repo: &git2::Repository) -> Result<git2::Oid> {
98 fn resolve_tag(repo: &git2::Repository, tag: &str) -> Result<git2::Oid> {
100 let refname = format!("refs/remotes/{DEFAULT_REMOTE_NAME}/tags/{tag}");
101 let id = repo.refname_to_id(&refname)?;
102 let obj = repo.find_object(id, None)?;
103 let obj = obj.peel(git2::ObjectType::Commit)?;
104 Ok(obj.id())
105 }
106
107 fn resolve_branch(repo: &git2::Repository, branch: &str) -> Result<git2::Oid> {
109 let name = format!("{DEFAULT_REMOTE_NAME}/{branch}");
110 let b = repo
111 .find_branch(&name, git2::BranchType::Remote)
112 .with_context(|| format!("failed to find branch `{branch}`"))?;
113 b.get()
114 .target()
115 .ok_or_else(|| anyhow::format_err!("branch `{}` did not have a target", branch))
116 }
117
118 fn resolve_default_branch(repo: &git2::Repository) -> Result<git2::Oid> {
120 let head_id =
121 repo.refname_to_id(&format!("refs/remotes/{DEFAULT_REMOTE_NAME}/HEAD"))?;
122 let head = repo.find_object(head_id, None)?;
123 Ok(head.peel(git2::ObjectType::Commit)?.id())
124 }
125
126 fn resolve_rev(repo: &git2::Repository, rev: &str) -> Result<git2::Oid> {
128 let obj = repo.revparse_single(rev)?;
129 match obj.as_tag() {
130 Some(tag) => Ok(tag.target_id()),
131 None => Ok(obj.id()),
132 }
133 }
134
135 match self {
136 Reference::Tag(s) => {
137 resolve_tag(repo, s).with_context(|| format!("failed to find tag `{s}`"))
138 }
139 Reference::Branch(s) => resolve_branch(repo, s),
140 Reference::DefaultBranch => resolve_default_branch(repo),
141 Reference::Rev(s) => resolve_rev(repo, s),
142 }
143 }
144}
145
146impl Pinned {
147 pub const PREFIX: &'static str = "git";
148}
149
150impl source::Pin for Source {
151 type Pinned = Pinned;
152 fn pin(&self, ctx: source::PinCtx) -> Result<(Self::Pinned, PathBuf)> {
153 let pinned = if ctx.offline() {
157 let (_local_path, commit_hash) =
158 search_source_locally(ctx.name(), self)?.ok_or_else(|| {
159 anyhow!(
160 "Unable to fetch pkg {:?} from {:?} in offline mode",
161 ctx.name(),
162 self.repo
163 )
164 })?;
165 Pinned {
166 source: self.clone(),
167 commit_hash,
168 }
169 } else if let Reference::DefaultBranch | Reference::Branch(_) = self.reference {
170 pin(ctx.fetch_id(), ctx.name(), self.clone())?
174 } else {
175 match search_source_locally(ctx.name(), self) {
178 Ok(Some((_local_path, commit_hash))) => Pinned {
179 source: self.clone(),
180 commit_hash,
181 },
182 _ => {
183 pin(ctx.fetch_id(), ctx.name(), self.clone())?
186 }
187 }
188 };
189 let repo_path = commit_path(ctx.name(), &pinned.source.repo, &pinned.commit_hash);
190 Ok((pinned, repo_path))
191 }
192}
193
194impl source::Fetch for Pinned {
195 fn fetch(&self, ctx: source::PinCtx, repo_path: &Path) -> Result<PackageManifestFile> {
196 let mut lock = forc_util::path_lock(repo_path)?;
198 {
206 let _guard = lock.write()?;
207 if !repo_path.exists() {
208 println_action_green(
209 "Fetching",
210 &format!("{} {}", ansiterm::Style::new().bold().paint(ctx.name), self),
211 );
212 fetch(ctx.fetch_id(), ctx.name(), self)?;
213 }
214 }
215 let path = {
216 let _guard = lock.read()?;
217 manifest::find_within(repo_path, ctx.name())
218 .ok_or_else(|| anyhow!("failed to find package `{}` in {}", ctx.name(), self))?
219 };
220 PackageManifestFile::from_file(path)
221 }
222}
223
224impl source::DepPath for Pinned {
225 fn dep_path(&self, name: &str) -> anyhow::Result<source::DependencyPath> {
226 let repo_path = commit_path(name, &self.source.repo, &self.commit_hash);
227 let lock = forc_util::path_lock(&repo_path)?;
229 let _guard = lock.read()?;
230 let path = manifest::find_within(&repo_path, name)
231 .ok_or_else(|| anyhow!("failed to find package `{}` in {}", name, self))?;
232 Ok(source::DependencyPath::ManifestPath(path))
233 }
234}
235
236impl fmt::Display for Url {
237 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238 let url_string = self.url.to_bstring().to_string();
239 write!(f, "{url_string}")
240 }
241}
242
243impl fmt::Display for Pinned {
244 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
245 write!(
247 f,
248 "{}+{}?{}#{}",
249 Self::PREFIX,
250 self.source.repo,
251 self.source.reference,
252 self.commit_hash
253 )
254 }
255}
256
257impl fmt::Display for Reference {
258 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
259 match self {
260 Reference::Branch(ref s) => write!(f, "branch={s}"),
261 Reference::Tag(ref s) => write!(f, "tag={s}"),
262 Reference::Rev(ref _s) => write!(f, "rev"),
263 Reference::DefaultBranch => write!(f, "default-branch"),
264 }
265 }
266}
267
268impl FromStr for Url {
269 type Err = anyhow::Error;
270
271 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
272 let url = gix_url::Url::from_bytes(s.as_bytes().into()).map_err(|e| anyhow!("{}", e))?;
273 Ok(Self { url })
274 }
275}
276
277impl FromStr for Pinned {
278 type Err = PinnedParseError;
279 fn from_str(s: &str) -> Result<Self, Self::Err> {
280 let s = s.trim();
282
283 let prefix_plus = format!("{}+", Self::PREFIX);
285 if s.find(&prefix_plus) != Some(0) {
286 return Err(PinnedParseError::Prefix);
287 }
288 let s = &s[prefix_plus.len()..];
289
290 let repo_str = s.split('?').next().ok_or(PinnedParseError::Url)?;
292 let repo = Url::from_str(repo_str).map_err(|_| PinnedParseError::Url)?;
293 let s = &s[repo_str.len() + "?".len()..];
294
295 let mut s_iter = s.split('#');
301 let reference = s_iter.next().ok_or(PinnedParseError::Reference)?;
302 let commit_hash = s_iter
303 .next()
304 .ok_or(PinnedParseError::CommitHash)?
305 .to_string();
306 validate_git_commit_hash(&commit_hash).map_err(|_| PinnedParseError::CommitHash)?;
307
308 const BRANCH: &str = "branch=";
309 const TAG: &str = "tag=";
310 let reference = if reference.find(BRANCH) == Some(0) {
311 Reference::Branch(reference[BRANCH.len()..].to_string())
312 } else if reference.find(TAG) == Some(0) {
313 Reference::Tag(reference[TAG.len()..].to_string())
314 } else if reference == "rev" {
315 Reference::Rev(commit_hash.to_string())
316 } else if reference == "default-branch" {
317 Reference::DefaultBranch
318 } else {
319 return Err(PinnedParseError::Reference);
320 };
321
322 let source = Source { repo, reference };
323 Ok(Self {
324 source,
325 commit_hash,
326 })
327 }
328}
329
330impl Default for Reference {
331 fn default() -> Self {
332 Self::DefaultBranch
333 }
334}
335
336impl From<Pinned> for source::Pinned {
337 fn from(p: Pinned) -> Self {
338 Self::Git(p)
339 }
340}
341
342fn git_repo_dir_name(name: &str, repo: &Url) -> String {
344 use std::hash::{Hash, Hasher};
345 fn hash_url(url: &Url) -> u64 {
346 let mut hasher = hash_map::DefaultHasher::new();
347 url.hash(&mut hasher);
348 hasher.finish()
349 }
350 let repo_url_hash = hash_url(repo);
351 format!("{name}-{repo_url_hash:x}")
352}
353
354fn validate_git_commit_hash(commit_hash: &str) -> Result<()> {
355 const LEN: usize = 40;
356 if commit_hash.len() != LEN {
357 bail!(
358 "invalid hash length: expected {}, found {}",
359 LEN,
360 commit_hash.len()
361 );
362 }
363 if !commit_hash.chars().all(|c| c.is_ascii_alphanumeric()) {
364 bail!("hash contains one or more non-ascii-alphanumeric characters");
365 }
366 Ok(())
367}
368
369fn tmp_git_repo_dir(fetch_id: u64, name: &str, repo: &Url) -> PathBuf {
382 let repo_dir_name = format!("{:x}-{}", fetch_id, git_repo_dir_name(name, repo));
383 git_checkouts_directory().join("tmp").join(repo_dir_name)
384}
385
386fn git_ref_to_refspecs(reference: &Reference) -> (Vec<String>, bool) {
390 let mut refspecs = vec![];
391 let mut tags = false;
392 match reference {
393 Reference::Branch(s) => {
394 refspecs.push(format!(
395 "+refs/heads/{s}:refs/remotes/{DEFAULT_REMOTE_NAME}/{s}"
396 ));
397 }
398 Reference::Tag(s) => {
399 refspecs.push(format!(
400 "+refs/tags/{s}:refs/remotes/{DEFAULT_REMOTE_NAME}/tags/{s}"
401 ));
402 }
403 Reference::Rev(s) => {
404 if s.starts_with("refs/") {
405 refspecs.push(format!("+{s}:{s}"));
406 } else {
407 refspecs.push(format!(
410 "+refs/heads/*:refs/remotes/{DEFAULT_REMOTE_NAME}/*"
411 ));
412 refspecs.push(format!("+HEAD:refs/remotes/{DEFAULT_REMOTE_NAME}/HEAD"));
413 tags = true;
414 }
415 }
416 Reference::DefaultBranch => {
417 refspecs.push(format!("+HEAD:refs/remotes/{DEFAULT_REMOTE_NAME}/HEAD"));
418 }
419 }
420 (refspecs, tags)
421}
422
423fn with_tmp_git_repo<F, O>(fetch_id: u64, name: &str, source: &Source, f: F) -> Result<O>
426where
427 F: FnOnce(git2::Repository) -> Result<O>,
428{
429 let repo_dir = tmp_git_repo_dir(fetch_id, name, &source.repo);
431 if repo_dir.exists() {
432 let _ = std::fs::remove_dir_all(&repo_dir);
433 }
434
435 let _cleanup_guard = scopeguard::guard(&repo_dir, |dir| {
438 let _ = std::fs::remove_dir_all(dir);
439 });
440
441 let config = git2::Config::open_default().unwrap();
442
443 let mut auth_handler = auth::AuthHandler::default_with_config(config);
445
446 let mut callback = git2::RemoteCallbacks::new();
448 callback.credentials(move |url, username, allowed| {
449 auth_handler.handle_callback(url, username, allowed)
450 });
451
452 let repo = git2::Repository::init(&repo_dir)
454 .map_err(|e| anyhow!("failed to init repo at \"{}\": {}", repo_dir.display(), e))?;
455
456 let (refspecs, tags) = git_ref_to_refspecs(&source.reference);
458
459 let mut fetch_opts = git2::FetchOptions::new();
461 fetch_opts.remote_callbacks(callback);
462
463 if tags {
464 fetch_opts.download_tags(git2::AutotagOption::All);
465 }
466 let repo_url_string = source.repo.to_string();
467 repo.remote_anonymous(&repo_url_string)?
468 .fetch(&refspecs, Some(&mut fetch_opts), None)
469 .with_context(|| {
470 format!(
471 "failed to fetch `{}`. Check your connection or run in `--offline` mode",
472 &repo_url_string
473 )
474 })?;
475
476 let output = f(repo)?;
478 Ok(output)
479}
480
481pub fn pin(fetch_id: u64, name: &str, source: Source) -> Result<Pinned> {
486 let commit_hash = with_tmp_git_repo(fetch_id, name, &source, |repo| {
487 let commit_id = source
489 .reference
490 .resolve(&repo)
491 .with_context(|| format!("Failed to resolve manifest reference: {source}"))?;
492 Ok(format!("{commit_id}"))
493 })?;
494 Ok(Pinned {
495 source,
496 commit_hash,
497 })
498}
499
500pub fn commit_path(name: &str, repo: &Url, commit_hash: &str) -> PathBuf {
510 let repo_dir_name = git_repo_dir_name(name, repo);
511 git_checkouts_directory()
512 .join(repo_dir_name)
513 .join(commit_hash)
514}
515
516pub fn fetch(fetch_id: u64, name: &str, pinned: &Pinned) -> Result<PathBuf> {
523 let path = commit_path(name, &pinned.source.repo, &pinned.commit_hash);
524 with_tmp_git_repo(fetch_id, name, &pinned.source, |repo| {
526 let id = git2::Oid::from_str(&pinned.commit_hash)?;
528 repo.set_head_detached(id)?;
529
530 if path.exists() {
533 let _ = fs::remove_dir_all(&path);
534 }
535 fs::create_dir_all(&path)?;
536
537 let mut checkout = git2::build::CheckoutBuilder::new();
539 checkout.force().target_dir(&path);
540 repo.checkout_head(Some(&mut checkout))?;
541
542 let current_head = repo.revparse_single("HEAD")?;
544 let head_commit = current_head
545 .as_commit()
546 .ok_or_else(|| anyhow!("Cannot get commit from {}", current_head.id().to_string()))?;
547 let head_time = head_commit.time().seconds();
548 let source_index = SourceIndex::new(
549 head_time,
550 pinned.source.reference.clone(),
551 pinned.commit_hash.clone(),
552 );
553
554 fs::write(
556 path.join(".forc_index"),
557 serde_json::to_string(&source_index)?,
558 )?;
559 Ok(())
560 })?;
561 Ok(path)
562}
563
564pub(crate) fn search_source_locally(
567 name: &str,
568 git_source: &Source,
569) -> Result<Option<(PathBuf, String)>> {
570 let checkouts_dir = git_checkouts_directory();
572 match &git_source.reference {
573 Reference::Branch(branch) => {
574 let repos_from_branch = collect_local_repos_with_branch(checkouts_dir, name, branch)?;
576 let newest_branch_repo = repos_from_branch
578 .into_iter()
579 .max_by_key(|&(_, (_, time))| time)
580 .map(|(repo_path, (hash, _))| (repo_path, hash));
581 Ok(newest_branch_repo)
582 }
583 _ => find_exact_local_repo_with_reference(checkouts_dir, name, &git_source.reference),
584 }
585}
586
587fn collect_local_repos_with_branch(
589 checkouts_dir: PathBuf,
590 package_name: &str,
591 branch_name: &str,
592) -> Result<Vec<(PathBuf, HeadWithTime)>> {
593 let mut list_of_repos = Vec::new();
594 with_search_checkouts(checkouts_dir, package_name, |repo_index, repo_dir_path| {
595 if let Reference::Branch(branch) = repo_index.git_reference {
597 if branch == branch_name {
598 list_of_repos.push((repo_dir_path, repo_index.head_with_time));
599 }
600 }
601 Ok(())
602 })?;
603 Ok(list_of_repos)
604}
605
606fn find_exact_local_repo_with_reference(
608 checkouts_dir: PathBuf,
609 package_name: &str,
610 git_reference: &Reference,
611) -> Result<Option<(PathBuf, String)>> {
612 let mut found_local_repo = None;
613 if let Reference::Tag(tag) = git_reference {
614 found_local_repo = find_repo_with_tag(tag, package_name, checkouts_dir)?;
615 } else if let Reference::Rev(rev) = git_reference {
616 found_local_repo = find_repo_with_rev(rev, package_name, checkouts_dir)?;
617 }
618 Ok(found_local_repo)
619}
620
621fn find_repo_with_tag(
623 tag: &str,
624 package_name: &str,
625 checkouts_dir: PathBuf,
626) -> Result<Option<(PathBuf, String)>> {
627 let mut found_local_repo = None;
628 with_search_checkouts(checkouts_dir, package_name, |repo_index, repo_dir_path| {
629 let current_head = repo_index.head_with_time.0;
631 if let Reference::Tag(curr_repo_tag) = repo_index.git_reference {
632 if curr_repo_tag == tag {
633 found_local_repo = Some((repo_dir_path, current_head));
634 }
635 }
636 Ok(())
637 })?;
638 Ok(found_local_repo)
639}
640
641fn find_repo_with_rev(
643 rev: &str,
644 package_name: &str,
645 checkouts_dir: PathBuf,
646) -> Result<Option<(PathBuf, String)>> {
647 let mut found_local_repo = None;
648 with_search_checkouts(checkouts_dir, package_name, |repo_index, repo_dir_path| {
649 let current_head = repo_index.head_with_time.0;
651 if let Reference::Rev(curr_repo_rev) = repo_index.git_reference {
652 if curr_repo_rev == rev {
653 found_local_repo = Some((repo_dir_path, current_head));
654 }
655 }
656 Ok(())
657 })?;
658 Ok(found_local_repo)
659}
660
661fn with_search_checkouts<F>(checkouts_dir: PathBuf, package_name: &str, mut f: F) -> Result<()>
664where
665 F: FnMut(SourceIndex, PathBuf) -> Result<()>,
666{
667 for entry in fs::read_dir(checkouts_dir)? {
668 let entry = entry?;
669 let folder_name = entry
670 .file_name()
671 .into_string()
672 .map_err(|_| anyhow!("invalid folder name"))?;
673 if folder_name.starts_with(package_name) {
674 for repo_dir in fs::read_dir(entry.path())? {
676 let repo_dir = repo_dir
679 .map_err(|e| anyhow!("Cannot find local repo at checkouts dir {}", e))?;
680 if repo_dir.file_type()?.is_dir() {
681 let repo_dir_path = repo_dir.path();
683 if let Ok(index_file) = fs::read_to_string(repo_dir_path.join(".forc_index")) {
685 let index = serde_json::from_str(&index_file)?;
686 f(index, repo_dir_path)?;
687 }
688 }
689 }
690 }
691 }
692 Ok(())
693}
694
695#[test]
696fn test_source_git_pinned_parsing() {
697 let strings = [
698 "git+https://github.com/foo/bar?branch=baz#64092602dd6158f3e41d775ed889389440a2cd86",
699 "git+https://github.com/fuellabs/sway-lib-std?tag=v0.1.0#0000000000000000000000000000000000000000",
700 "git+https://some-git-host.com/owner/repo?rev#FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",
701 "git+https://some-git-host.com/owner/repo?default-branch#AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
702 ];
703
704 let expected = [
705 Pinned {
706 source: Source {
707 repo: Url::from_str("https://github.com/foo/bar").unwrap(),
708 reference: Reference::Branch("baz".to_string()),
709 },
710 commit_hash: "64092602dd6158f3e41d775ed889389440a2cd86".to_string(),
711 },
712 Pinned {
713 source: Source {
714 repo: Url::from_str("https://github.com/fuellabs/sway-lib-std").unwrap(),
715 reference: Reference::Tag("v0.1.0".to_string()),
716 },
717 commit_hash: "0000000000000000000000000000000000000000".to_string(),
718 },
719 Pinned {
720 source: Source {
721 repo: Url::from_str("https://some-git-host.com/owner/repo").unwrap(),
722 reference: Reference::Rev("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF".to_string()),
723 },
724 commit_hash: "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF".to_string(),
725 },
726 Pinned {
727 source: Source {
728 repo: Url::from_str("https://some-git-host.com/owner/repo").unwrap(),
729 reference: Reference::DefaultBranch,
730 },
731 commit_hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(),
732 },
733 ];
734
735 for (&string, expected) in strings.iter().zip(&expected) {
736 let parsed = Pinned::from_str(string).unwrap();
737 assert_eq!(&parsed, expected);
738 let serialized = expected.to_string();
739 assert_eq!(&serialized, string);
740 }
741}