use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use crate::config::{parse_bool, ConfigSet};
use crate::error::{Error, Result};
pub const NON_INTERACTIVE_MESSAGE: &str = "credentials required but unavailable (non-interactive)";
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Credential {
pub protocol: Option<String>,
pub host: Option<String>,
pub path: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub url: Option<String>,
pub extra: Vec<(String, String)>,
}
impl Credential {
pub fn parse(input: &str) -> Self {
let mut cred = Credential::default();
for line in input.lines() {
let line = line.trim_end_matches('\r');
if line.is_empty() {
break;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
cred.set(key, value);
}
cred
}
pub fn parse_bytes(bytes: &[u8]) -> Self {
Self::parse(&String::from_utf8_lossy(bytes))
}
pub fn serialize(&self) -> String {
let mut out = String::new();
for (key, value) in self.iter_pairs() {
out.push_str(&key);
out.push('=');
out.push_str(&value);
out.push('\n');
}
out
}
fn set(&mut self, key: &str, value: &str) {
match key {
"protocol" => self.protocol = Some(value.to_string()),
"host" => self.host = Some(value.to_string()),
"path" => self.path = Some(value.to_string()),
"username" => self.username = Some(value.to_string()),
"password" => self.password = Some(value.to_string()),
"url" => self.url = Some(value.to_string()),
_ => {
if key.ends_with("[]") {
self.extra.push((key.to_string(), value.to_string()));
} else if let Some(slot) = self
.extra
.iter_mut()
.find(|(k, _)| k == key)
.map(|(_, v)| v)
{
*slot = value.to_string();
} else {
self.extra.push((key.to_string(), value.to_string()));
}
}
}
}
fn extra_get(&self, key: &str) -> Option<&str> {
self.extra
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
fn iter_pairs(&self) -> Vec<(String, String)> {
let mut pairs = Vec::new();
if let Some(v) = &self.protocol {
pairs.push(("protocol".to_string(), v.clone()));
}
if let Some(v) = &self.host {
pairs.push(("host".to_string(), v.clone()));
}
if let Some(v) = &self.path {
pairs.push(("path".to_string(), v.clone()));
}
if let Some(v) = &self.username {
pairs.push(("username".to_string(), v.clone()));
}
if let Some(v) = &self.password {
pairs.push(("password".to_string(), v.clone()));
}
if let Some(v) = &self.url {
pairs.push(("url".to_string(), v.clone()));
}
for (k, v) in &self.extra {
pairs.push((k.clone(), v.clone()));
}
pairs
}
pub fn is_complete(&self) -> bool {
self.username.as_deref().is_some_and(|s| !s.is_empty())
&& self.password.as_deref().is_some_and(|s| !s.is_empty())
}
pub fn target_url(&self) -> Option<String> {
if let Some(u) = self.url.as_deref().filter(|u| !u.trim().is_empty()) {
return Some(u.to_string());
}
let protocol = self.protocol.as_deref()?;
let host = self.host.as_deref()?;
let mut url = format!("{protocol}://");
if let Some(username) = self.username.as_deref().filter(|u| !u.is_empty()) {
url.push_str(username);
url.push('@');
}
url.push_str(host);
if let Some(path) = self.path.as_deref().filter(|p| !p.is_empty()) {
if !path.starts_with('/') {
url.push('/');
}
url.push_str(path);
}
Some(url)
}
fn merge_response(&mut self, response: &Credential) {
if self.username.is_none() {
self.username = response.username.clone();
}
if self.password.is_none() {
self.password = response.password.clone();
}
if self.protocol.is_none() {
self.protocol = response.protocol.clone();
}
if self.host.is_none() {
self.host = response.host.clone();
}
if self.path.is_none() {
self.path = response.path.clone();
}
for (k, v) in &response.extra {
if k == "quit" {
self.set(k, v);
}
}
}
fn wants_quit(&self) -> bool {
matches!(self.extra_get("quit"), Some("1") | Some("true"))
}
}
pub trait CredentialProvider {
fn fill(&self, input: &Credential) -> Result<Credential>;
fn approve(&self, cred: &Credential) -> Result<()>;
fn reject(&self, cred: &Credential) -> Result<()>;
}
pub struct HelperCredentialProvider {
config: ConfigSet,
}
impl HelperCredentialProvider {
pub fn new(config: ConfigSet) -> Self {
Self { config }
}
fn helpers(&self, target_url: Option<&str>) -> Vec<String> {
credential_helpers(&self.config, target_url)
}
}
impl CredentialProvider for HelperCredentialProvider {
fn fill(&self, input: &Credential) -> Result<Credential> {
let mut filled = input.clone();
if filled.is_complete() {
return Ok(filled);
}
let target_url = filled.target_url();
for helper in self.helpers(target_url.as_deref()) {
let response = invoke_helper(&helper, "get", &filled)?;
if response.wants_quit() {
return Err(Error::Message(format!(
"credential helper '{helper}' told us to quit"
)));
}
filled.merge_response(&response);
if filled.is_complete() {
return Ok(filled);
}
}
Err(Error::Message(NON_INTERACTIVE_MESSAGE.to_string()))
}
fn approve(&self, cred: &Credential) -> Result<()> {
let target_url = cred.target_url();
for helper in self.helpers(target_url.as_deref()) {
invoke_helper(&helper, "store", cred)?;
}
Ok(())
}
fn reject(&self, cred: &Credential) -> Result<()> {
let target_url = cred.target_url();
for helper in self.helpers(target_url.as_deref()) {
invoke_helper(&helper, "erase", cred)?;
}
Ok(())
}
}
fn credential_helpers(config: &ConfigSet, target_url: Option<&str>) -> Vec<String> {
let mut out = Vec::new();
for entry in config.entries() {
let key = &entry.key;
if key.contains('\n') || key.to_ascii_lowercase().contains("%0a") {
continue;
}
let Some(first_dot) = key.find('.') else {
continue;
};
let Some(last_dot) = key.rfind('.') else {
continue;
};
let section = &key[..first_dot];
let variable = &key[last_dot + 1..];
if !section.eq_ignore_ascii_case("credential") || !variable.eq_ignore_ascii_case("helper") {
continue;
}
if first_dot != last_dot {
let subsection = &key[first_dot + 1..last_dot];
if percent_decode_lossy(subsection).contains('\n') {
continue;
}
let Some(target) = target_url else {
continue;
};
if !credential_url_matches(subsection, target) {
continue;
}
}
let value = entry.value.as_deref().unwrap_or("");
if value.trim().is_empty() {
out.clear();
} else {
out.push(value.to_string());
}
}
out
}
fn credential_url_matches(pattern: &str, target: &str) -> bool {
let pattern = percent_decode_lossy(pattern);
if pattern.contains('\n') {
return false;
}
let pattern = pattern.trim_end_matches('/');
let pattern_no_user = strip_url_userinfo(pattern);
let pattern_after_user = pattern.rsplit_once('@').map(|(_, host)| host);
let target = target.trim_end_matches('/');
let target_no_user = strip_url_userinfo(target);
let target_no_scheme = strip_url_scheme(target);
let target_no_scheme_no_user = strip_url_scheme(&target_no_user);
let target_path = target_path_component(target);
let matches = |pattern: &str| {
if pattern.starts_with('/') {
return credential_prefix_matches(pattern, target_path);
}
if pattern.ends_with("://") {
return target.starts_with(pattern) || target_no_user.starts_with(pattern);
}
if pattern.contains('*') {
return credential_wildcard_matches(pattern, target)
|| credential_wildcard_matches(pattern, &target_no_user)
|| credential_wildcard_matches(pattern, target_no_scheme)
|| credential_wildcard_matches(pattern, target_no_scheme_no_user);
}
credential_prefix_matches(pattern, target)
|| credential_prefix_matches(pattern, &target_no_user)
|| credential_prefix_matches(pattern, target_no_scheme)
|| credential_prefix_matches(pattern, target_no_scheme_no_user)
};
matches(pattern)
|| (pattern_no_user != pattern && matches(&pattern_no_user))
|| pattern_after_user.is_some_and(matches)
}
fn credential_prefix_matches(pattern: &str, candidate: &str) -> bool {
candidate
.strip_prefix(pattern)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/') || pattern.ends_with("://"))
}
fn credential_wildcard_matches(pattern: &str, candidate: &str) -> bool {
let Some((prefix, suffix)) = pattern.split_once('*') else {
return false;
};
let Some(rest) = candidate.strip_prefix(prefix) else {
return false;
};
rest.find(suffix).is_some_and(|idx| {
let after = &rest[idx + suffix.len()..];
after.is_empty() || after.starts_with('/')
})
}
fn strip_url_scheme(url: &str) -> &str {
url.split_once("://").map_or(url, |(_, rest)| rest)
}
fn strip_url_userinfo(url: &str) -> String {
let Some((scheme, rest)) = url.split_once("://") else {
return url
.rsplit_once('@')
.map_or(url, |(_, host)| host)
.to_string();
};
rest.rsplit_once('@')
.map_or_else(|| url.to_string(), |(_, host)| format!("{scheme}://{host}"))
}
fn target_path_component(url: &str) -> &str {
let rest = strip_url_scheme(url);
let idx = rest
.char_indices()
.find_map(|(idx, ch)| matches!(ch, '/' | '?' | '#').then_some(idx))
.unwrap_or(rest.len());
&rest[idx..]
}
fn percent_decode_lossy(input: &str) -> String {
let mut out = Vec::with_capacity(input.len());
let bytes = input.as_bytes();
let mut idx = 0;
while idx < bytes.len() {
if bytes[idx] == b'%' && idx + 2 < bytes.len() {
if let (Some(hi), Some(lo)) = (hex_value(bytes[idx + 1]), hex_value(bytes[idx + 2])) {
out.push((hi << 4) | lo);
idx += 3;
continue;
}
}
out.push(bytes[idx]);
idx += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
fn credential_helper_exec_path_candidates() -> Vec<PathBuf> {
let mut v = Vec::new();
if let Ok(ep) = std::env::var("GIT_EXEC_PATH") {
let p = PathBuf::from(ep.trim());
if p.is_dir() {
v.push(p);
}
}
for candidate in [
"/usr/libexec/git-core",
"/Library/Developer/CommandLineTools/usr/libexec/git-core",
"/opt/homebrew/opt/git/libexec/git-core",
"/opt/homebrew/libexec/git-core",
"/usr/lib/git-core",
"/usr/local/libexec/git-core",
] {
let p = PathBuf::from(candidate);
if p.is_dir() {
v.push(p);
}
}
v
}
fn resolve_credential_helper_executable(helper_program: &str) -> PathBuf {
if helper_program.contains('/') {
return PathBuf::from(helper_program);
}
if let Some(suffix) = helper_program.strip_prefix("git-credential-") {
let exe_name = format!("git-credential-{suffix}");
for ep in credential_helper_exec_path_candidates() {
let candidate = ep.join(&exe_name);
if candidate.is_file() {
return candidate;
}
}
}
PathBuf::from(helper_program)
}
fn invoke_helper(helper: &str, action: &str, creds: &Credential) -> Result<Credential> {
let helper_words = shell_words::split(helper)
.map_err(|e| Error::Message(format!("invalid credential.helper '{helper}': {e}")))?;
let (first_word, extra_args) = match helper_words.split_first() {
Some((first, rest)) => (first.as_str(), rest),
None => ("", &[][..]),
};
let mut child = if let Some(shell_cmd) = helper.strip_prefix('!') {
Command::new("sh")
.arg("-c")
.arg(format!("{shell_cmd} {action}"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| {
Error::Message(format!(
"failed to run credential helper shell '{helper}': {e}"
))
})?
} else if matches!(
first_word,
"store" | "cache" | "git-credential-store" | "git-credential-cache"
) {
let subcmd = if first_word.ends_with("store") {
"credential-store"
} else {
"credential-cache"
};
let exe = std::env::current_exe()
.map_err(|e| Error::Message(format!("resolve current executable: {e}")))?;
let mut cmd = Command::new(exe);
cmd.arg(subcmd);
for arg in extra_args {
cmd.arg(arg);
}
cmd.arg(action);
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| {
Error::Message(format!(
"failed to run built-in credential helper '{subcmd}': {e}"
))
})?
} else {
let helper_program =
if first_word.contains('/') || first_word.starts_with("git-credential-") {
first_word.to_string()
} else {
format!("git-credential-{first_word}")
};
let resolved = resolve_credential_helper_executable(&helper_program);
let mut cmd = Command::new(&resolved);
for arg in extra_args {
cmd.arg(arg);
}
cmd.arg(action);
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| {
Error::Message(format!(
"failed to run credential helper '{helper_program}': {e}"
))
})?
};
{
let stdin = child
.stdin
.as_mut()
.ok_or_else(|| Error::Message("credential helper missing stdin".to_string()))?;
stdin.write_all(creds.serialize().as_bytes())?;
stdin.write_all(b"\n")?;
}
let output = child
.wait_with_output()
.map_err(|e| Error::Message(format!("credential helper '{helper}' failed: {e}")))?;
if !output.status.success() {
return Err(Error::Message(format!(
"credential helper '{helper}' exited with status {}",
output.status
)));
}
Ok(Credential::parse_bytes(&output.stdout))
}
pub fn use_http_path(config: &ConfigSet, target_url: Option<&str>) -> bool {
credential_config_value(config, target_url, "useHttpPath")
.as_deref()
.map(|value| parse_bool(value).unwrap_or(false))
.unwrap_or(false)
}
fn credential_config_value(
config: &ConfigSet,
target_url: Option<&str>,
variable_name: &str,
) -> Option<String> {
let mut out = None;
for entry in config.entries() {
let key = &entry.key;
if key.contains('\n') || key.to_ascii_lowercase().contains("%0a") {
continue;
}
let Some(first_dot) = key.find('.') else {
continue;
};
let Some(last_dot) = key.rfind('.') else {
continue;
};
let section = &key[..first_dot];
let variable = &key[last_dot + 1..];
if !section.eq_ignore_ascii_case("credential")
|| !variable.eq_ignore_ascii_case(variable_name)
{
continue;
}
if first_dot != last_dot {
let subsection = &key[first_dot + 1..last_dot];
if percent_decode_lossy(subsection).contains('\n') {
continue;
}
let Some(target) = target_url else {
continue;
};
if !credential_url_matches(subsection, target) {
continue;
}
}
out = entry.value.clone();
}
out
}
#[cfg(windows)]
pub mod windows_store {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::{GetLastError, ERROR_NOT_FOUND};
use windows_sys::Win32::Security::Credentials::{
CredDeleteW, CredFree, CredReadW, CredWriteW, CREDENTIALW, CRED_PERSIST_LOCAL_MACHINE,
CRED_TYPE_GENERIC,
};
use super::Credential;
use crate::error::{Error, Result};
fn target_name(cred: &Credential) -> Result<String> {
let protocol = cred
.protocol
.as_deref()
.filter(|p| !p.is_empty())
.ok_or_else(|| Error::Message("credential is missing its protocol".to_owned()))?;
let host = cred
.host
.as_deref()
.filter(|h| !h.is_empty())
.ok_or_else(|| Error::Message("credential is missing its host".to_owned()))?;
Ok(format!("git:{protocol}://{host}"))
}
fn wide(s: &str) -> Vec<u16> {
OsStr::new(s)
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
unsafe fn wstr_to_string(ptr: *const u16) -> String {
if ptr.is_null() {
return String::new();
}
let mut len = 0usize;
while *ptr.add(len) != 0 {
len += 1;
}
String::from_utf16_lossy(std::slice::from_raw_parts(ptr, len))
}
pub fn get(cred: &Credential) -> Result<Option<Credential>> {
let target = wide(&target_name(cred)?);
let mut pcred: *mut CREDENTIALW = std::ptr::null_mut();
let ok = unsafe { CredReadW(target.as_ptr(), CRED_TYPE_GENERIC, 0, &mut pcred) };
if ok == 0 {
let err = unsafe { GetLastError() };
if err == ERROR_NOT_FOUND {
return Ok(None);
}
return Err(Error::Message(format!(
"reading from the Windows Credential Manager failed (error {err})"
)));
}
let (username, password) = unsafe {
let c = &*pcred;
let username = wstr_to_string(c.UserName);
let password = if c.CredentialBlob.is_null() || c.CredentialBlobSize == 0 {
String::new()
} else {
let bytes =
std::slice::from_raw_parts(c.CredentialBlob, c.CredentialBlobSize as usize);
String::from_utf8_lossy(bytes).into_owned()
};
(username, password)
};
unsafe { CredFree(pcred as *const core::ffi::c_void) };
let mut out = Credential::default();
if !username.is_empty() {
out.username = Some(username);
}
if !password.is_empty() {
out.password = Some(password);
}
Ok(Some(out))
}
pub fn store(cred: &Credential) -> Result<()> {
let password = match cred.password.as_deref() {
Some(p) if !p.is_empty() => p,
_ => return Ok(()),
};
let mut target = wide(&target_name(cred)?);
let mut username = wide(cred.username.as_deref().unwrap_or(""));
let blob = password.as_bytes();
let mut credential: CREDENTIALW = unsafe { std::mem::zeroed() };
credential.Type = CRED_TYPE_GENERIC;
credential.TargetName = target.as_mut_ptr();
credential.CredentialBlobSize = blob.len() as u32;
credential.CredentialBlob = blob.as_ptr() as *mut u8;
credential.Persist = CRED_PERSIST_LOCAL_MACHINE;
credential.UserName = username.as_mut_ptr();
let ok = unsafe { CredWriteW(&credential as *const CREDENTIALW, 0) };
if ok == 0 {
let err = unsafe { GetLastError() };
return Err(Error::Message(format!(
"writing to the Windows Credential Manager failed (error {err})"
)));
}
Ok(())
}
pub fn erase(cred: &Credential) -> Result<()> {
let target = wide(&target_name(cred)?);
let ok = unsafe { CredDeleteW(target.as_ptr(), CRED_TYPE_GENERIC, 0) };
if ok == 0 {
let err = unsafe { GetLastError() };
if err != ERROR_NOT_FOUND {
return Err(Error::Message(format!(
"deleting from the Windows Credential Manager failed (error {err})"
)));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_round_trips_named_fields() {
let input =
"protocol=https\nhost=example.com\nusername=alice\npassword=secret\n\nignored=x\n";
let cred = Credential::parse(input);
assert_eq!(cred.protocol.as_deref(), Some("https"));
assert_eq!(cred.host.as_deref(), Some("example.com"));
assert_eq!(cred.username.as_deref(), Some("alice"));
assert_eq!(cred.password.as_deref(), Some("secret"));
assert!(cred.extra.is_empty());
}
#[test]
fn serialize_uses_canonical_order() {
let cred = Credential {
protocol: Some("https".into()),
host: Some("h".into()),
username: Some("u".into()),
password: Some("p".into()),
..Default::default()
};
assert_eq!(
cred.serialize(),
"protocol=https\nhost=h\nusername=u\npassword=p\n"
);
}
#[test]
fn target_url_reconstructed_from_fields() {
let cred = Credential {
protocol: Some("https".into()),
host: Some("github.com".into()),
path: Some("o/r.git".into()),
..Default::default()
};
assert_eq!(
cred.target_url().as_deref(),
Some("https://github.com/o/r.git")
);
}
}