use std::path::PathBuf;
use git2::{Cred, CredentialType, FetchOptions, RemoteCallbacks, Repository};
use tracing::{info, warn};
use crate::collect::collector::{FetchOutcome, PerRepoFetch};
use crate::collect::errors::Result;
pub fn fetch_remote(repo: &Repository, remote_name: &str) -> Result<()> {
fetch_remote_with_outcome(repo, remote_name).map(|_| ())
}
pub fn fetch_remote_with_outcome(repo: &Repository, remote_name: &str) -> Result<FetchOutcome> {
let mut remote = match repo.find_remote(remote_name) {
Ok(r) => r,
Err(e) => {
info!(
remote = remote_name,
error = %e,
"no matching remote; skipping fetch (local-only repo)"
);
return Ok(FetchOutcome::Skipped {
reason: "no remote configured".to_string(),
});
}
};
info!("Fetching from remote '{}'", remote_name);
let cursor = std::cell::Cell::new(0usize);
let mut callbacks = RemoteCallbacks::new();
callbacks.credentials(move |_url, username_from_url, allowed_types| {
let (next, cred) =
non_interactive_credentials(cursor.get(), username_from_url, allowed_types)?;
cursor.set(next);
Ok(cred)
});
let mut fetch_options = FetchOptions::new();
fetch_options.remote_callbacks(callbacks);
match remote.fetch(&[] as &[&str], Some(&mut fetch_options), None) {
Ok(()) => {
info!(remote = remote_name, "fetch succeeded");
Ok(FetchOutcome::Success {
remote: remote_name.to_string(),
})
}
Err(e) => {
let kind = if is_auth_or_transport_error(&e) {
"auth/transport"
} else {
"git"
};
warn!(
remote = remote_name,
error = %e,
kind,
"fetch failed — continuing with local refs"
);
Ok(FetchOutcome::Failed {
remote: remote_name.to_string(),
error: e.to_string(),
})
}
}
}
pub fn fetch_and_record(repo: &Repository, repo_name: &str, remote_name: &str) -> PerRepoFetch {
match fetch_remote_with_outcome(repo, remote_name) {
Ok(outcome) => PerRepoFetch {
repo: repo_name.to_string(),
outcome,
},
Err(e) => PerRepoFetch {
repo: repo_name.to_string(),
outcome: FetchOutcome::Failed {
remote: remote_name.to_string(),
error: e.to_string(),
},
},
}
}
#[derive(Clone, Copy)]
enum CredSource {
Agent,
KeyFile(&'static str),
GithubToken,
Helper,
}
const CRED_CHAIN: [CredSource; 5] = [
CredSource::Agent,
CredSource::KeyFile("id_ed25519"),
CredSource::KeyFile("id_rsa"),
CredSource::GithubToken,
CredSource::Helper,
];
fn non_interactive_credentials(
from: usize,
username_from_url: Option<&str>,
allowed_types: CredentialType,
) -> std::result::Result<(usize, Cred), git2::Error> {
let username = username_from_url.unwrap_or("git");
for (index, source) in CRED_CHAIN.iter().enumerate().skip(from) {
let cred = match source {
CredSource::Agent if allowed_types.contains(CredentialType::SSH_KEY) => {
Cred::ssh_key_from_agent(username).ok()
}
CredSource::KeyFile(name) if allowed_types.contains(CredentialType::SSH_KEY) => {
home_dir()
.map(|home| home.join(".ssh").join(name))
.filter(|key| key.exists())
.and_then(|key| {
Cred::ssh_key(username, None, key.as_path(), None).ok()
})
}
CredSource::GithubToken
if allowed_types.contains(CredentialType::USER_PASS_PLAINTEXT) =>
{
std::env::var(trusty_common::env_vars::ENV_GITHUB_TOKEN)
.or_else(|_| std::env::var("GH_TOKEN"))
.ok()
.and_then(|token| Cred::userpass_plaintext("x-access-token", &token).ok())
}
CredSource::Helper if allowed_types.contains(CredentialType::DEFAULT) => {
Cred::default().ok()
}
_ => None,
};
if let Some(cred) = cred {
return Ok((index + 1, cred));
}
}
Err(git2::Error::from_str(
"no non-interactive credentials available \
(tried SSH agent, ~/.ssh/id_ed25519, ~/.ssh/id_rsa, \
GITHUB_TOKEN/GH_TOKEN env, platform credential helper)",
))
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
fn is_auth_or_transport_error(e: &git2::Error) -> bool {
matches!(
e.class(),
git2::ErrorClass::Ssh
| git2::ErrorClass::Http
| git2::ErrorClass::Net
| git2::ErrorClass::Callback
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fetch_outcome_skipped_for_local_repo() {
let td = tempfile::tempdir().expect("tempdir");
let repo = git2::Repository::init(td.path()).expect("init");
let outcome =
fetch_remote_with_outcome(&repo, "origin").expect("no error expected from soft-fail");
assert!(
matches!(outcome, FetchOutcome::Skipped { .. }),
"expected Skipped, got {outcome:?}"
);
}
#[test]
fn fetch_and_record_sets_repo_name() {
let td = tempfile::tempdir().expect("tempdir");
let repo = git2::Repository::init(td.path()).expect("init");
let prf = fetch_and_record(&repo, "my-repo", "origin");
assert_eq!(prf.repo, "my-repo");
assert!(
matches!(prf.outcome, FetchOutcome::Skipped { .. }),
"expected Skipped, got {:?}",
prf.outcome
);
}
#[test]
fn fetch_remote_with_outcome_returns_failed_for_dead_remote() {
let td_remote = tempfile::tempdir().expect("tempdir for remote");
let _remote = git2::Repository::init_bare(td_remote.path()).expect("init bare");
let td_local = tempfile::tempdir().expect("tempdir for local");
let local = git2::Repository::init(td_local.path()).expect("init local");
let remote_url = td_remote.path().to_str().expect("valid utf8").to_string();
local
.remote("origin", &remote_url)
.expect("add remote origin");
drop(td_remote);
let local2 = git2::Repository::open(td_local.path()).expect("reopen local");
let outcome = fetch_remote_with_outcome(&local2, "origin")
.expect("no Err — soft-fail policy means we return Ok(Failed)");
assert!(
matches!(outcome, FetchOutcome::Failed { .. }),
"expected Failed for dead remote path, got {outcome:?}"
);
let local3 = git2::Repository::open(td_local.path()).expect("reopen local 3");
let prf = fetch_and_record(&local3, "dead-remote-repo", "origin");
assert_eq!(prf.repo, "dead-remote-repo");
assert!(
matches!(prf.outcome, FetchOutcome::Failed { .. }),
"expected Failed in PerRepoFetch, got {:?}",
prf.outcome
);
}
const NO_TRANSPORT: &str = "unsupported URL protocol";
fn closed_loopback_port() -> u16 {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
let port = listener.local_addr().expect("local addr").port();
drop(listener);
port
}
fn fetch_error_for(url: &str) -> String {
let td = tempfile::tempdir().expect("tempdir");
let repo = git2::Repository::init(td.path()).expect("init");
repo.remote("origin", url).expect("add remote origin");
match fetch_remote_with_outcome(&repo, "origin").expect("soft-fail returns Ok") {
FetchOutcome::Failed { error, .. } => error,
other => panic!("expected Failed for unreachable {url}, got {other:?}"),
}
}
#[test]
fn the_credential_chain_advances_rather_than_repeating() {
let allowed =
CredentialType::SSH_KEY | CredentialType::USER_PASS_PLAINTEXT | CredentialType::DEFAULT;
let mut cursor = 0usize;
let mut answers = 0usize;
while let Ok((next, _cred)) = non_interactive_credentials(cursor, Some("git"), allowed) {
assert!(
next > cursor,
"the callback answered without advancing: cursor {cursor} -> {next}"
);
cursor = next;
answers += 1;
assert!(
answers <= CRED_CHAIN.len(),
"the chain answered more times than it has sources"
);
}
assert!(
cursor <= CRED_CHAIN.len(),
"the cursor ran past the chain: {cursor}"
);
}
#[test]
fn an_ssh_url_reaches_the_ssh_transport() {
let url = format!(
"ssh://git@127.0.0.1:{}/org/repo.git",
closed_loopback_port()
);
let error = fetch_error_for(&url);
assert!(
!error.contains(NO_TRANSPORT),
"ssh:// remote was rejected for want of a transport, not for want of a \
reachable host: {error}"
);
}
#[test]
fn an_scp_style_remote_reaches_the_ssh_transport() {
let error = fetch_error_for("git@127.0.0.1:org/repo.git");
assert!(
!error.contains(NO_TRANSPORT),
"scp-style remote was rejected for want of a transport, not for want of a \
reachable host: {error}"
);
}
}