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 {
205 let _guard = lock.write()?;
206 if !repo_path.exists() {
207 println_action_green(
208 "Fetching",
209 &format!("{} {}", ansiterm::Style::new().bold().paint(ctx.name), self),
210 );
211 fetch(ctx.fetch_id(), ctx.name(), self)?;
212 }
213 }
214 let path = {
215 let _guard = lock.read()?;
216 manifest::find_within(repo_path, ctx.name())
217 .ok_or_else(|| anyhow!("failed to find package `{}` in {}", ctx.name(), self))?
218 };
219 PackageManifestFile::from_file(path)
220 }
221}
222
223impl source::DepPath for Pinned {
224 fn dep_path(&self, name: &str) -> anyhow::Result<source::DependencyPath> {
225 let repo_path = commit_path(name, &self.source.repo, &self.commit_hash);
226 let lock = forc_util::path_lock(&repo_path)?;
228 let _guard = lock.read()?;
229 let path = manifest::find_within(&repo_path, name)
230 .ok_or_else(|| anyhow!("failed to find package `{}` in {}", name, self))?;
231 Ok(source::DependencyPath::ManifestPath(path))
232 }
233}
234
235impl fmt::Display for Url {
236 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237 let url_string = self.url.to_bstring().to_string();
238 write!(f, "{url_string}")
239 }
240}
241
242impl fmt::Display for Pinned {
243 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
244 write!(
246 f,
247 "{}+{}?{}#{}",
248 Self::PREFIX,
249 self.source.repo,
250 self.source.reference,
251 self.commit_hash
252 )
253 }
254}
255
256impl fmt::Display for Reference {
257 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
258 match self {
259 Reference::Branch(ref s) => write!(f, "branch={s}"),
260 Reference::Tag(ref s) => write!(f, "tag={s}"),
261 Reference::Rev(ref _s) => write!(f, "rev"),
262 Reference::DefaultBranch => write!(f, "default-branch"),
263 }
264 }
265}
266
267impl FromStr for Url {
268 type Err = anyhow::Error;
269
270 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
271 let url = gix_url::Url::from_bytes(s.as_bytes().into()).map_err(|e| anyhow!("{}", e))?;
272 Ok(Self { url })
273 }
274}
275
276impl FromStr for Pinned {
277 type Err = PinnedParseError;
278 fn from_str(s: &str) -> Result<Self, Self::Err> {
279 let s = s.trim();
281
282 let prefix_plus = format!("{}+", Self::PREFIX);
284 if s.find(&prefix_plus) != Some(0) {
285 return Err(PinnedParseError::Prefix);
286 }
287 let s = &s[prefix_plus.len()..];
288
289 let repo_str = s.split('?').next().ok_or(PinnedParseError::Url)?;
291 let repo = Url::from_str(repo_str).map_err(|_| PinnedParseError::Url)?;
292 let s = &s[repo_str.len() + "?".len()..];
293
294 let mut s_iter = s.split('#');
300 let reference = s_iter.next().ok_or(PinnedParseError::Reference)?;
301 let commit_hash = s_iter
302 .next()
303 .ok_or(PinnedParseError::CommitHash)?
304 .to_string();
305 validate_git_commit_hash(&commit_hash).map_err(|_| PinnedParseError::CommitHash)?;
306
307 const BRANCH: &str = "branch=";
308 const TAG: &str = "tag=";
309 let reference = if reference.find(BRANCH) == Some(0) {
310 Reference::Branch(reference[BRANCH.len()..].to_string())
311 } else if reference.find(TAG) == Some(0) {
312 Reference::Tag(reference[TAG.len()..].to_string())
313 } else if reference == "rev" {
314 Reference::Rev(commit_hash.to_string())
315 } else if reference == "default-branch" {
316 Reference::DefaultBranch
317 } else {
318 return Err(PinnedParseError::Reference);
319 };
320
321 let source = Source { repo, reference };
322 Ok(Self {
323 source,
324 commit_hash,
325 })
326 }
327}
328
329impl Default for Reference {
330 fn default() -> Self {
331 Self::DefaultBranch
332 }
333}
334
335impl From<Pinned> for source::Pinned {
336 fn from(p: Pinned) -> Self {
337 Self::Git(p)
338 }
339}
340
341fn git_repo_dir_name(name: &str, repo: &Url) -> String {
343 use std::hash::{Hash, Hasher};
344 fn hash_url(url: &Url) -> u64 {
345 let mut hasher = hash_map::DefaultHasher::new();
346 url.hash(&mut hasher);
347 hasher.finish()
348 }
349 let repo_url_hash = hash_url(repo);
350 format!("{name}-{repo_url_hash:x}")
351}
352
353fn validate_git_commit_hash(commit_hash: &str) -> Result<()> {
354 const LEN: usize = 40;
355 if commit_hash.len() != LEN {
356 bail!(
357 "invalid hash length: expected {}, found {}",
358 LEN,
359 commit_hash.len()
360 );
361 }
362 if !commit_hash.chars().all(|c| c.is_ascii_alphanumeric()) {
363 bail!("hash contains one or more non-ascii-alphanumeric characters");
364 }
365 Ok(())
366}
367
368fn tmp_git_repo_dir(fetch_id: u64, name: &str, repo: &Url) -> PathBuf {
381 let repo_dir_name = format!("{:x}-{}", fetch_id, git_repo_dir_name(name, repo));
382 git_checkouts_directory().join("tmp").join(repo_dir_name)
383}
384
385fn git_ref_to_refspecs(reference: &Reference) -> (Vec<String>, bool) {
389 let mut refspecs = vec![];
390 let mut tags = false;
391 match reference {
392 Reference::Branch(s) => {
393 refspecs.push(format!(
394 "+refs/heads/{s}:refs/remotes/{DEFAULT_REMOTE_NAME}/{s}"
395 ));
396 }
397 Reference::Tag(s) => {
398 refspecs.push(format!(
399 "+refs/tags/{s}:refs/remotes/{DEFAULT_REMOTE_NAME}/tags/{s}"
400 ));
401 }
402 Reference::Rev(s) => {
403 if s.starts_with("refs/") {
404 refspecs.push(format!("+{s}:{s}"));
405 } else {
406 refspecs.push(format!(
409 "+refs/heads/*:refs/remotes/{DEFAULT_REMOTE_NAME}/*"
410 ));
411 refspecs.push(format!("+HEAD:refs/remotes/{DEFAULT_REMOTE_NAME}/HEAD"));
412 tags = true;
413 }
414 }
415 Reference::DefaultBranch => {
416 refspecs.push(format!("+HEAD:refs/remotes/{DEFAULT_REMOTE_NAME}/HEAD"));
417 }
418 }
419 (refspecs, tags)
420}
421
422fn with_tmp_git_repo<F, O>(fetch_id: u64, name: &str, source: &Source, f: F) -> Result<O>
425where
426 F: FnOnce(git2::Repository) -> Result<O>,
427{
428 let repo_dir = tmp_git_repo_dir(fetch_id, name, &source.repo);
430 if repo_dir.exists() {
431 let _ = std::fs::remove_dir_all(&repo_dir);
432 }
433
434 let config = git2::Config::open_default().unwrap();
435
436 let mut auth_handler = auth::AuthHandler::default_with_config(config);
438
439 let mut callback = git2::RemoteCallbacks::new();
441 callback.credentials(move |url, username, allowed| {
442 auth_handler.handle_callback(url, username, allowed)
443 });
444
445 let repo = git2::Repository::init(&repo_dir)
447 .map_err(|e| anyhow!("failed to init repo at \"{}\": {}", repo_dir.display(), e))?;
448
449 let (refspecs, tags) = git_ref_to_refspecs(&source.reference);
451
452 let mut fetch_opts = git2::FetchOptions::new();
454 fetch_opts.remote_callbacks(callback);
455
456 if tags {
457 fetch_opts.download_tags(git2::AutotagOption::All);
458 }
459 let repo_url_string = source.repo.to_string();
460 repo.remote_anonymous(&repo_url_string)?
461 .fetch(&refspecs, Some(&mut fetch_opts), None)
462 .with_context(|| {
463 format!(
464 "failed to fetch `{}`. Check your connection or run in `--offline` mode",
465 &repo_url_string
466 )
467 })?;
468
469 let output = f(repo)?;
471
472 let _ = std::fs::remove_dir_all(&repo_dir);
474 Ok(output)
475}
476
477pub fn pin(fetch_id: u64, name: &str, source: Source) -> Result<Pinned> {
482 let commit_hash = with_tmp_git_repo(fetch_id, name, &source, |repo| {
483 let commit_id = source
485 .reference
486 .resolve(&repo)
487 .with_context(|| format!("Failed to resolve manifest reference: {source}"))?;
488 Ok(format!("{commit_id}"))
489 })?;
490 Ok(Pinned {
491 source,
492 commit_hash,
493 })
494}
495
496pub fn commit_path(name: &str, repo: &Url, commit_hash: &str) -> PathBuf {
506 let repo_dir_name = git_repo_dir_name(name, repo);
507 git_checkouts_directory()
508 .join(repo_dir_name)
509 .join(commit_hash)
510}
511
512pub fn fetch(fetch_id: u64, name: &str, pinned: &Pinned) -> Result<PathBuf> {
519 let path = commit_path(name, &pinned.source.repo, &pinned.commit_hash);
520 with_tmp_git_repo(fetch_id, name, &pinned.source, |repo| {
522 let id = git2::Oid::from_str(&pinned.commit_hash)?;
524 repo.set_head_detached(id)?;
525
526 if path.exists() {
529 let _ = fs::remove_dir_all(&path);
530 }
531 fs::create_dir_all(&path)?;
532
533 let mut checkout = git2::build::CheckoutBuilder::new();
535 checkout.force().target_dir(&path);
536 repo.checkout_head(Some(&mut checkout))?;
537
538 let current_head = repo.revparse_single("HEAD")?;
540 let head_commit = current_head
541 .as_commit()
542 .ok_or_else(|| anyhow!("Cannot get commit from {}", current_head.id().to_string()))?;
543 let head_time = head_commit.time().seconds();
544 let source_index = SourceIndex::new(
545 head_time,
546 pinned.source.reference.clone(),
547 pinned.commit_hash.clone(),
548 );
549
550 fs::write(
552 path.join(".forc_index"),
553 serde_json::to_string(&source_index)?,
554 )?;
555 Ok(())
556 })?;
557 Ok(path)
558}
559
560pub(crate) fn search_source_locally(
563 name: &str,
564 git_source: &Source,
565) -> Result<Option<(PathBuf, String)>> {
566 let checkouts_dir = git_checkouts_directory();
568 match &git_source.reference {
569 Reference::Branch(branch) => {
570 let repos_from_branch = collect_local_repos_with_branch(checkouts_dir, name, branch)?;
572 let newest_branch_repo = repos_from_branch
574 .into_iter()
575 .max_by_key(|&(_, (_, time))| time)
576 .map(|(repo_path, (hash, _))| (repo_path, hash));
577 Ok(newest_branch_repo)
578 }
579 _ => find_exact_local_repo_with_reference(checkouts_dir, name, &git_source.reference),
580 }
581}
582
583fn collect_local_repos_with_branch(
585 checkouts_dir: PathBuf,
586 package_name: &str,
587 branch_name: &str,
588) -> Result<Vec<(PathBuf, HeadWithTime)>> {
589 let mut list_of_repos = Vec::new();
590 with_search_checkouts(checkouts_dir, package_name, |repo_index, repo_dir_path| {
591 if let Reference::Branch(branch) = repo_index.git_reference {
593 if branch == branch_name {
594 list_of_repos.push((repo_dir_path, repo_index.head_with_time));
595 }
596 }
597 Ok(())
598 })?;
599 Ok(list_of_repos)
600}
601
602fn find_exact_local_repo_with_reference(
604 checkouts_dir: PathBuf,
605 package_name: &str,
606 git_reference: &Reference,
607) -> Result<Option<(PathBuf, String)>> {
608 let mut found_local_repo = None;
609 if let Reference::Tag(tag) = git_reference {
610 found_local_repo = find_repo_with_tag(tag, package_name, checkouts_dir)?;
611 } else if let Reference::Rev(rev) = git_reference {
612 found_local_repo = find_repo_with_rev(rev, package_name, checkouts_dir)?;
613 }
614 Ok(found_local_repo)
615}
616
617fn find_repo_with_tag(
619 tag: &str,
620 package_name: &str,
621 checkouts_dir: PathBuf,
622) -> Result<Option<(PathBuf, String)>> {
623 let mut found_local_repo = None;
624 with_search_checkouts(checkouts_dir, package_name, |repo_index, repo_dir_path| {
625 let current_head = repo_index.head_with_time.0;
627 if let Reference::Tag(curr_repo_tag) = repo_index.git_reference {
628 if curr_repo_tag == tag {
629 found_local_repo = Some((repo_dir_path, current_head));
630 }
631 }
632 Ok(())
633 })?;
634 Ok(found_local_repo)
635}
636
637fn find_repo_with_rev(
639 rev: &str,
640 package_name: &str,
641 checkouts_dir: PathBuf,
642) -> Result<Option<(PathBuf, String)>> {
643 let mut found_local_repo = None;
644 with_search_checkouts(checkouts_dir, package_name, |repo_index, repo_dir_path| {
645 let current_head = repo_index.head_with_time.0;
647 if let Reference::Rev(curr_repo_rev) = repo_index.git_reference {
648 if curr_repo_rev == rev {
649 found_local_repo = Some((repo_dir_path, current_head));
650 }
651 }
652 Ok(())
653 })?;
654 Ok(found_local_repo)
655}
656
657fn with_search_checkouts<F>(checkouts_dir: PathBuf, package_name: &str, mut f: F) -> Result<()>
660where
661 F: FnMut(SourceIndex, PathBuf) -> Result<()>,
662{
663 for entry in fs::read_dir(checkouts_dir)? {
664 let entry = entry?;
665 let folder_name = entry
666 .file_name()
667 .into_string()
668 .map_err(|_| anyhow!("invalid folder name"))?;
669 if folder_name.starts_with(package_name) {
670 for repo_dir in fs::read_dir(entry.path())? {
672 let repo_dir = repo_dir
675 .map_err(|e| anyhow!("Cannot find local repo at checkouts dir {}", e))?;
676 if repo_dir.file_type()?.is_dir() {
677 let repo_dir_path = repo_dir.path();
679 if let Ok(index_file) = fs::read_to_string(repo_dir_path.join(".forc_index")) {
681 let index = serde_json::from_str(&index_file)?;
682 f(index, repo_dir_path)?;
683 }
684 }
685 }
686 }
687 }
688 Ok(())
689}
690
691#[test]
692fn test_source_git_pinned_parsing() {
693 let strings = [
694 "git+https://github.com/foo/bar?branch=baz#64092602dd6158f3e41d775ed889389440a2cd86",
695 "git+https://github.com/fuellabs/sway-lib-std?tag=v0.1.0#0000000000000000000000000000000000000000",
696 "git+https://some-git-host.com/owner/repo?rev#FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",
697 "git+https://some-git-host.com/owner/repo?default-branch#AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
698 ];
699
700 let expected = [
701 Pinned {
702 source: Source {
703 repo: Url::from_str("https://github.com/foo/bar").unwrap(),
704 reference: Reference::Branch("baz".to_string()),
705 },
706 commit_hash: "64092602dd6158f3e41d775ed889389440a2cd86".to_string(),
707 },
708 Pinned {
709 source: Source {
710 repo: Url::from_str("https://github.com/fuellabs/sway-lib-std").unwrap(),
711 reference: Reference::Tag("v0.1.0".to_string()),
712 },
713 commit_hash: "0000000000000000000000000000000000000000".to_string(),
714 },
715 Pinned {
716 source: Source {
717 repo: Url::from_str("https://some-git-host.com/owner/repo").unwrap(),
718 reference: Reference::Rev("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF".to_string()),
719 },
720 commit_hash: "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF".to_string(),
721 },
722 Pinned {
723 source: Source {
724 repo: Url::from_str("https://some-git-host.com/owner/repo").unwrap(),
725 reference: Reference::DefaultBranch,
726 },
727 commit_hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(),
728 },
729 ];
730
731 for (&string, expected) in strings.iter().zip(&expected) {
732 let parsed = Pinned::from_str(string).unwrap();
733 assert_eq!(&parsed, expected);
734 let serialized = expected.to_string();
735 assert_eq!(&serialized, string);
736 }
737}