ref:main
//! Git credential-helper plumbing: how `anvil` hands its login token to `git`.
//!
//! Anvil serves git over HTTPS with Basic auth, where the *password* is the same
//! `anvil_…` token `anvil auth login` already stored and the username is
//! ignored. So the CLI has everything git needs — the problem is only getting it
//! there without writing the secret somewhere it doesn't belong.
//!
//! Two obvious approaches are rejected here:
//!
//! * **Token in the remote URL** (`https://x-token:TOKEN@host/org/repo.git`).
//! `git clone` persists the remote into `.git/config`, so the token ends up
//! in a world-readable file in every clone, survives `auth rotate`, and gets
//! copied into any bug report that includes a git config.
//! * **Token on git's command line** (`-c http.extraHeader=…`). Every process
//! on the machine can read another process's argv via `/proc`.
//!
//! Instead git is pointed at *this binary* as a credential helper. The token
//! stays in the 0600 config file, is read only when git actually gets a 401, and
//! the only thing written into a clone's config is the command to run.
use crate::config::Config;
use std::io::{BufRead, Write};
/// The Basic-auth username sent alongside the token.
///
/// The server ignores it entirely — it dispatches on the password — but git
/// requires *some* username before it will consider a credential complete, and
/// this is the literal the Anvil server's own CI runner uses when it builds
/// clone URLs, so it's the one that shows up in server-side logs.
pub const GIT_USERNAME: &str = "x-token";
/// A parsed git credential request: the `key=value` lines git writes to a
/// helper's stdin, terminated by a blank line or EOF.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct CredentialRequest {
pub protocol: Option<String>,
/// Git sends `host` including the port when it isn't the scheme default.
pub host: Option<String>,
pub path: Option<String>,
pub username: Option<String>,
}
/// Parse git's credential description from `reader`.
///
/// Unknown keys are ignored rather than rejected: git has added fields over
/// time (`wwwauth[]`, `capability[]`, `oauth_refresh_token`) and a helper that
/// choked on an unfamiliar one would break on the next git release.
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
}
/// The `protocol` and `host` (with port, when non-default) that identify the
/// configured Anvil server in git's terms.
///
/// Returns `None` if the configured server URL has no host — a malformed
/// config, which must decline rather than match everything.
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();
// `Url::port()` is None for the scheme's default port, which is exactly
// when git omits it too — so the two agree without special-casing 443/80.
let host = match url.port() {
Some(p) => format!("{host}:{p}"),
None => host,
};
Some((scheme, host))
}
/// Drop a port that is the scheme's default.
///
/// `url::Url` elides a default port always; git preserves whichever form the
/// URL was written in, so a remote of `https://host:443/…` reaches the helper
/// as `host=host:443`. Without this the two spellings of the same server would
/// not compare equal, the helper would decline, and — with terminal prompts
/// disabled — the clone would die on "could not read Username". The Anvil web
/// UI's own clone-URL widget always interpolates the port, so this is a form
/// users really are handed.
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()
}
/// `server_url` with a redundant default port removed, so the URL handed to
/// git, the config key scoping the helper, and the host git reports back all
/// agree on one spelling.
pub fn normalize_server_url(server_url: &str) -> String {
let trimmed = server_url.trim_end_matches('/');
match url::Url::parse(trimmed) {
// `Url`'s serialization drops a default port for us.
Ok(u) => u.as_str().trim_end_matches('/').to_string(),
Err(_) => trimmed.to_string(),
}
}
/// Whether a credential request is for the configured Anvil server.
///
/// Deliberately strict about both scheme and host. A helper that answered for
/// any host would hand the Anvil token to github.com the first time git asked.
pub fn matches_server(req: &CredentialRequest, server_url: &str) -> bool {
let Some((scheme, host)) = server_identity(server_url) else {
return false;
};
// Git omits `protocol` only in odd configurations; treat a missing one as
// "unknown", not "matches".
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))
}
/// Render the `username=…\npassword=…` answer git expects.
pub fn format_response(username: &str, password: &str) -> String {
format!("username={username}\npassword={password}\n")
}
/// Serve one credential request: read git's query from `input`, write the
/// answer (if any) to `output`.
///
/// Only `get` produces output. `store` and `erase` are accepted and ignored on
/// purpose: the credential's home is the anvil config file, so there is nothing
/// to cache and nothing git should be able to delete. Both still exit
/// successfully — a helper that errors on `store` makes git print warnings
/// after an otherwise-fine fetch.
///
/// Declining (writing nothing) is also the response when the request is for
/// some other host, or when we hold no token: git then falls through to its
/// next helper or prompts, which is the correct behaviour in both cases.
pub fn serve<R: BufRead, W: Write>(
operation: &str,
input: R,
output: &mut W,
) -> Result<(), Box<dyn std::error::Error>> {
// A config we can't read or parse is "no credential", not a failure to
// report: git runs this on every fetch and push, and an error here would
// print `Error: config parse error: …` on each one while git fell back to
// prompting anyway. Declining leaves git to its next helper, and
// `anvil auth status` is where a broken config gets diagnosed.
let config = Config::load().unwrap_or_default();
serve_with(
operation,
input,
output,
config.server_url(),
config.token.as_deref(),
)
}
/// [`serve`], with the credential supplied rather than read from disk.
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(())
}
/// The `credential.<url>.helper` value that invokes this binary.
///
/// The leading `!` tells git to run the string as a shell command, which is the
/// only form that reliably carries arguments and survives a path with spaces
/// (single-quoted below). Falls back to the bare name `anvil` when the
/// executable's own path can't be determined, so a `$PATH` install still works.
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))
}
/// Wrap `s` in single quotes for a POSIX shell, escaping any it contains.
///
/// Git runs `!`-prefixed helpers through `sh` (including Git for Windows, via
/// its bundled shell), where single quotes also stop backslashes from being
/// interpreted — so a Windows path survives unmangled.
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
/// The git config key that scopes a helper to one server, e.g.
/// `credential.https://anvil.fangorn.io.helper`.
///
/// Scoping matters: an unscoped `credential.helper` would offer the Anvil token
/// for every host git talks to.
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() {
// Git keeps the pipe open after the blank line in some flows; reading
// past it would block or absorb the next 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() {
// Newer gits send wwwauth[]/capability[]; an older helper must cope.
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"));
}
// ── host matching ─────────────────────────────────────────────────
#[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() {
// The failure this prevents is handing the Anvil token to github.com.
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() {
// Answering an http:// challenge would send the token in cleartext.
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() {
// Git drops :443 from `host`; url::Url drops it from `port()`. They
// must agree or every clone of a `https://host:443` config fails.
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() {
// Git preserves whichever spelling the URL used, so a remote of
// `https://host:443/…` reaches the helper as `host=host:443` (verified
// against real git). Declining there meant the helper refused its own
// clone, and with terminal prompts off the clone died on "could not
// read Username". The Anvil web UI hands out exactly this form.
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"));
// :80 is not the default for https and must not be stripped.
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"
);
// Not a URL: left exactly as given rather than mangled.
assert_eq!(normalize_server_url("not a url"), "not a url");
}
#[test]
fn the_config_key_and_a_normalized_url_agree_on_one_spelling() {
// These three have to line up or the helper never gets consulted.
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"));
}
// ── serve ─────────────────────────────────────────────────────────
#[test]
fn store_and_erase_write_nothing_and_succeed() {
// Git calls `store` after every successful authentication. Erroring
// there would print a warning on every fetch.
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");
}
}
/// `serve_with` over a request for `host`, returning what it wrote.
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() {
// This is also the path a corrupt or unreadable config takes: `serve`
// falls back to a default config rather than propagating an error, so
// git gets a clean decline on every fetch instead of a printed error.
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");
}
// ── helper command ────────────────────────────────────────────────
#[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'");
// Windows backslashes are literal inside single quotes.
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());
}
}