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