use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use std::io::{self, BufRead, Write}; use std::path::PathBuf; use crate::config::manager::ConfigManager; use crate::config::{TokenConfig, TokenType}; /// Git credential helper command. /// /// Implements the git credential helper protocol /// (https://git-scm.com/docs/gitcredentials). Intended to be invoked by git /// via `credential.helper` configuration, not by end users. Hidden from the /// main help output. #[derive(Parser)] #[command(hide = true)] pub struct CredentialCommand { #[command(subcommand)] command: CredentialSubcommand, } #[derive(Subcommand)] enum CredentialSubcommand { /// Read attributes on stdin and output credentials on stdout. #[command(hide = true)] Get, /// Read attributes (including password) on stdin and store them. #[command(hide = true)] Store, /// Read attributes on stdin and erase any matching stored credentials. #[command(hide = true)] Erase, } impl CredentialCommand { pub async fn execute(&self, config_path: Option) -> Result<()> { match &self.command { CredentialSubcommand::Get => Self::get(config_path), CredentialSubcommand::Store => Self::store(config_path), CredentialSubcommand::Erase => Self::erase(config_path), } } fn get_manager(config_path: &Option) -> Result { match config_path { Some(path) => ConfigManager::with_path(path), None => ConfigManager::new(), } } /// `git credential get`: look up a PAT for the requested host and emit it /// on stdout following the git credential helper protocol. fn get(config_path: Option) -> Result<()> { let attrs = CredentialAttributes::from_stdin()?; let host = match attrs.host.as_deref() { Some(h) if !h.is_empty() => h, _ => return Ok(()), }; let service = host_to_service(host); let manager = match Self::get_manager(&config_path) { Ok(m) => m, Err(_) => return Ok(()), }; let (profile_name, pat) = match find_pat_for_service(&manager, &service) { Some(tuple) => tuple, None => return Ok(()), }; // Prefer a git-supplied username; fall back to the profile's user_name. let username = attrs.username.clone().or_else(|| { manager .get_profile(&profile_name) .map(|p| p.user_name.clone()) }); let output = CredentialAttributes { protocol: attrs.protocol.clone(), host: attrs.host.clone(), path: attrs.path.clone(), username, password: Some(pat), }; output.to_stdout()?; Ok(()) } /// `git credential store`: persist the PAT provided by git using the /// existing keyring-backed storage, associated with a matching profile. fn store(config_path: Option) -> Result<()> { let attrs = CredentialAttributes::from_stdin()?; let host = match attrs.host.as_deref() { Some(h) if !h.is_empty() => h, _ => return Ok(()), }; let service = host_to_service(host); let password = match attrs.password.as_deref() { Some(p) if !p.is_empty() => p, _ => return Ok(()), }; let mut manager = Self::get_manager(&config_path)?; let profile_name = match find_profile_for_store(&manager, attrs.username.as_deref()) { Some(name) => name, None => return Ok(()), }; // Store PAT in keyring using the existing profile-bound logic. if let Err(e) = manager.store_pat_for_profile(&profile_name, &service, password) { eprintln!("[quicommit credential] failed to store PAT: {}", e); return Ok(()); } // Register the token in the profile config if not already present. let already_has = manager .get_profile(&profile_name) .map(|p| p.tokens.contains_key(&service)) .unwrap_or(false); if !already_has { let _ = manager.add_token_to_profile( &profile_name, service.clone(), TokenConfig::new(TokenType::Personal), ); } manager.save()?; Ok(()) } /// `git credential erase`: remove any PAT stored for the requested host /// from the keyring and the associated profile config entries. fn erase(config_path: Option) -> Result<()> { let attrs = CredentialAttributes::from_stdin()?; let host = match attrs.host.as_deref() { Some(h) if !h.is_empty() => h, _ => return Ok(()), }; let service = host_to_service(host); let mut manager = Self::get_manager(&config_path)?; let profile_names: Vec = manager .list_profiles() .into_iter() .filter(|name| { manager .get_profile(name) .map(|p| p.tokens.contains_key(&service)) .unwrap_or(false) }) .cloned() .collect(); for name in &profile_names { if let Err(e) = manager.remove_token_from_profile(name, &service) { eprintln!( "[quicommit credential] failed to erase PAT for '{}': {}", name, e ); } } manager.save()?; Ok(()) } } /// Credential attributes exchanged with git via stdin/stdout following the /// git credential helper protocol (`key=value`, one per line, blank line ends). #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct CredentialAttributes { pub protocol: Option, pub host: Option, pub path: Option, pub username: Option, pub password: Option, } impl CredentialAttributes { /// Read attributes from stdin following the git credential helper protocol. pub fn from_stdin() -> Result { let stdin = io::stdin(); let mut attrs = Self::default(); for line in stdin.lock().lines() { let line = line.context("Failed to read credential attributes from stdin")?; if line.is_empty() { break; } if let Some((key, value)) = line.split_once('=') { match key { "protocol" => attrs.protocol = Some(value.to_string()), "host" => attrs.host = Some(value.to_string()), "path" => attrs.path = Some(value.to_string()), "username" => attrs.username = Some(value.to_string()), "password" => attrs.password = Some(value.to_string()), _ => {} // ignore unknown keys } } } Ok(attrs) } /// Write attributes to stdout following the git credential helper protocol. pub fn to_stdout(&self) -> Result<()> { let stdout = io::stdout(); let mut handle = stdout.lock(); if let Some(ref v) = self.protocol { writeln!(handle, "protocol={}", v)?; } if let Some(ref v) = self.host { writeln!(handle, "host={}", v)?; } if let Some(ref v) = self.path { writeln!(handle, "path={}", v)?; } if let Some(ref v) = self.username { writeln!(handle, "username={}", v)?; } if let Some(ref v) = self.password { writeln!(handle, "password={}", v)?; } writeln!(handle)?; // blank line terminates the attribute list handle.flush()?; Ok(()) } /// Parse attributes from a text block following the git credential helper /// protocol. Intended for testing and non-stdin input handling. pub fn parse_str(input: &str) -> Self { let mut attrs = Self::default(); for line in input.lines() { if line.is_empty() { break; } if let Some((key, value)) = line.split_once('=') { match key { "protocol" => attrs.protocol = Some(value.to_string()), "host" => attrs.host = Some(value.to_string()), "path" => attrs.path = Some(value.to_string()), "username" => attrs.username = Some(value.to_string()), "password" => attrs.password = Some(value.to_string()), _ => {} } } } attrs } /// Serialize attributes to a string following the git credential helper /// protocol. Intended for testing and non-stdout output handling. pub fn serialize(&self) -> String { let mut out = String::new(); if let Some(ref v) = self.protocol { out.push_str(&format!("protocol={}\n", v)); } if let Some(ref v) = self.host { out.push_str(&format!("host={}\n", v)); } if let Some(ref v) = self.path { out.push_str(&format!("path={}\n", v)); } if let Some(ref v) = self.username { out.push_str(&format!("username={}\n", v)); } if let Some(ref v) = self.password { out.push_str(&format!("password={}\n", v)); } out.push('\n'); out } } /// Map a git host to a service name used by the keyring-backed PAT storage. /// /// Common git hosting services are mapped to short canonical names. Unknown /// hosts are used as-is (lowercased, trailing slash trimmed). pub fn host_to_service(host: &str) -> String { let host = host.to_lowercase(); let host = host.trim_end_matches('/'); match host { "github.com" | "www.github.com" => "github".to_string(), "gitlab.com" | "www.gitlab.com" => "gitlab".to_string(), "bitbucket.org" | "www.bitbucket.org" => "bitbucket".to_string(), "codeberg.org" | "www.codeberg.org" => "codeberg".to_string(), "gitea.com" | "www.gitea.com" => "gitea".to_string(), "gitee.com" | "www.gitee.com" => "gitee".to_string(), other => other.to_string(), } } /// Search all profiles for one that has a PAT stored for the given service. /// Returns the profile name and the PAT value. fn find_pat_for_service(manager: &ConfigManager, service: &str) -> Option<(String, String)> { for profile_name in manager.list_profiles() { if manager.has_pat_for_profile(profile_name, service) { if let Ok(Some(pat)) = manager.get_pat_for_profile(profile_name, service) { return Some((profile_name.clone(), pat)); } } } None } /// Determine which profile to use when storing a credential. /// /// Prefers a profile whose `user_name` or `user_email` matches the /// git-supplied username; otherwise falls back to the default profile. fn find_profile_for_store( manager: &ConfigManager, username: Option<&str>, ) -> Option { if let Some(username) = username { for name in manager.list_profiles() { if let Some(profile) = manager.get_profile(name) { if profile.user_name == username || profile.user_email == username { return Some(name.clone()); } } } } manager.default_profile_name().cloned() } /// Extract a PAT for the given host from saved credentials across all profiles. /// /// This is intended for use by other parts of the application (e.g. when /// verifying access to a git hosting service) and searches every configured /// profile for a stored PAT matching the host. pub fn get_pat_for_host(host: &str) -> Result> { let manager = ConfigManager::new()?; let service = host_to_service(host); Ok(find_pat_for_service(&manager, &service).map(|(_, pat)| pat)) } #[cfg(test)] mod tests { use super::*; #[test] fn test_host_to_service_known_hosts() { assert_eq!(host_to_service("github.com"), "github"); assert_eq!(host_to_service("www.github.com"), "github"); assert_eq!(host_to_service("gitlab.com"), "gitlab"); assert_eq!(host_to_service("bitbucket.org"), "bitbucket"); assert_eq!(host_to_service("codeberg.org"), "codeberg"); assert_eq!(host_to_service("gitea.com"), "gitea"); assert_eq!(host_to_service("gitee.com"), "gitee"); } #[test] fn test_host_to_service_case_insensitive() { assert_eq!(host_to_service("GitHub.Com"), "github"); assert_eq!(host_to_service("GITLAB.COM"), "gitlab"); } #[test] fn test_host_to_service_trailing_slash() { assert_eq!(host_to_service("github.com/"), "github"); assert_eq!(host_to_service("gitlab.com//"), "gitlab"); } #[test] fn test_host_to_service_unknown_host() { assert_eq!(host_to_service("example.com"), "example.com"); assert_eq!(host_to_service("git.internal.corp"), "git.internal.corp"); } #[test] fn test_credential_attributes_parse_basic() { let input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_token123\n\n"; let attrs = CredentialAttributes::parse_str(input); assert_eq!(attrs.protocol.as_deref(), Some("https")); assert_eq!(attrs.host.as_deref(), Some("github.com")); assert_eq!(attrs.username.as_deref(), Some("octocat")); assert_eq!(attrs.password.as_deref(), Some("ghp_token123")); assert!(attrs.path.is_none()); } #[test] fn test_credential_attributes_parse_with_path() { let input = "protocol=https\nhost=github.com\npath=owner/repo.git\n\n"; let attrs = CredentialAttributes::parse_str(input); assert_eq!(attrs.path.as_deref(), Some("owner/repo.git")); } #[test] fn test_credential_attributes_parse_empty() { let attrs = CredentialAttributes::parse_str(""); assert!(attrs.protocol.is_none()); assert!(attrs.host.is_none()); assert!(attrs.username.is_none()); assert!(attrs.password.is_none()); } #[test] fn test_credential_attributes_parse_ignores_unknown_keys() { let input = "protocol=https\nhost=github.com\nunknown=value\nfoo=bar\n\n"; let attrs = CredentialAttributes::parse_str(input); assert_eq!(attrs.protocol.as_deref(), Some("https")); assert_eq!(attrs.host.as_deref(), Some("github.com")); } #[test] fn test_credential_attributes_parse_stops_at_blank_line() { let input = "protocol=https\nhost=github.com\n\npassword=should_be_ignored\n"; let attrs = CredentialAttributes::parse_str(input); assert_eq!(attrs.protocol.as_deref(), Some("https")); assert_eq!(attrs.host.as_deref(), Some("github.com")); assert!(attrs.password.is_none()); } #[test] fn test_credential_attributes_serialize_roundtrip() { let attrs = CredentialAttributes { protocol: Some("https".to_string()), host: Some("github.com".to_string()), path: None, username: Some("octocat".to_string()), password: Some("ghp_token".to_string()), }; let serialized = attrs.serialize(); let reparsed = CredentialAttributes::parse_str(&serialized); assert_eq!(attrs, reparsed); } #[test] fn test_credential_attributes_serialize_includes_blank_line() { let attrs = CredentialAttributes { protocol: Some("https".to_string()), host: Some("github.com".to_string()), path: None, username: None, password: None, }; let serialized = attrs.serialize(); assert!(serialized.ends_with("\n\n")); assert!(serialized.contains("protocol=https\n")); assert!(serialized.contains("host=github.com\n")); } #[test] fn test_credential_attributes_default() { let attrs = CredentialAttributes::default(); assert!(attrs.protocol.is_none()); assert!(attrs.host.is_none()); assert!(attrs.path.is_none()); assert!(attrs.username.is_none()); assert!(attrs.password.is_none()); } }