use crate::core::GitReference;
use crate::util::errors::{CargoResult, CargoResultExt};
use crate::util::paths;
use crate::util::process_builder::process;
use crate::util::{internal, network, Config, IntoUrl, Progress};
use curl::easy::{Easy, List};
use git2::{self, ObjectType};
use log::{debug, info};
use serde::ser;
use serde::Serialize;
use std::env;
use std::fmt;
use std::fs::File;
use std::mem;
use std::path::{Path, PathBuf};
use std::process::Command;
use url::Url;
#[derive(PartialEq, Clone, Debug)]
pub struct GitRevision(git2::Oid);
impl ser::Serialize for GitRevision {
fn serialize<S: ser::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
serialize_str(self, s)
}
}
fn serialize_str<T, S>(t: &T, s: S) -> Result<S::Ok, S::Error>
where
T: fmt::Display,
S: ser::Serializer,
{
s.collect_str(t)
}
impl fmt::Display for GitRevision {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
pub struct GitShortID(git2::Buf);
impl GitShortID {
pub fn as_str(&self) -> &str {
self.0.as_str().unwrap()
}
}
#[derive(PartialEq, Clone, Debug, Serialize)]
pub struct GitRemote {
#[serde(serialize_with = "serialize_str")]
url: Url,
}
#[derive(Serialize)]
pub struct GitDatabase {
remote: GitRemote,
path: PathBuf,
#[serde(skip_serializing)]
repo: git2::Repository,
}
#[derive(Serialize)]
pub struct GitCheckout<'a> {
database: &'a GitDatabase,
location: PathBuf,
revision: GitRevision,
#[serde(skip_serializing)]
repo: git2::Repository,
}
impl GitRemote {
pub fn new(url: &Url) -> GitRemote {
GitRemote { url: url.clone() }
}
pub fn url(&self) -> &Url {
&self.url
}
pub fn rev_for(&self, path: &Path, reference: &GitReference) -> CargoResult<GitRevision> {
reference.resolve(&self.db_at(path)?.repo)
}
pub fn checkout(
&self,
into: &Path,
reference: &GitReference,
cargo_config: &Config,
) -> CargoResult<(GitDatabase, GitRevision)> {
let mut repo_and_rev = None;
if let Ok(mut repo) = git2::Repository::open(into) {
self.fetch_into(&mut repo, cargo_config)
.chain_err(|| format!("failed to fetch into {}", into.display()))?;
if let Ok(rev) = reference.resolve(&repo) {
repo_and_rev = Some((repo, rev));
}
}
let (repo, rev) = match repo_and_rev {
Some(pair) => pair,
None => {
let repo = self
.clone_into(into, cargo_config)
.chain_err(|| format!("failed to clone into: {}", into.display()))?;
let rev = reference.resolve(&repo)?;
(repo, rev)
}
};
Ok((
GitDatabase {
remote: self.clone(),
path: into.to_path_buf(),
repo,
},
rev,
))
}
pub fn db_at(&self, db_path: &Path) -> CargoResult<GitDatabase> {
let repo = git2::Repository::open(db_path)?;
Ok(GitDatabase {
remote: self.clone(),
path: db_path.to_path_buf(),
repo,
})
}
fn fetch_into(&self, dst: &mut git2::Repository, cargo_config: &Config) -> CargoResult<()> {
let refspec = "refs/heads/*:refs/heads/*";
fetch(dst, self.url.as_str(), refspec, cargo_config)
}
fn clone_into(&self, dst: &Path, cargo_config: &Config) -> CargoResult<git2::Repository> {
if dst.exists() {
paths::remove_dir_all(dst)?;
}
paths::create_dir_all(dst)?;
let mut repo = init(dst, true)?;
fetch(
&mut repo,
self.url.as_str(),
"refs/heads/*:refs/heads/*",
cargo_config,
)?;
Ok(repo)
}
}
impl GitDatabase {
pub fn copy_to(
&self,
rev: GitRevision,
dest: &Path,
cargo_config: &Config,
) -> CargoResult<GitCheckout<'_>> {
let mut checkout = None;
if let Ok(repo) = git2::Repository::open(dest) {
let mut co = GitCheckout::new(dest, self, rev.clone(), repo);
if !co.is_fresh() {
co.fetch(cargo_config)?;
if co.has_object() {
co.reset(cargo_config)?;
assert!(co.is_fresh());
checkout = Some(co);
}
} else {
checkout = Some(co);
}
};
let checkout = match checkout {
Some(c) => c,
None => GitCheckout::clone_into(dest, self, rev, cargo_config)?,
};
checkout.update_submodules(cargo_config)?;
Ok(checkout)
}
pub fn to_short_id(&self, revision: &GitRevision) -> CargoResult<GitShortID> {
let obj = self.repo.find_object(revision.0, None)?;
Ok(GitShortID(obj.short_id()?))
}
pub fn has_ref(&self, reference: &str) -> CargoResult<()> {
self.repo.revparse_single(reference)?;
Ok(())
}
}
impl GitReference {
fn resolve(&self, repo: &git2::Repository) -> CargoResult<GitRevision> {
let id = match *self {
GitReference::Tag(ref s) => (|| -> CargoResult<git2::Oid> {
let refname = format!("refs/tags/{}", s);
let id = repo.refname_to_id(&refname)?;
let obj = repo.find_object(id, None)?;
let obj = obj.peel(ObjectType::Commit)?;
Ok(obj.id())
})()
.chain_err(|| format!("failed to find tag `{}`", s))?,
GitReference::Branch(ref s) => {
let b = repo
.find_branch(s, git2::BranchType::Local)
.chain_err(|| format!("failed to find branch `{}`", s))?;
b.get()
.target()
.ok_or_else(|| failure::format_err!("branch `{}` did not have a target", s))?
}
GitReference::Rev(ref s) => {
let obj = repo.revparse_single(s)?;
match obj.as_tag() {
Some(tag) => tag.target_id(),
None => obj.id(),
}
}
};
Ok(GitRevision(id))
}
}
impl<'a> GitCheckout<'a> {
fn new(
path: &Path,
database: &'a GitDatabase,
revision: GitRevision,
repo: git2::Repository,
) -> GitCheckout<'a> {
GitCheckout {
location: path.to_path_buf(),
database,
revision,
repo,
}
}
fn clone_into(
into: &Path,
database: &'a GitDatabase,
revision: GitRevision,
config: &Config,
) -> CargoResult<GitCheckout<'a>> {
let dirname = into.parent().unwrap();
paths::create_dir_all(&dirname)?;
if into.exists() {
paths::remove_dir_all(into)?;
}
let git_config = git2::Config::new()?;
let url = database.path.into_url()?;
let mut repo = None;
with_fetch_options(&git_config, url.as_str(), config, &mut |fopts| {
let mut checkout = git2::build::CheckoutBuilder::new();
checkout.dry_run();
let r = git2::build::RepoBuilder::new()
.clone_local(git2::build::CloneLocal::Local)
.with_checkout(checkout)
.fetch_options(fopts)
.clone(url.as_str(), into)?;
repo = Some(r);
Ok(())
})?;
let repo = repo.unwrap();
let checkout = GitCheckout::new(into, database, revision, repo);
checkout.reset(config)?;
Ok(checkout)
}
fn is_fresh(&self) -> bool {
match self.repo.revparse_single("HEAD") {
Ok(ref head) if head.id() == self.revision.0 => {
self.location.join(".cargo-ok").exists()
}
_ => false,
}
}
fn fetch(&mut self, cargo_config: &Config) -> CargoResult<()> {
info!("fetch {}", self.repo.path().display());
let url = self.database.path.into_url()?;
let refspec = "refs/heads/*:refs/heads/*";
fetch(&mut self.repo, url.as_str(), refspec, cargo_config)?;
Ok(())
}
fn has_object(&self) -> bool {
self.repo.find_object(self.revision.0, None).is_ok()
}
fn reset(&self, config: &Config) -> CargoResult<()> {
let ok_file = self.location.join(".cargo-ok");
let _ = paths::remove_file(&ok_file);
info!("reset {} to {}", self.repo.path().display(), self.revision);
let object = self.repo.find_object(self.revision.0, None)?;
reset(&self.repo, &object, config)?;
File::create(ok_file)?;
Ok(())
}
fn update_submodules(&self, cargo_config: &Config) -> CargoResult<()> {
return update_submodules(&self.repo, cargo_config);
fn update_submodules(repo: &git2::Repository, cargo_config: &Config) -> CargoResult<()> {
info!("update submodules for: {:?}", repo.workdir().unwrap());
for mut child in repo.submodules()? {
update_submodule(repo, &mut child, cargo_config).chain_err(|| {
format!(
"failed to update submodule `{}`",
child.name().unwrap_or("")
)
})?;
}
Ok(())
}
fn update_submodule(
parent: &git2::Repository,
child: &mut git2::Submodule<'_>,
cargo_config: &Config,
) -> CargoResult<()> {
child.init(false)?;
let url = child
.url()
.ok_or_else(|| internal("non-utf8 url for submodule"))?;
let head = match child.head_id() {
Some(head) => head,
None => return Ok(()),
};
let head_and_repo = child.open().and_then(|repo| {
let target = repo.head()?.target();
Ok((target, repo))
});
let mut repo = match head_and_repo {
Ok((head, repo)) => {
if child.head_id() == head {
return update_submodules(&repo, cargo_config);
}
repo
}
Err(..) => {
let path = parent.workdir().unwrap().join(child.path());
let _ = paths::remove_dir_all(&path);
init(&path, false)?
}
};
let refspec = "refs/heads/*:refs/heads/*";
fetch(&mut repo, url, refspec, cargo_config).chain_err(|| {
internal(format!(
"failed to fetch submodule `{}` from {}",
child.name().unwrap_or(""),
url
))
})?;
let obj = repo.find_object(head, None)?;
reset(&repo, &obj, cargo_config)?;
update_submodules(&repo, cargo_config)
}
}
}
fn with_authentication<T, F>(url: &str, cfg: &git2::Config, mut f: F) -> CargoResult<T>
where
F: FnMut(&mut git2::Credentials<'_>) -> CargoResult<T>,
{
let mut cred_helper = git2::CredentialHelper::new(url);
cred_helper.config(cfg);
let mut ssh_username_requested = false;
let mut cred_helper_bad = None;
let mut ssh_agent_attempts = Vec::new();
let mut any_attempts = false;
let mut tried_sshkey = false;
let mut res = f(&mut |url, username, allowed| {
any_attempts = true;
if allowed.contains(git2::CredentialType::USERNAME) {
debug_assert!(username.is_none());
ssh_username_requested = true;
return Err(git2::Error::from_str("gonna try usernames later"));
}
if allowed.contains(git2::CredentialType::SSH_KEY) && !tried_sshkey {
tried_sshkey = true;
let username = username.unwrap();
debug_assert!(!ssh_username_requested);
ssh_agent_attempts.push(username.to_string());
return git2::Cred::ssh_key_from_agent(username);
}
if allowed.contains(git2::CredentialType::USER_PASS_PLAINTEXT) && cred_helper_bad.is_none()
{
let r = git2::Cred::credential_helper(cfg, url, username);
cred_helper_bad = Some(r.is_err());
return r;
}
if allowed.contains(git2::CredentialType::DEFAULT) {
return git2::Cred::default();
}
Err(git2::Error::from_str("no authentication available"))
});
if ssh_username_requested {
debug_assert!(res.is_err());
let mut attempts = Vec::new();
attempts.push("git".to_string());
if let Ok(s) = env::var("USER").or_else(|_| env::var("USERNAME")) {
attempts.push(s);
}
if let Some(ref s) = cred_helper.username {
attempts.push(s.clone());
}
while let Some(s) = attempts.pop() {
let mut attempts = 0;
res = f(&mut |_url, username, allowed| {
if allowed.contains(git2::CredentialType::USERNAME) {
return git2::Cred::username(&s);
}
if allowed.contains(git2::CredentialType::SSH_KEY) {
debug_assert_eq!(Some(&s[..]), username);
attempts += 1;
if attempts == 1 {
ssh_agent_attempts.push(s.to_string());
return git2::Cred::ssh_key_from_agent(&s);
}
}
Err(git2::Error::from_str("no authentication available"))
});
if attempts != 2 {
break;
}
}
}
if res.is_ok() || !any_attempts {
return res.map_err(From::from);
}
let res = res.map_err(failure::Error::from).chain_err(|| {
let mut msg = "failed to authenticate when downloading \
repository"
.to_string();
if !ssh_agent_attempts.is_empty() {
let names = ssh_agent_attempts
.iter()
.map(|s| format!("`{}`", s))
.collect::<Vec<_>>()
.join(", ");
msg.push_str(&format!(
"\nattempted ssh-agent authentication, but \
none of the usernames {} succeeded",
names
));
}
if let Some(failed_cred_helper) = cred_helper_bad {
if failed_cred_helper {
msg.push_str(
"\nattempted to find username/password via \
git's `credential.helper` support, but failed",
);
} else {
msg.push_str(
"\nattempted to find username/password via \
`credential.helper`, but maybe the found \
credentials were incorrect",
);
}
}
msg
})?;
Ok(res)
}
fn reset(repo: &git2::Repository, obj: &git2::Object<'_>, config: &Config) -> CargoResult<()> {
let mut pb = Progress::new("Checkout", config);
let mut opts = git2::build::CheckoutBuilder::new();
opts.progress(|_, cur, max| {
drop(pb.tick(cur, max));
});
repo.reset(obj, git2::ResetType::Hard, Some(&mut opts))?;
Ok(())
}
pub fn with_fetch_options(
git_config: &git2::Config,
url: &str,
config: &Config,
cb: &mut dyn FnMut(git2::FetchOptions<'_>) -> CargoResult<()>,
) -> CargoResult<()> {
let mut progress = Progress::new("Fetch", config);
network::with_retry(config, || {
with_authentication(url, git_config, |f| {
let mut rcb = git2::RemoteCallbacks::new();
rcb.credentials(f);
rcb.transfer_progress(|stats| {
progress
.tick(stats.indexed_objects(), stats.total_objects())
.is_ok()
});
let mut opts = git2::FetchOptions::new();
opts.remote_callbacks(rcb)
.download_tags(git2::AutotagOption::All);
cb(opts)
})?;
Ok(())
})
}
pub fn fetch(
repo: &mut git2::Repository,
url: &str,
refspec: &str,
config: &Config,
) -> CargoResult<()> {
if config.frozen() {
failure::bail!(
"attempting to update a git repository, but --frozen \
was specified"
)
}
if !config.network_allowed() {
failure::bail!("can't update a git repository in the offline mode")
}
if let Ok(url) = Url::parse(url) {
if url.host_str() == Some("github.com") {
if let Ok(oid) = repo.refname_to_id("refs/remotes/origin/master") {
let mut handle = config.http()?.borrow_mut();
debug!("attempting GitHub fast path for {}", url);
if github_up_to_date(&mut handle, &url, &oid) {
return Ok(());
} else {
debug!("fast path failed, falling back to a git fetch");
}
}
}
}
maybe_gc_repo(repo)?;
if let Some(true) = config.net_config()?.git_fetch_with_cli {
return fetch_with_cli(repo, url, refspec, config);
}
debug!("doing a fetch for {}", url);
let git_config = git2::Config::open_default()?;
with_fetch_options(&git_config, url, config, &mut |mut opts| {
let mut repo_reinitialized = false;
loop {
debug!("initiating fetch of {} from {}", refspec, url);
let res = repo
.remote_anonymous(url)?
.fetch(&[refspec], Some(&mut opts), None);
let err = match res {
Ok(()) => break,
Err(e) => e,
};
debug!("fetch failed: {}", err);
if !repo_reinitialized && err.class() == git2::ErrorClass::Reference {
repo_reinitialized = true;
debug!(
"looks like this is a corrupt repository, reinitializing \
and trying again"
);
if reinitialize(repo).is_ok() {
continue;
}
}
return Err(err.into());
}
Ok(())
})
}
fn fetch_with_cli(
repo: &mut git2::Repository,
url: &str,
refspec: &str,
config: &Config,
) -> CargoResult<()> {
let mut cmd = process("git");
cmd.arg("fetch")
.arg("--tags")
.arg("--force")
.arg("--update-head-ok")
.arg(url)
.arg(refspec)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_OBJECT_DIRECTORY")
.env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES")
.cwd(repo.path());
config
.shell()
.verbose(|s| s.status("Running", &cmd.to_string()))?;
cmd.exec_with_output()?;
Ok(())
}
fn maybe_gc_repo(repo: &mut git2::Repository) -> CargoResult<()> {
let entries = match repo.path().join("objects/pack").read_dir() {
Ok(e) => e.count(),
Err(_) => {
debug!("skipping gc as pack dir appears gone");
return Ok(());
}
};
let max = env::var("__CARGO_PACKFILE_LIMIT")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(100);
if entries < max {
debug!("skipping gc as there's only {} pack files", entries);
return Ok(());
}
match Command::new("git")
.arg("gc")
.current_dir(repo.path())
.output()
{
Ok(out) => {
debug!(
"git-gc status: {}\n\nstdout ---\n{}\nstderr ---\n{}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
if out.status.success() {
let new = git2::Repository::open(repo.path())?;
mem::replace(repo, new);
return Ok(());
}
}
Err(e) => debug!("git-gc failed to spawn: {}", e),
}
reinitialize(repo)
}
fn reinitialize(repo: &mut git2::Repository) -> CargoResult<()> {
let path = repo.path().to_path_buf();
debug!("reinitializing git repo at {:?}", path);
let tmp = path.join("tmp");
let bare = !repo.path().ends_with(".git");
*repo = init(&tmp, false)?;
for entry in path.read_dir()? {
let entry = entry?;
if entry.file_name().to_str() == Some("tmp") {
continue;
}
let path = entry.path();
drop(paths::remove_file(&path).or_else(|_| paths::remove_dir_all(&path)));
}
*repo = init(&path, bare)?;
paths::remove_dir_all(&tmp)?;
Ok(())
}
fn init(path: &Path, bare: bool) -> CargoResult<git2::Repository> {
let mut opts = git2::RepositoryInitOptions::new();
opts.external_template(false);
opts.bare(bare);
Ok(git2::Repository::init_opts(&path, &opts)?)
}
fn github_up_to_date(handle: &mut Easy, url: &Url, oid: &git2::Oid) -> bool {
macro_rules! r#try {
($e:expr) => {
match $e {
Some(e) => e,
None => return false,
}
};
}
let mut pieces = r#try!(url.path_segments());
let username = r#try!(pieces.next());
let repo = r#try!(pieces.next());
if pieces.next().is_some() {
return false;
}
let url = format!(
"https://api.github.com/repos/{}/{}/commits/master",
username, repo
);
r#try!(handle.get(true).ok());
r#try!(handle.url(&url).ok());
r#try!(handle.useragent("cargo").ok());
let mut headers = List::new();
r#try!(headers.append("Accept: application/vnd.github.3.sha").ok());
r#try!(headers.append(&format!("If-None-Match: \"{}\"", oid)).ok());
r#try!(handle.http_headers(headers).ok());
r#try!(handle.perform().ok());
r#try!(handle.response_code().ok()) == 304
}