protofetch 0.1.21

A source dependency management tool for Protobuf.
Documentation
use std::path::{Path, PathBuf};

use git2::{
    cert::Cert, AutotagOption, CertificateCheckStatus, Config, Cred, CredentialType, FetchOptions,
    Oid, RemoteCallbacks, Repository, ResetType, WorktreeAddOptions,
};
use log::{debug, info, trace};
use ssh_key::{known_hosts::HostPatterns, KnownHosts};

use super::{error::GitBackendError, types::GitOid, GitBackend, GitRepository, WorktreeResult};

const GLOBAL_KNOWN_HOSTS: &str = "/etc/ssh/ssh_known_hosts";

pub struct Libgit2Backend;

impl Libgit2Backend {
    pub fn new() -> Self {
        Self
    }
}

pub struct Libgit2Repository {
    repo_path: PathBuf,
    git_config: Config,
}

impl Libgit2Repository {
    fn fetch_options(&self) -> FetchOptions<'_> {
        let mut callbacks = RemoteCallbacks::new();

        let mut tried_username = false;
        let mut tried_agent = false;
        let mut tried_helper = false;

        // Consider using https://crates.io/crates/git2_credentials that supports
        // more authentication options
        callbacks.credentials(move |url, username, allowed_types| {
            trace!(
                "Requested credentials for {}, username {:?}, allowed types {:?}",
                url,
                username,
                allowed_types
            );
            // Asking for ssh username
            if allowed_types.contains(CredentialType::USERNAME) && !tried_username {
                tried_username = true;
                return Cred::username("git");
            }
            // SSH auth
            if allowed_types.contains(CredentialType::SSH_KEY) && !tried_agent {
                tried_agent = true;
                return Cred::ssh_key_from_agent(username.unwrap_or("git"));
            }
            // HTTP auth
            if allowed_types.contains(CredentialType::USER_PASS_PLAINTEXT) && !tried_helper {
                tried_helper = true;
                return Cred::credential_helper(&self.git_config, url, username);
            }
            Err(git2::Error::from_str("no valid authentication available"))
        });

        callbacks.certificate_check(check_certificate);

        let mut fetch_options = FetchOptions::new();
        fetch_options
            .remote_callbacks(callbacks)
            .download_tags(AutotagOption::None);

        fetch_options
    }
}

impl GitRepository for Libgit2Repository {
    fn remote_add(&self, name: &str, url: &str) -> Result<(), GitBackendError> {
        let repo = Repository::open(&self.repo_path)?;
        repo.remote_with_fetch(name, url, "")?;
        Ok(())
    }

    fn remote_get_url(&self, name: &str) -> Result<Option<String>, GitBackendError> {
        let repo = Repository::open(&self.repo_path)?;
        let result = repo.find_remote(name);
        match result {
            Ok(remote) => {
                let url = remote.url().map(|s| s.to_string());
                Ok(url.into_option())
            }
            Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
            Err(e) => Err(e.into()),
        }
    }

    fn remote_set_url(&self, name: &str, url: &str) -> Result<(), GitBackendError> {
        let repo = Repository::open(&self.repo_path)?;
        repo.remote_set_url(name, url)?;
        Ok(())
    }

    fn fetch(&self, remote_name: &str, refspecs: &[String]) -> Result<(), GitBackendError> {
        let repo = Repository::open(&self.repo_path)?;
        let mut remote = repo.find_remote(remote_name)?;
        debug!("Fetching {:?} from {}", refspecs, self.repo_path.display());
        remote.fetch(refspecs, Some(&mut self.fetch_options()), None)?;
        Ok(())
    }

    fn commit_exists(&self, oid: &str) -> Result<bool, GitBackendError> {
        let repo = Repository::open(&self.repo_path)?;
        let oid = Oid::from_str(oid).map_err(|e| GitBackendError::InvalidRef(e.to_string()))?;
        let exists = repo.find_commit(oid).is_ok();
        Ok(exists)
    }

    fn revparse_commit(&self, spec: &str) -> Result<GitOid, GitBackendError> {
        let repo = Repository::open(&self.repo_path)?;
        let obj = repo.revparse_single(spec)?;
        let commit = obj.peel_to_commit()?;
        Ok(GitOid::from(commit.id()))
    }

    fn read_blob(&self, commit: &str, blob_path: &str) -> Result<Option<Vec<u8>>, GitBackendError> {
        let repo = Repository::open(&self.repo_path)?;
        let spec = format!("{commit}:{blob_path}");
        let result = repo.revparse_single(&spec);
        match result {
            Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
            Err(e) => Err(e.into()),
            Ok(obj) => match obj.kind() {
                Some(git2::ObjectType::Blob) => {
                    let blob = obj.peel_to_blob()?;
                    let content = blob.content().to_vec();
                    Ok(Some(content))
                }
                Some(kind) => Err(GitBackendError::GitError(format!(
                    "Bad git object kind {} found for {} (expected blob)",
                    kind, spec
                ))),
                None => Err(GitBackendError::NotFound(format!(
                    "Missing object for {}",
                    spec
                ))),
            },
        }
    }

    fn is_ancestor(&self, ancestor: &GitOid, descendant: &GitOid) -> Result<bool, GitBackendError> {
        let repo = Repository::open(&self.repo_path)?;
        let a = Oid::from_str(ancestor.as_str())
            .map_err(|e| GitBackendError::InvalidRef(e.to_string()))?;
        let b = Oid::from_str(descendant.as_str())
            .map_err(|e| GitBackendError::InvalidRef(e.to_string()))?;
        match repo.merge_base(a, b) {
            Ok(merge_base) => Ok(merge_base == a),
            Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(false),
            Err(e) => Err(e.into()),
        }
    }

