use std::io::Write;
use std::process::{Command, Stdio};
use sley_config::GitConfig;
use sley_core::Result;
use sley_transport::{
GitCredential, RemoteTransport, RemoteUrl, encode_git_credential, parse_git_credential,
};
use crate::CredentialProvider;
pub fn http_protocol_name(remote: &RemoteUrl) -> Option<String> {
match remote.transport {
RemoteTransport::Https => Some("https".to_string()),
RemoteTransport::Http => Some("http".to_string()),
_ => None,
}
}
pub fn http_credential_host(remote: &RemoteUrl) -> Option<String> {
remote.host.clone().map(|host| match remote.port {
Some(port) => format!("{host}:{port}"),
None => host,
})
}
pub fn http_url_credential(remote: &RemoteUrl) -> Option<GitCredential> {
let username = remote.user.clone()?;
Some(GitCredential {
protocol: http_protocol_name(remote),
host: http_credential_host(remote),
username: Some(username),
password: remote.password.clone(),
..GitCredential::default()
})
}
pub fn credential_request_for_url(remote: &RemoteUrl) -> GitCredential {
GitCredential {
protocol: http_protocol_name(remote),
host: http_credential_host(remote),
username: remote.user.clone(),
..GitCredential::default()
}
}
fn credential_helper_specs(config: Option<&GitConfig>) -> Vec<String> {
let Some(config) = config else {
return Vec::new();
};
let mut specs = Vec::new();
for section in &config.sections {
if section.name != "credential" || section.subsection.is_some() {
continue;
}
for entry in §ion.entries {
if !entry.key.eq_ignore_ascii_case("helper") {
continue;
}
match entry.value.as_deref() {
Some("") | None => specs.clear(),
Some(value) => specs.push(value.to_string()),
}
}
}
specs
}
fn credential_helper_command(spec: &str, op: &str) -> Option<Command> {
let spec = spec.trim();
if spec.is_empty() {
return None;
}
if let Some(shell) = spec.strip_prefix('!') {
let mut command = Command::new("sh");
command
.arg("-c")
.arg(format!("{shell} \"$@\""))
.arg("sh")
.arg(op);
return Some(command);
}
let mut tokens = spec.split_whitespace();
let head = tokens.next()?;
let program = if head.starts_with('/') {
head.to_string()
} else {
format!("git-credential-{head}")
};
let mut command = Command::new(program);
for arg in tokens {
command.arg(arg);
}
command.arg(op);
Some(command)
}
fn run_credential_helper(spec: &str, op: &str, input: &[u8]) -> Result<Option<Vec<u8>>> {
let Some(mut command) = credential_helper_command(spec, op) else {
return Ok(None);
};
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = match command.spawn() {
Ok(child) => child,
Err(_) => return Ok(None),
};
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(input)?;
}
let output = child.wait_with_output()?;
if !output.status.success() {
return Ok(None);
}
Ok(Some(output.stdout))
}
pub fn credential_fill(
config: Option<&GitConfig>,
mut request: GitCredential,
) -> Result<Option<GitCredential>> {
for spec in credential_helper_specs(config) {
if request.username.is_some() && request.password.is_some() {
break;
}
let input = encode_git_credential(&request)?;
if let Some(stdout) = run_credential_helper(&spec, "get", &input)? {
let filled = parse_git_credential(&stdout)?;
if filled.username.is_some() {
request.username = filled.username;
}
if filled.password.is_some() {
request.password = filled.password;
}
}
}
if request.username.is_some() && request.password.is_some() {
Ok(Some(request))
} else {
Ok(None)
}
}
pub fn credential_store(config: Option<&GitConfig>, credential: &GitCredential, approve: bool) {
let Ok(input) = encode_git_credential(credential) else {
return;
};
let op = if approve { "store" } else { "erase" };
for spec in credential_helper_specs(config) {
let _ = run_credential_helper(&spec, op, &input);
}
}
pub struct CredentialHelperProvider<'a> {
config: Option<&'a GitConfig>,
}
impl<'a> CredentialHelperProvider<'a> {
pub fn new(config: Option<&'a GitConfig>) -> Self {
Self { config }
}
}
impl CredentialProvider for CredentialHelperProvider<'_> {
fn fill(&mut self, request: GitCredential) -> Result<Option<GitCredential>> {
credential_fill(self.config, request)
}
fn approve(&mut self, credential: &GitCredential) -> Result<()> {
credential_store(self.config, credential, true);
Ok(())
}
fn reject(&mut self, credential: &GitCredential) -> Result<()> {
credential_store(self.config, credential, false);
Ok(())
}
}
#[cfg(all(test, unix))]
mod credential_dispatch_parity_tests {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use sley_config::GitConfig;
use sley_transport::GitCredential;
use super::{credential_fill, credential_helper_command};
fn config_with_helper(helper: &str) -> GitConfig {
let escaped = helper.replace('\\', "\\\\").replace('"', "\\\"");
let body = format!("[credential]\n\thelper = \"{escaped}\"\n");
GitConfig::parse(body.as_bytes()).expect("config parses")
}
fn write_script(dir: &Path, name: &str, body: &str) -> std::path::PathBuf {
let path = dir.join(name);
fs::write(&path, body).expect("write script");
let mut perms = fs::metadata(&path).expect("metadata").permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms).expect("chmod");
path
}
fn base_request() -> GitCredential {
GitCredential {
protocol: Some("https".to_string()),
host: Some("example.com".to_string()),
..GitCredential::default()
}
}
#[test]
fn absolute_path_form_passes_args_and_op() {
let tmp = tempdir();
let marker = tmp.path().join("abs.out");
let script = write_script(
tmp.path(),
"abs-helper.sh",
&format!(
"#!/bin/sh\ncat >/dev/null\nprintf 'ARGS:[%s]\\n' \"$*\" >> '{}'\necho username=abs-user\necho password=abs-pass\n",
marker.display()
),
);
let cfg = config_with_helper(&format!("{} --flag", script.display()));
let filled = credential_fill(Some(&cfg), base_request())
.expect("fill ok")
.expect("credential filled");
assert_eq!(filled.username.as_deref(), Some("abs-user"));
assert_eq!(filled.password.as_deref(), Some("abs-pass"));
let recorded = fs::read_to_string(&marker).expect("marker written");
assert_eq!(recorded.trim(), "ARGS:[--flag get]");
}
#[test]
fn shell_snippet_form_runs_through_shell_with_op_arg() {
let tmp = tempdir();
let marker = tmp.path().join("snip.out");
let helper = format!(
"!f() {{ cat >/dev/null; printf 'GOT:[%s]\\n' \"$*\" >> '{}'; echo username=snip-user; echo password=snip-pass; }}; f",
marker.display()
);
let cfg = config_with_helper(&helper);
let filled = credential_fill(Some(&cfg), base_request())
.expect("fill ok")
.expect("credential filled");
assert_eq!(filled.username.as_deref(), Some("snip-user"));
assert_eq!(filled.password.as_deref(), Some("snip-pass"));
let recorded = fs::read_to_string(&marker).expect("marker written");
assert_eq!(recorded.trim(), "GOT:[get]");
}
#[test]
fn relative_slash_name_is_bare_not_path() {
let cmd = credential_helper_command("sub/relhelper", "get").expect("command built");
let program = command_program(&cmd);
assert_ne!(
program, "sub/relhelper",
"relative slash name must not be exec'd directly (git would prefix it)"
);
assert!(
program.contains("git-credential-sub/relhelper")
|| program == "sh"
|| program == "/bin/sh",
"expected git-credential-<name> dispatch, got program {program:?}"
);
}
#[test]
fn plain_bare_name_maps_to_credential_binary() {
let cmd = credential_helper_command("myhelper --opt val", "get").expect("command built");
let argv = command_argv(&cmd);
assert!(
argv[0].contains("git-credential-myhelper") || argv[0] == "sh" || argv[0] == "/bin/sh",
"expected git-credential-<name> dispatch, got argv {argv:?}"
);
assert_ne!(argv[0], "git", "must not shell out to the git binary");
let rendered = argv.join(" ");
assert!(
rendered.contains("git-credential-myhelper")
&& rendered.contains("--opt")
&& rendered.contains("val")
&& rendered.contains("get"),
"expected `git-credential-myhelper --opt val get` dispatch, got {rendered:?}"
);
}
fn command_program(cmd: &std::process::Command) -> String {
cmd.get_program().to_string_lossy().into_owned()
}
fn command_argv(cmd: &std::process::Command) -> Vec<String> {
let mut out = vec![cmd.get_program().to_string_lossy().into_owned()];
out.extend(cmd.get_args().map(|a| a.to_string_lossy().into_owned()));
out
}
struct TempDir {
path: std::path::PathBuf,
}
impl TempDir {
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
fn tempdir() -> TempDir {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let path = std::env::temp_dir().join(format!("sley-cred-parity-{pid}-{n}"));
fs::create_dir_all(&path).expect("mkdir tempdir");
TempDir { path }
}
}