use std::io::Write;
use std::process::{Command, Stdio};
use crate::helper::{Credentials, Helper, HelperError};
use crate::query::Query;
use crate::trace::trace_enabled;
#[derive(Debug, Clone)]
pub struct GitCredentialHelper {
git_program: String,
protect_protocol: bool,
}
impl Default for GitCredentialHelper {
fn default() -> Self {
Self {
git_program: "git".to_owned(),
protect_protocol: true,
}
}
}
impl GitCredentialHelper {
pub fn new() -> Self {
Self::default()
}
pub fn with_program(git_program: impl Into<String>) -> Self {
Self {
git_program: git_program.into(),
protect_protocol: true,
}
}
pub fn with_protect_protocol(mut self, protect: bool) -> Self {
self.protect_protocol = protect;
self
}
fn run(&self, subcommand: &str, query: &Query) -> Result<String, HelperError> {
if trace_enabled() {
let mut e = std::io::stderr().lock();
let _ = writeln!(
e,
"creds: git credential {subcommand} ({:?}, {:?}, {:?})",
query.protocol, query.host, query.path,
);
}
let mut child = Command::new(&self.git_program)
.args(["credential", subcommand])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
{
let stdin = child
.stdin
.as_mut()
.ok_or_else(|| HelperError::Failed("git stdin unavailable".into()))?;
write_input(stdin, query, None, self.protect_protocol)?;
}
let out = child.wait_with_output()?;
if !out.status.success() {
if subcommand == "fill" && out.status.code() == Some(128) {
return Ok(String::new());
}
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
return Err(HelperError::Failed(format!(
"git credential {subcommand} exited {}: {stderr}",
out.status,
)));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
}
impl Helper for GitCredentialHelper {
fn fill(&self, query: &Query) -> Result<Option<Credentials>, HelperError> {
let stdout = self.run("fill", query)?;
Ok(parse_response(&stdout))
}
fn approve(&self, query: &Query, creds: &Credentials) -> Result<(), HelperError> {
let mut child = spawn(&self.git_program, "approve")?;
if let Some(stdin) = child.stdin.as_mut() {
write_input(stdin, query, Some(creds), self.protect_protocol)?;
}
let out = child.wait_with_output()?;
if !out.status.success() {
return Err(HelperError::Failed(format!(
"git credential approve exited {}: {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim(),
)));
}
Ok(())
}
fn reject(&self, query: &Query, creds: &Credentials) -> Result<(), HelperError> {
let mut child = spawn(&self.git_program, "reject")?;
if let Some(stdin) = child.stdin.as_mut() {
write_input(stdin, query, Some(creds), self.protect_protocol)?;
}
let out = child.wait_with_output()?;
if !out.status.success() {
return Err(HelperError::Failed(format!(
"git credential reject exited {}: {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim(),
)));
}
Ok(())
}
}
fn spawn(program: &str, subcommand: &str) -> Result<std::process::Child, HelperError> {
Command::new(program)
.args(["credential", subcommand])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(HelperError::Io)
}
fn write_input(
sink: &mut impl Write,
query: &Query,
creds: Option<&Credentials>,
protect_protocol: bool,
) -> Result<(), HelperError> {
write_field(sink, "protocol", &query.protocol, protect_protocol)?;
write_field(sink, "host", &query.host, protect_protocol)?;
write_field(sink, "path", &query.path, protect_protocol)?;
if let Some(c) = creds {
write_field(sink, "username", &c.username, protect_protocol)?;
validate_value("password", &c.password, protect_protocol)?;
writeln!(sink, "password={}", c.password)?;
}
writeln!(sink)?;
Ok(())
}
fn write_field(
sink: &mut impl Write,
key: &str,
value: &str,
protect_protocol: bool,
) -> Result<(), HelperError> {
if value.is_empty() {
return Ok(());
}
validate_value(key, value, protect_protocol)?;
writeln!(sink, "{key}={value}")?;
Ok(())
}
fn validate_value(key: &str, value: &str, protect_protocol: bool) -> Result<(), HelperError> {
if value.contains('\n') {
return Err(HelperError::Failed(format!(
"credential value for {key} contains newline: {value:?}"
)));
}
if value.contains('\0') {
return Err(HelperError::Failed(format!(
"credential value for {key} contains null byte: {value:?}"
)));
}
if protect_protocol && value.contains('\r') {
return Err(HelperError::Failed(format!(
"credential value for {key} contains carriage return: {value:?}\n\
If this is intended, set `credential.protectProtocol=false`"
)));
}
Ok(())
}
fn parse_response(stdout: &str) -> Option<Credentials> {
let mut username = String::new();
let mut password: Option<String> = None;
for line in stdout.lines() {
let Some((k, v)) = line.split_once('=') else {
continue;
};
match k {
"username" => username = v.to_owned(),
"password" => password = Some(v.to_owned()),
_ => {}
}
}
password.map(|p| Credentials::new(username, p))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_response_extracts_credentials() {
let out = "protocol=https\nhost=git.example.com\nusername=alice\npassword=hunter2\n";
assert_eq!(
parse_response(out),
Some(Credentials::new("alice", "hunter2")),
);
}
#[test]
fn parse_response_returns_none_without_password() {
let out = "protocol=https\nhost=git.example.com\nusername=alice\n";
assert_eq!(parse_response(out), None);
}
#[test]
fn parse_response_allows_empty_username_with_token_password() {
let out = "password=ghp_token\n";
assert_eq!(parse_response(out), Some(Credentials::new("", "ghp_token")));
}
#[test]
fn write_input_rejects_newline_in_path() {
let q = Query {
protocol: "https".into(),
host: "h.example".into(),
path: "evil\nrepo".into(),
};
let mut buf = Vec::new();
let err = write_input(&mut buf, &q, None, true).unwrap_err();
assert!(
matches!(&err, HelperError::Failed(m) if m.contains("contains newline")),
"got {err:?}"
);
}
#[test]
fn write_input_rejects_null_byte_even_when_protection_off() {
let q = Query {
protocol: "https".into(),
host: "h.example".into(),
path: "evil\0repo".into(),
};
let mut buf = Vec::new();
let err = write_input(&mut buf, &q, None, false).unwrap_err();
assert!(
matches!(&err, HelperError::Failed(m) if m.contains("contains null byte")),
"got {err:?}"
);
}
#[test]
fn write_input_rejects_carriage_return_by_default() {
let q = Query {
protocol: "https".into(),
host: "h.example".into(),
path: "evil\rrepo".into(),
};
let mut buf = Vec::new();
let err = write_input(&mut buf, &q, None, true).unwrap_err();
assert!(
matches!(&err, HelperError::Failed(m) if m.contains("contains carriage return")),
"got {err:?}"
);
}
#[test]
fn write_input_allows_carriage_return_when_protection_off() {
let q = Query {
protocol: "https".into(),
host: "h.example".into(),
path: "evil\rrepo".into(),
};
let mut buf = Vec::new();
write_input(&mut buf, &q, None, false).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("path=evil\rrepo\n"));
}
#[test]
fn write_input_skips_empty_fields() {
let q = Query {
protocol: "https".into(),
host: "h.example".into(),
path: String::new(),
};
let mut buf = Vec::new();
write_input(&mut buf, &q, None, true).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(!s.contains("path="));
assert!(s.contains("protocol=https\n"));
assert!(s.contains("host=h.example\n"));
}
}