use git2::{Cred, FetchOptions, PushOptions, RemoteCallbacks, RemoteRedirect};
use tracing::warn;
#[derive(Debug, Clone)]
pub struct CredentialScope {
pub username: String,
pub password: String,
pub origin: String,
}
impl CredentialScope {
pub fn bind(
username: Option<&str>,
password: Option<&str>,
remote_url: Option<&str>,
) -> Option<Self> {
let (username, password) = (username?, password?);
let origin = remote_origin(remote_url?)?;
Some(Self {
username: username.to_string(),
password: password.to_string(),
origin,
})
}
pub fn allows(&self, url: &str) -> bool {
remote_origin(url).as_deref() == Some(self.origin.as_str())
}
}
pub fn remote_origin(url: &str) -> Option<String> {
if let Some((scheme, rest)) = url.split_once("://") {
let scheme = scheme.to_ascii_lowercase();
if scheme == "file" {
return None;
}
let authority = rest.split('/').next()?;
let host_port = authority.rsplit('@').next()?;
return normalise_host_port(&scheme, host_port);
}
let colon = url.find(':')?;
let slash = url.find('/').unwrap_or(usize::MAX);
if colon == 0 || slash < colon || url.starts_with('.') {
return None;
}
let host = url[..colon].rsplit('@').next()?;
if host.is_empty() || host.len() == 1 || host.contains(char::is_whitespace) {
return None;
}
Some(format!("ssh://{}", host.to_ascii_lowercase()))
}
fn normalise_host_port(scheme: &str, host_port: &str) -> Option<String> {
let (host, port) = if let Some(rest) = host_port.strip_prefix('[') {
let end = rest.find(']')?;
let host = &rest[..end];
let port = rest[end + 1..].strip_prefix(':');
(format!("[{host}]"), port)
} else {
match host_port.rsplit_once(':') {
Some((h, p)) if p.bytes().all(|b| b.is_ascii_digit()) => (h.to_string(), Some(p)),
_ => (host_port.to_string(), None),
}
};
if host.is_empty() {
return None;
}
let host = host.to_ascii_lowercase();
let default = match scheme {
"http" => Some("80"),
"https" => Some("443"),
"git" => Some("9418"),
"ssh" => Some("22"),
_ => None,
};
match port {
Some(p) if Some(p) != default && !p.is_empty() => Some(format!("{scheme}://{host}:{p}")),
_ => Some(format!("{scheme}://{host}")),
}
}
pub fn remote_callbacks(scope: Option<&CredentialScope>) -> RemoteCallbacks<'static> {
let mut callbacks = RemoteCallbacks::new();
if let Some(scope) = scope {
let scope = scope.clone();
callbacks.credentials(move |url, _username_from_url, _allowed_types| {
if scope.allows(url) {
Cred::userpass_plaintext(&scope.username, &scope.password)
} else {
let asked = remote_origin(url).unwrap_or_else(|| url.to_string());
warn!(
"Git client refused to offer its credentials to {} (bound to {})",
asked, scope.origin
);
Err(git2::Error::from_str(&format!(
"this client's credentials are bound to {} and are not offered to {}; \
open a client for that host if you mean to authenticate there",
scope.origin, asked
)))
}
});
}
callbacks
}
pub fn fetch_options(scope: Option<&CredentialScope>) -> FetchOptions<'static> {
let mut options = FetchOptions::new();
options.remote_callbacks(remote_callbacks(scope));
options.follow_redirects(RemoteRedirect::None);
options
}
pub fn push_options(scope: Option<&CredentialScope>) -> PushOptions<'static> {
let mut options = PushOptions::new();
options.remote_callbacks(remote_callbacks(scope));
options.follow_redirects(RemoteRedirect::None);
options
}