    fn create_worktree(
        &self,
        name: &str,
        worktree_path: &Path,
        commit: &str,
    ) -> Result<WorktreeResult, GitBackendError> {
        let repo = Repository::open(&self.repo_path)?;

        match repo.find_worktree(name) {
            Ok(worktree) => {
                let canonical_existing = worktree.path().canonicalize().map_err(|e| {
                    GitBackendError::IO(std::io::Error::new(
                        e.kind(),
                        format!(
                            "Error while canonicalizing path {}: {}",
                            worktree.path().display(),
                            e
                        ),
                    ))
                })?;
                Ok(WorktreeResult::Existing(
                    canonical_existing,
                    Box::new(Libgit2Repository {
                        repo_path: worktree.path().to_path_buf(),
                        git_config: Config::open_default()?,
                    }),
                ))
            }
            Err(_) => {
                info!(
                    "Creating new worktree {} at {}",
                    name,
                    worktree_path.display()
                );

                // We need to create a branch-like reference to be able to create a worktree
                let reference = repo.reference(
                    &format!("refs/heads/{}", commit),
                    repo.revparse_single(commit)?.id(),
                    true,
                    "",
                )?;

                let mut options = WorktreeAddOptions::new();
                options.reference(Some(&reference));
                repo.worktree(name, worktree_path, Some(&options))?;

                Ok(WorktreeResult::Created(Box::new(Libgit2Repository {
                    repo_path: worktree_path.to_path_buf(),
                    git_config: Config::open_default()?,
                })))
            }
        }
    }

    fn reset(&self, commit: &str) -> Result<(), GitBackendError> {
        let repo = Repository::open(&self.repo_path)?;
        let obj = repo.revparse_single(commit)?;
        repo.reset(&obj, ResetType::Hard, None)?;
        Ok(())
    }
}

impl GitBackend for Libgit2Backend {
    fn init_bare(&self, path: &Path) -> Result<Box<dyn GitRepository>, GitBackendError> {
        trace!("Creating a new bare repository at {}", path.display());
        Repository::init_bare(path)?;
        Ok(Box::new(Libgit2Repository {
            repo_path: path.to_path_buf(),
            git_config: Config::open_default()?,
        }))
    }

    fn open(&self, path: &Path) -> Result<Box<dyn GitRepository>, GitBackendError> {
        trace!("Opening existing repository at {}", path.display());
        Repository::open_bare(path)?;
        Ok(Box::new(Libgit2Repository {
            repo_path: path.to_path_buf(),
            git_config: Config::open_default()?,
        }))
    }
}

fn check_certificate(
    certificate: &Cert<'_>,
    host: &str,
) -> Result<CertificateCheckStatus, git2::Error> {
    if let Some(hostkey) = certificate.as_hostkey().and_then(|h| h.hostkey()) {
        trace!("Loading {}", GLOBAL_KNOWN_HOSTS);
        match KnownHosts::read_file(GLOBAL_KNOWN_HOSTS) {
            Ok(entries) => {
                for entry in entries {
                    if host_matches_patterns(host, entry.host_patterns()) {
                        trace!(
                            "Found known host entry for {} ({})",
                            host,
                            entry.public_key().algorithm()
                        );
                        if entry.public_key().to_bytes().as_deref() == Ok(hostkey) {
                            trace!("Known host entry matches the host key");
                            return Ok(CertificateCheckStatus::CertificateOk);
                        }
                    }
                }
                trace!("No known host entry matched the host key");
            }
            Err(error) => trace!("Could not load {}: {}", GLOBAL_KNOWN_HOSTS, error),
        }
    }
    Ok(CertificateCheckStatus::CertificatePassthrough)
}

fn host_matches_patterns(host: &str, patterns: &HostPatterns) -> bool {
    match patterns {
        HostPatterns::Patterns(patterns) => {
            let mut match_found = false;
            for pattern in patterns {
                let pattern = pattern.to_lowercase();
                // * and ? wildcards are not yet supported
                if let Some(pattern) = pattern.strip_prefix('!') {
                    if pattern == host {
                        return false;
                    }
                } else {
                    match_found |= pattern == host;
                }
            }
            match_found
        }
        // Not yet supported
        HostPatterns::HashedName { .. } => false,
    }
}

impl From<git2::Oid> for GitOid {
    fn from(oid: git2::Oid) -> Self {
        Self::from_hex(oid.to_string())
    }
}

impl From<git2::Error> for GitBackendError {
    fn from(e: git2::Error) -> Self {
        match e.code() {
            git2::ErrorCode::NotFound => GitBackendError::NotFound(e.message().to_string()),
            git2::ErrorCode::Auth => GitBackendError::AuthError(e.message().to_string()),
            _ => GitBackendError::GitError(e.message().to_string()),
        }
    }
}

// Starting from git2 0.21, some methods return Result<T, git2::Error> that previously used to return Option<T>.
// This is a compatibility layer to be able to keep a broad git2 dependency range.
trait Git2ResultCompat<T> {
    fn into_option(self) -> Option<T>;
}

impl<T> Git2ResultCompat<T> for Result<T, git2::Error> {
    fn into_option(self) -> Option<T> {
        self.ok()
    }
}

impl<T> Git2ResultCompat<T> for Option<T> {
    fn into_option(self) -> Option<T> {
        self
    }
}