use crate::config::Config;
use std::io::{BufRead, Write};
pub const GIT_USERNAME: &str = "x-token";
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct CredentialRequest {
pub protocol: Option<String>,
pub host: Option<String>,
pub path: Option<String>,
pub username: Option<String>,
}
pub fn parse_request<R: BufRead>(reader: R) -> CredentialRequest {
let mut req = CredentialRequest::default();
for line in reader.lines().map_while(Result::ok) {
let line = line.trim_end_matches(['\r', '\n']);
if line.is_empty() {
break;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
match key {
"protocol" => req.protocol = Some(value.to_string()),
"host" => req.host = Some(value.to_string()),
"path" => req.path = Some(value.to_string()),
"username" => req.username = Some(value.to_string()),
_ => {}
}
}
req
}
pub fn server_identity(server_url: &str) -> Option<(String, String)> {
let url = url::Url::parse(server_url).ok()?;
let host = url.host_str()?.to_string();
let scheme = url.scheme().to_string();
let host = match url.port() {
Some(p) => format!("{host}:{p}"),
None => host,
};
Some((scheme, host))
}
fn strip_default_port(scheme: &str, host: &str) -> String {
let default = match scheme {
"https" => ":443",
"http" => ":80",
_ => return host.to_string(),
};
host.strip_suffix(default).unwrap_or(host).to_string()
}
pub fn normalize_server_url(server_url: &str) -> String {
let trimmed = server_url.trim_end_matches('/');
match url::Url::parse(trimmed) {
Ok(u) => u.as_str().trim_end_matches('/').to_string(),
Err(_) => trimmed.to_string(),
}
}
pub fn matches_server(req: &CredentialRequest, server_url: &str) -> bool {
let Some((scheme, host)) = server_identity(server_url) else {
return false;
};
if req.protocol.as_deref() != Some(scheme.as_str()) {
return false;
}
let want = strip_default_port(&scheme, &host);
req.host
.as_deref()
.map(|h| strip_default_port(&scheme, h))
.is_some_and(|h| h.eq_ignore_ascii_case(&want))
}
pub fn format_response(username: &str, password: &str) -> String {
format!("username={username}\npassword={password}\n")
}
pub fn serve<R: BufRead, W: Write>(
operation: &str,
input: R,
output: &mut W,
) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load().unwrap_or_default();
serve_with(
operation,
input,
output,
config.server_url(),
config.token.as_deref(),
)
}
pub fn serve_with<R: BufRead, W: Write>(
operation: &str,
input: R,
output: &mut W,
server_url: &str,
token: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
if operation != "get" {
return Ok(());
}
let req = parse_request(input);
if !matches_server(&req, server_url) {
return Ok(());
}
let Some(token) = token.filter(|t| !t.is_empty()) else {
return Ok(());
};
output.write_all(format_response(GIT_USERNAME, token).as_bytes())?;
output.flush()?;
Ok(())
}
pub fn helper_command() -> String {
let exe = std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(String::from))
.unwrap_or_else(|| "anvil".to_string());
format!("!{} auth git-credential", shell_quote(&exe))
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
pub fn helper_config_key(server_url: &str) -> Option<String> {
let (scheme, host) = server_identity(server_url)?;
Some(format!("credential.{scheme}://{host}.helper"))
}
#[cfg(test)]
mod tests {
use super::*;
fn req(s: &str) -> CredentialRequest {
parse_request(std::io::Cursor::new(s.as_bytes()))
}
#[test]
fn parses_gits_key_value_block() {
let r = req("protocol=https\nhost=anvil.fangorn.io\npath=org/repo.git\n\n");
assert_eq!(r.protocol.as_deref(), Some("https"));
assert_eq!(r.host.as_deref(), Some("anvil.fangorn.io"));
assert_eq!(r.path.as_deref(), Some("org/repo.git"));
}
#[test]
fn a_blank_line_ends_the_request() {
let r = req("protocol=https\nhost=anvil.fangorn.io\n\nprotocol=ssh\n");
assert_eq!(r.protocol.as_deref(), Some("https"));
}
#[test]
fn eof_without_a_blank_line_is_fine() {
let r = req("protocol=https\nhost=anvil.fangorn.io");
assert_eq!(r.host.as_deref(), Some("anvil.fangorn.io"));
}
#[test]
fn unknown_keys_are_ignored_not_fatal() {
let r = req(
"protocol=https\nhost=h\nwwwauth[]=Basic realm=\"Anvil Git\"\ncapability[]=authtype\n",
);
assert_eq!(r.host.as_deref(), Some("h"));
}
#[test]
fn a_value_containing_equals_is_kept_whole() {
let r = req("protocol=https\nhost=h\npath=a=b\n");
assert_eq!(r.path.as_deref(), Some("a=b"));
}
#[test]
fn crlf_input_is_handled() {
let r = req("protocol=https\r\nhost=anvil.fangorn.io\r\n\r\n");
assert_eq!(r.host.as_deref(), Some("anvil.fangorn.io"));
}
#[test]
fn matches_the_configured_server() {
let r = req("protocol=https\nhost=anvil.fangorn.io\n");
assert!(matches_server(&r, "https://anvil.fangorn.io"));
}
#[test]
fn never_answers_for_another_host() {
let r = req("protocol=https\nhost=github.com\n");
assert!(!matches_server(&r, "https://anvil.fangorn.io"));
}
#[test]
fn a_scheme_mismatch_does_not_match() {
let r = req("protocol=http\nhost=anvil.fangorn.io\n");
assert!(!matches_server(&r, "https://anvil.fangorn.io"));
}
#[test]
fn host_comparison_is_case_insensitive() {
let r = req("protocol=https\nhost=Anvil.Fangorn.IO\n");
assert!(matches_server(&r, "https://anvil.fangorn.io"));
}
#[test]
fn a_non_default_port_must_agree() {
let r = req("protocol=http\nhost=localhost:4000\n");
assert!(matches_server(&r, "http://localhost:4000"));
assert!(!matches_server(&r, "http://localhost:4001"));
assert!(!matches_server(&r, "http://localhost"));
}
#[test]
fn a_default_port_in_the_config_still_matches_gits_bare_host() {
https://host:443
let r = req("protocol=https\nhost=anvil.fangorn.io\n");
assert!(matches_server(&r, "https://anvil.fangorn.io:443"));
}
#[test]
fn an_explicit_default_port_matches_a_bare_configured_host() {
https://host:443/…
let r = req("protocol=https\nhost=anvil.fangorn.io:443\n");
assert!(matches_server(&r, "https://anvil.fangorn.io"));
assert!(matches_server(&r, "https://anvil.fangorn.io:443"));
let r80 = req("protocol=http\nhost=localhost:80\n");
assert!(matches_server(&r80, "http://localhost"));
}
#[test]
fn stripping_the_default_port_does_not_make_other_ports_match() {
let r = req("protocol=https\nhost=anvil.fangorn.io:8443\n");
assert!(!matches_server(&r, "https://anvil.fangorn.io"));
let r80 = req("protocol=https\nhost=anvil.fangorn.io:80\n");
assert!(!matches_server(&r80, "https://anvil.fangorn.io"));
}
#[test]
fn normalizing_a_server_url_drops_only_a_redundant_port() {
assert_eq!(
normalize_server_url("https://anvil.fangorn.io:443"),
"https://anvil.fangorn.io"
);
assert_eq!(
normalize_server_url("https://anvil.fangorn.io/"),
"https://anvil.fangorn.io"
);
assert_eq!(
normalize_server_url("http://localhost:4000"),
"http://localhost:4000"
);
assert_eq!(normalize_server_url("not a url"), "not a url");
}
#[test]
fn the_config_key_and_a_normalized_url_agree_on_one_spelling() {
let server = normalize_server_url("https://anvil.fangorn.io:443");
assert_eq!(
helper_config_key(&server).as_deref(),
Some("credential.https://anvil.fangorn.io.helper")
);
assert!(matches_server(
&req("protocol=https\nhost=anvil.fangorn.io\n"),
&server
));
}
#[test]
fn a_request_missing_host_or_protocol_does_not_match() {
assert!(!matches_server(&req("protocol=https\n"), "https://a.io"));
assert!(!matches_server(&req("host=a.io\n"), "https://a.io"));
assert!(!matches_server(&req(""), "https://a.io"));
}
#[test]
fn an_unparseable_server_url_matches_nothing() {
let r = req("protocol=https\nhost=anvil.fangorn.io\n");
assert!(!matches_server(&r, "not a url"));
}
#[test]
fn store_and_erase_write_nothing_and_succeed() {
for op in ["store", "erase"] {
let mut out = Vec::new();
serve(
op,
std::io::Cursor::new(b"protocol=https\nhost=x\n".as_ref()),
&mut out,
)
.expect("op must succeed");
assert!(out.is_empty(), "{op} must not emit credentials");
}
}
fn served(host: &str, token: Option<&str>) -> String {
let mut out = Vec::new();
serve_with(
"get",
std::io::Cursor::new(format!("protocol=https\nhost={host}\n\n").into_bytes()),
&mut out,
"https://anvil.fangorn.io",
token,
)
.expect("serve must not fail");
String::from_utf8(out).unwrap()
}
#[test]
fn holding_no_token_declines_instead_of_erroring() {
assert_eq!(served("anvil.fangorn.io", None), "");
assert_eq!(served("anvil.fangorn.io", Some("")), "");
}
#[test]
fn a_held_token_is_offered_only_to_the_configured_host() {
assert_eq!(
served("anvil.fangorn.io", Some("anvil_secret")),
"username=x-token\npassword=anvil_secret\n"
);
assert_eq!(served("github.com", Some("anvil_secret")), "");
}
#[test]
fn response_has_the_shape_git_parses() {
let s = format_response("x-token", "anvil_secret");
assert_eq!(s, "username=x-token\npassword=anvil_secret\n");
}
#[test]
fn helper_command_is_a_shell_command_with_the_subcommand() {
let h = helper_command();
assert!(h.starts_with('!'), "git needs the ! prefix: {h}");
assert!(h.ends_with(" auth git-credential"), "got: {h}");
}
#[test]
fn a_path_with_spaces_or_quotes_stays_one_argument() {
assert_eq!(shell_quote("/opt/my tools/anvil"), "'/opt/my tools/anvil'");
assert_eq!(shell_quote("/o'dd/anvil"), r"'/o'\''dd/anvil'");
assert_eq!(
shell_quote(r"C:\Users\me\anvil.exe"),
r"'C:\Users\me\anvil.exe'"
);
}
#[test]
fn config_key_scopes_the_helper_to_the_server() {
assert_eq!(
helper_config_key("https://anvil.fangorn.io").as_deref(),
Some("credential.https://anvil.fangorn.io.helper")
);
assert_eq!(
helper_config_key("http://localhost:4000").as_deref(),
Some("credential.http://localhost:4000.helper")
);
assert!(helper_config_key("not a url").is_none());
}
}