use std::collections::HashMap;
use std::io::{self, BufRead};
use anyhow::Result;
use tsafe_cli::cli::CredentialHelperOperation;
use tsafe_core::{audit::AuditEntry, events::emit_event, vault::Vault};
use crate::helpers::*;
#[derive(Default, Debug, Clone)]
pub(crate) struct GitCredentialRequest {
pub(crate) protocol: Option<String>,
pub(crate) host: Option<String>,
pub(crate) path: Option<String>,
pub(crate) username: Option<String>,
pub(crate) password: Option<String>,
pub(crate) url: Option<String>,
}
fn parse_git_credential_request() -> Result<GitCredentialRequest> {
let stdin = io::stdin();
let mut request = GitCredentialRequest::default();
for line in stdin.lock().lines() {
let line = line?;
if line.is_empty() {
break;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
match key {
"protocol" => request.protocol = Some(value.to_string()),
"host" => request.host = Some(value.to_string()),
"path" => request.path = Some(value.to_string()),
"username" => request.username = Some(value.to_string()),
"password" => request.password = Some(value.to_string()),
"url" => request.url = Some(value.to_string()),
_ => {}
}
}
if (request.host.is_none() || request.protocol.is_none()) && request.url.is_some() {
populate_credential_request_from_url(&mut request);
}
Ok(request)
}
fn populate_credential_request_from_url(request: &mut GitCredentialRequest) {
let Some(url) = request.url.as_deref() else {
return;
};
let Some((protocol, rest)) = url.split_once("://") else {
return;
};
if request.protocol.is_none() {
request.protocol = Some(protocol.to_string());
}
let authority_and_path = rest
.split_once('#')
.map(|(head, _)| head)
.unwrap_or(rest)
.split_once('?')
.map(|(head, _)| head)
.unwrap_or(rest);
let (authority, path) = authority_and_path
.split_once('/')
.map(|(auth, p)| (auth, Some(p)))
.unwrap_or((authority_and_path, None));
if request.path.is_none() {
if let Some(path) = path {
if !path.is_empty() {
request.path = Some(path.to_string());
}
}
}
if request.host.is_none() {
let authority = authority
.rsplit_once('@')
.map(|(_, host)| host)
.unwrap_or(authority);
let host = authority
.rsplit_once(':')
.map(|(host, port)| {
if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) {
host
} else {
authority
}
})
.unwrap_or(authority);
if !host.is_empty() {
request.host = Some(host.to_string());
}
}
}
fn normalise_credential_component(value: &str) -> String {
let mut out = String::new();
let mut last_was_separator = false;
for ch in value.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_uppercase());
last_was_separator = false;
} else if !out.is_empty() && !last_was_separator {
out.push('_');
last_was_separator = true;
}
}
let trimmed = out.trim_matches('_').to_string();
let mut result = if trimmed.is_empty() {
"_".to_string()
} else {
trimmed
};
if result
.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(false)
{
result.insert(0, '_');
}
result
}
fn credential_secret_keys(host: &str, path: Option<&str>) -> (String, String) {
let mut base = normalise_credential_component(host);
if let Some(path) = path.map(str::trim).filter(|path| !path.is_empty()) {
base.push('_');
base.push_str(&normalise_credential_component(path));
}
(format!("{base}_USERNAME"), format!("{base}_PASSWORD"))
}
fn credential_secret_key_candidates(
request: &GitCredentialRequest,
host: &str,
) -> Vec<(String, String)> {
let mut candidates = Vec::new();
if let Some(path) = request
.path
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty())
{
candidates.push(credential_secret_keys(host, Some(path)));
}
candidates.push(credential_secret_keys(host, None));
candidates.dedup();
candidates
}
fn credential_tags_match(
tags: &HashMap<String, String>,
request: &GitCredentialRequest,
host: &str,
) -> bool {
if tags.get("host").map(String::as_str) != Some(host) {
return false;
}
if let Some(protocol) = request.protocol.as_deref() {
if let Some(tag_protocol) = tags.get("protocol").map(String::as_str) {
if tag_protocol != protocol {
return false;
}
}
}
if let Some(path) = request.path.as_deref() {
if let Some(tag_path) = tags.get("path").map(String::as_str) {
if tag_path != path {
return false;
}
}
}
true
}
fn credential_tags(
existing: Option<&HashMap<String, String>>,
request: &GitCredentialRequest,
host: &str,
field: &str,
) -> HashMap<String, String> {
let mut tags = existing.cloned().unwrap_or_default();
tags.insert("type".to_string(), "git-credential".to_string());
tags.insert("credential_field".to_string(), field.to_string());
tags.insert("host".to_string(), host.to_string());
if let Some(protocol) = request.protocol.as_deref() {
tags.insert("protocol".to_string(), protocol.to_string());
}
if let Some(path) = request.path.as_deref() {
tags.insert("path".to_string(), path.to_string());
}
tags
}
fn lookup_credential_by_tags(
vault: &Vault,
request: &GitCredentialRequest,
host: &str,
) -> (Option<String>, Option<String>, Option<String>) {
let mut username = None;
let mut password = None;
let mut matched_key = None;
for key in vault.list() {
let Some(entry) = vault.file().secrets.get(key) else {
continue;
};
if !credential_tags_match(&entry.tags, request, host) {
continue;
}
let Ok(value) = vault.get(key) else {
continue;
};
let key_upper = key.to_ascii_uppercase();
let field = entry.tags.get("credential_field").map(String::as_str);
match field {
Some("username") => username = Some(value.to_string()),
Some("password") => {
password = Some(value.to_string());
matched_key = Some(key.to_string());
}
_ if key_upper.contains("USER") => username = Some(value.to_string()),
_ if key_upper.contains("PASS") || key_upper.contains("TOKEN") => {
password = Some(value.to_string());
matched_key = Some(key.to_string());
}
_ => {}
}
}
(username, password, matched_key)
}
fn cmd_credential_helper_get(profile: &str, request: &GitCredentialRequest) -> Result<()> {
let Some(host) = request.host.as_deref() else {
return Ok(());
};
let Some(vault) = open_vault_noninteractive(profile)? else {
return Ok(());
};
let canonical_keys = credential_secret_key_candidates(request, host);
let fallback_keys = credential_secret_keys(host, None);
let mut username = None;
let mut password = None;
let mut matched_key = None;
for (user_key, pass_key) in &canonical_keys {
if username.is_none() {
if let Ok(value) = vault.get(user_key) {
username = Some(value.to_string());
if matched_key.is_none() {
matched_key = Some(user_key.clone());
}
}
}
if password.is_none() {
if let Ok(value) = vault.get(pass_key) {
password = Some(value.to_string());
matched_key = Some(pass_key.clone());
}
}
if username.is_some() && password.is_some() {
break;
}
}
let (tag_username, tag_password, tag_key) = if username.is_none() || password.is_none() {
lookup_credential_by_tags(&vault, request, host)
} else {
(None, None, None)
};
username = username
.or(tag_username)
.or_else(|| request.username.clone());
password = password.or(tag_password);
if matched_key.is_none() {
matched_key = tag_key;
}
if let Some(username) = username.as_deref() {
println!("username={username}");
}
if let Some(password) = password.as_deref() {
println!("password={password}");
}
if username.is_some() || password.is_some() {
let audit_key = matched_key.as_deref().or_else(|| {
if password.is_some() {
Some(fallback_keys.1.as_str())
} else if username.is_some() {
Some(fallback_keys.0.as_str())
} else {
None
}
});
audit(profile)
.append(&AuditEntry::success(
profile,
"credential-helper-get",
audit_key,
))
.ok();
emit_event(profile, "credential-helper-get", audit_key);
}
Ok(())
}
fn cmd_credential_helper_store(profile: &str, request: &GitCredentialRequest) -> Result<()> {
let Some(host) = request.host.as_deref() else {
return Ok(());
};
if request.username.is_none() && request.password.is_none() {
return Ok(());
}
let Some(mut vault) = open_vault_noninteractive(profile)? else {
return Ok(());
};
let (user_key, pass_key) = credential_secret_keys(host, request.path.as_deref());
let mut audit_key = None;
if let Some(username) = request.username.as_deref() {
let tags = credential_tags(
vault.file().secrets.get(&user_key).map(|entry| &entry.tags),
request,
host,
"username",
);
vault.set(&user_key, username, tags)?;
audit_key = Some(user_key.as_str());
}
if let Some(password) = request.password.as_deref() {
let tags = credential_tags(
vault.file().secrets.get(&pass_key).map(|entry| &entry.tags),
request,
host,
"password",
);
vault.set(&pass_key, password, tags)?;
audit_key = Some(pass_key.as_str());
}
audit(profile)
.append(&AuditEntry::success(
profile,
"credential-helper-store",
audit_key,
))
.ok();
emit_event(profile, "credential-helper-store", audit_key);
Ok(())
}
fn cmd_credential_helper_erase(profile: &str, request: &GitCredentialRequest) -> Result<()> {
let Some(host) = request.host.as_deref() else {
return Ok(());
};
let Some(mut vault) = open_vault_noninteractive(profile)? else {
return Ok(());
};
let (user_key, pass_key) = credential_secret_keys(host, request.path.as_deref());
let mut removed = Vec::new();
for key in [&user_key, &pass_key] {
if vault.file().secrets.contains_key(key) {
vault.delete(key)?;
removed.push(key.clone());
}
}
let tagged_keys: Vec<String> = vault
.list()
.iter()
.filter_map(|key| {
let entry = vault.file().secrets.get(*key)?;
if !credential_tags_match(&entry.tags, request, host) {
return None;
}
let is_git_credential = entry.tags.get("type").map(String::as_str)
== Some("git-credential")
|| entry.tags.contains_key("credential_field");
if is_git_credential {
Some((*key).to_string())
} else {
None
}
})
.collect();
for key in tagged_keys {
if removed.contains(&key) {
continue;
}
if vault.file().secrets.contains_key(&key) {
vault.delete(&key)?;
removed.push(key);
}
}
if let Some(audit_key) = removed.last().map(String::as_str) {
audit(profile)
.append(&AuditEntry::success(
profile,
"credential-helper-erase",
Some(audit_key),
))
.ok();
emit_event(profile, "credential-helper-erase", Some(audit_key));
}
Ok(())
}
fn cmd_credential_helper_install(profile: &str, global: bool) -> Result<()> {
use colored::Colorize;
use std::process::Command;
let helper_value = if profile == "default" {
"tsafe credential-helper".to_string()
} else {
format!("tsafe --profile {profile} credential-helper")
};
let mut args = vec!["config"];
if global {
args.push("--global");
}
args.extend_from_slice(&["credential.helper", &helper_value]);
let status = Command::new("git")
.args(&args)
.status()
.map_err(|e| anyhow::anyhow!("failed to run git: {e}\n Is git installed and on PATH?"))?;
if !status.success() {
if !global {
anyhow::bail!(
"git config failed (exit {}).\n\
If you are not inside a git repository, add --global to configure user-wide:\n\
\n tsafe credential-helper install --global",
status.code().unwrap_or(1)
);
} else {
anyhow::bail!(
"git config --global failed (exit {})",
status.code().unwrap_or(1)
);
}
}
let scope = if global { "global" } else { "local" };
println!(
"{} Configured git {} credential.helper = '{helper_value}'",
"✓".green(),
scope
);
println!();
println!("git will now call tsafe to provide credentials automatically.");
println!("Store credentials in tsafe first with:");
println!(" tsafe set GITHUB_COM_USERNAME <your-username>");
println!(" tsafe set GITHUB_COM_PASSWORD <your-token>");
println!("Or let tsafe learn them the first time git asks and you provide them.");
Ok(())
}
pub(crate) fn cmd_credential_helper(
profile: &str,
action: CredentialHelperOperation,
global: bool,
) -> Result<()> {
match action {
CredentialHelperOperation::Install => cmd_credential_helper_install(profile, global),
_ => {
let request = parse_git_credential_request()?;
match action {
CredentialHelperOperation::Get => cmd_credential_helper_get(profile, &request),
CredentialHelperOperation::Store => cmd_credential_helper_store(profile, &request),
CredentialHelperOperation::Erase => cmd_credential_helper_erase(profile, &request),
CredentialHelperOperation::Install => unreachable!(),
}
}
}
}