use std::{ path::Path, process::{Command, ExitStatus, Output, Stdio}, }; use tracing::{debug, debug_span}; use url::Url; use crate::{bitbucket::is_bitbucket_access_token, git_url::GitUrl}; const BITBUCKET_CREDENTIAL_HELPER: &str = r#"credential.helper=!_bbcreds() { if [ -n "$KF_BITBUCKET_OAUTH_TOKEN" ]; then echo username="x-token-auth"; echo password="$KF_BITBUCKET_OAUTH_TOKEN"; return; fi if [ -n "$KF_BITBUCKET_ACCESS_TOKEN" ]; then echo username="x-token-auth"; echo password="$KF_BITBUCKET_ACCESS_TOKEN"; return; fi if [ -n "$KF_BITBUCKET_USERNAME" ]; then bb_pass="${KF_BITBUCKET_APP_PASSWORD:-${KF_BITBUCKET_TOKEN:-${KF_BITBUCKET_PASSWORD:-}}}"; if [ -n "$bb_pass" ]; then echo username="$KF_BITBUCKET_USERNAME"; echo password="$bb_pass"; return; fi fi }; _bbcreds"#; const GITEA_CREDENTIAL_HELPER: &str = r#"credential.helper=!_gteacreds() { if [ -n "$KF_GITEA_TOKEN" ]; then user="${KF_GITEA_USERNAME:-gitea}"; echo username="$user"; echo password="$KF_GITEA_TOKEN"; fi }; _gteacreds"#; const AZURE_CREDENTIAL_HELPER: &str = r#"credential.helper=!_azcreds() { token="${KF_AZURE_TOKEN:-${KF_AZURE_PAT:-}}"; if [ -n "$token" ]; then user="${KF_AZURE_USERNAME:-pat}"; echo username="$user"; echo password="$token"; fi }; _azcreds"#; const HUGGINGFACE_CREDENTIAL_HELPER: &str = r#"credential.helper=!_hfcreds() { token="$KF_HUGGINGFACE_TOKEN"; if [ -n "$token" ]; then user="${KF_HUGGINGFACE_USERNAME:-hf_user}"; echo username="$user"; echo password="$token"; fi }; _hfcreds"#; /// Represents errors that can occur when interacting with the `git` CLI. #[derive(Debug, thiserror::Error)] pub enum GitError { #[error("git execution failed: {0}")] IOError(#[from] std::io::Error), #[error( "git execution failed (status: {status}){summary}", status = format_exit_status(.status), summary = format_git_error_summary(.stdout.as_slice(), .stderr.as_slice()) )] GitError { stdout: Vec, stderr: Vec, status: ExitStatus }, } fn format_exit_status(status: &ExitStatus) -> String { status.code().map(|code| code.to_string()).unwrap_or_else(|| status.to_string()) } fn format_git_error_summary(stdout: &[u8], stderr: &[u8]) -> String { let mut messages = Vec::new(); if let Some(line) = summarize_output(stderr) { messages.push(line); } if let Some(line) = summarize_output(stdout) { messages.push(line); } if messages.is_empty() { String::new() } else { format!(": {}", messages.join(" | ")) } } fn summarize_output(output: &[u8]) -> Option { let text = String::from_utf8_lossy(output); text.lines().map(str::trim).find(|line| !line.is_empty()).map(|line| line.to_owned()) } /// A helper struct for running `git` commands. /// /// It supports optional GitHub, GitLab, Gitea, and Bitbucket credentials passed via /// environment variables and optionally ignores TLS certificate validation if /// requested. pub struct Git { credentials: Vec, ignore_certs: bool, bitbucket_access_token: Option, bitbucket_env: Vec<(String, String)>, bitbucket_basic_auth: Option<(String, String)>, } impl Git { /// Create a new `Git` instance. /// /// * `ignore_certs`: If `true`, disables SSL certificate verification for `git` operations. pub fn new(ignore_certs: bool) -> Self { let mut credentials = Vec::new(); fn normalized_env_var(name: &str) -> Option { std::env::var(name) .ok() .map(|value| value.trim().to_owned()) .filter(|value| !value.is_empty()) } let bitbucket_username = normalized_env_var("KF_BITBUCKET_USERNAME"); let bitbucket_app_password = normalized_env_var("KF_BITBUCKET_APP_PASSWORD"); let bitbucket_token = normalized_env_var("KF_BITBUCKET_TOKEN"); let bitbucket_password = normalized_env_var("KF_BITBUCKET_PASSWORD"); let bitbucket_oauth_token = normalized_env_var("KF_BITBUCKET_OAUTH_TOKEN"); let mut bitbucket_env = Vec::new(); for (key, value) in [ ("KF_BITBUCKET_USERNAME", bitbucket_username.as_ref()), ("KF_BITBUCKET_APP_PASSWORD", bitbucket_app_password.as_ref()), ("KF_BITBUCKET_TOKEN", bitbucket_token.as_ref()), ("KF_BITBUCKET_PASSWORD", bitbucket_password.as_ref()), ("KF_BITBUCKET_OAUTH_TOKEN", bitbucket_oauth_token.as_ref()), ] { if let Some(value) = value { bitbucket_env.push((key.to_string(), value.to_string())); } } let has_github_token = matches!(std::env::var("KF_GITHUB_TOKEN"), Ok(token) if !token.is_empty()); let has_gitlab_token = matches!(std::env::var("KF_GITLAB_TOKEN"), Ok(token) if !token.is_empty()); let has_gitea_token = matches!(std::env::var("KF_GITEA_TOKEN"), Ok(token) if !token.is_empty()); let bitbucket_access_token = bitbucket_token.as_ref().filter(|token| is_bitbucket_access_token(token)).cloned(); let bitbucket_basic_password = bitbucket_app_password .clone() .or(bitbucket_token.clone()) .or(bitbucket_password.clone()); let bitbucket_basic_auth = if let Some(token) = bitbucket_oauth_token.clone() { Some(("x-token-auth".to_string(), token)) } else if let Some(token) = bitbucket_access_token.clone() { Some(("x-token-auth".to_string(), token)) } else if let (Some(username), Some(password)) = (bitbucket_username.clone(), bitbucket_basic_password.clone()) { Some((username, password)) } else if let Some(token) = bitbucket_token.clone() { // Allow token-only authentication (common for x-token-auth URLs). Some(("x-token-auth".to_string(), token)) } else { None }; let has_bitbucket_username = bitbucket_username.is_some(); let has_bitbucket_password = bitbucket_app_password.is_some() || bitbucket_token.is_some() || bitbucket_password.is_some(); let has_bitbucket_oauth_token = bitbucket_oauth_token.is_some(); let has_bitbucket_credentials = has_bitbucket_oauth_token || bitbucket_access_token.is_some() || bitbucket_token.is_some() || (has_bitbucket_username && has_bitbucket_password); let has_azure_token = ["KF_AZURE_TOKEN", "KF_AZURE_PAT"] .iter() .any(|key| matches!(std::env::var(key), Ok(value) if !value.is_empty())); let has_huggingface_token = matches!(std::env::var("KF_HUGGINGFACE_TOKEN"), Ok(value) if !value.is_empty()); // If credentials are provided via environment variables, clear existing helpers first. if has_github_token || has_gitlab_token || has_gitea_token || has_bitbucket_credentials || has_azure_token || has_huggingface_token { credentials.push("-c".into()); credentials.push(r#"credential.helper="#.into()); } // Inject GitHub token helper if has_github_token { credentials.push("-c".into()); credentials.push( r#"credential.helper=!_ghcreds() { echo username="kingfisher"; echo password="$KF_GITHUB_TOKEN"; }; _ghcreds"#.into(), ); } // Inject GitLab token helper if has_gitlab_token { credentials.push("-c".into()); credentials.push( r#"credential.helper=!_glcreds() { echo username="oauth2"; echo password="$KF_GITLAB_TOKEN"; }; _glcreds"#.into(), ); } // Inject Gitea token helper if has_gitea_token { credentials.push("-c".into()); credentials.push(GITEA_CREDENTIAL_HELPER.into()); } // Inject Bitbucket credential helper for OAuth tokens or basic auth. if has_bitbucket_credentials { credentials.push("-c".into()); credentials.push(BITBUCKET_CREDENTIAL_HELPER.into()); } if has_azure_token { credentials.push("-c".into()); credentials.push(AZURE_CREDENTIAL_HELPER.into()); } if has_huggingface_token { credentials.push("-c".into()); credentials.push(HUGGINGFACE_CREDENTIAL_HELPER.into()); } Self { credentials, ignore_certs, bitbucket_access_token, bitbucket_env, bitbucket_basic_auth, } } /// Create a basic `git` `Command` with environment variables set to /// limit config usage and (optionally) ignore certs. Includes credentials /// if GitHub, GitLab, or Bitbucket tokens are present. fn git(&self) -> Command { let mut cmd = Command::new("git"); cmd.env("GIT_CONFIG_GLOBAL", "/dev/null"); cmd.env("GIT_CONFIG_NOSYSTEM", "1"); cmd.env("GIT_CONFIG_SYSTEM", "/dev/null"); cmd.env("GIT_TERMINAL_PROMPT", "0"); if self.ignore_certs { cmd.env("GIT_SSL_NO_VERIFY", "1"); } for (key, value) in &self.bitbucket_env { cmd.env(key, value); } if let Some(token) = &self.bitbucket_access_token { cmd.env("KF_BITBUCKET_ACCESS_TOKEN", token); } cmd.args(&self.credentials); cmd.stdin(Stdio::null()); cmd } /// Helper to run the constructed `git` command and capture its output. /// /// Returns an error if the command fails or exits with a non-zero status. fn run_cmd(&self, mut cmd: Command) -> Result<(), GitError> { debug!("{cmd:#?}"); let output: Output = cmd.output()?; if !output.status.success() { return Err(GitError::GitError { stdout: output.stdout, stderr: output.stderr, status: output.status, }); } Ok(()) } /// Update an existing bare or mirror clone by running `git remote update --prune`. /// /// * `repo_url`: The remote repository URL (only used for logging). /// * `output_dir`: The path to the existing bare/mirror clone. pub fn update_clone(&self, repo_url: &GitUrl, output_dir: &Path) -> Result<(), GitError> { let _span = debug_span!("git_update", "{repo_url} {}", output_dir.display()).entered(); debug!("Attempting to update clone of {repo_url} at {}", output_dir.display()); let mut cmd = self.git(); if output_dir.join(".git").is_dir() { cmd.arg("-C"); cmd.arg(output_dir); } else { cmd.arg("--git-dir"); cmd.arg(output_dir); } cmd.arg("remote"); cmd.arg("update"); cmd.arg("--prune"); debug!("{cmd:#?}"); self.run_cmd(cmd) } /// Create a fresh clone of the specified repository in either bare or mirror mode. /// /// * `repo_url`: The remote repository URL. /// * `output_dir`: Where to place the newly created clone. /// * `clone_mode`: Whether to clone as `--bare` or `--mirror`. pub fn create_fresh_clone( &self, repo_url: &GitUrl, output_dir: &Path, clone_mode: CloneMode, ) -> Result<(), GitError> { let _span = debug_span!("git_clone", "{repo_url} {}", output_dir.display()).entered(); debug!("Attempting to create fresh clone of {} at {}", repo_url, output_dir.display()); let mut cmd = self.git(); cmd.arg("clone"); if let Some(arg) = clone_mode.arg() { cmd.arg(arg); } cmd.arg("--quiet"); cmd.arg("-c"); cmd.arg("remote.origin.fetch=+refs/*:refs/remotes/origin/*"); cmd.arg(self.repo_arg_for_clone(repo_url)); cmd.arg(output_dir); debug!("{cmd:#?}"); self.run_cmd(cmd) } fn repo_arg_for_clone(&self, repo_url: &GitUrl) -> String { if let Some((username, password)) = &self.bitbucket_basic_auth { if let Ok(mut url) = Url::parse(repo_url.as_str()) { if url .host_str() .map(|host| host.eq_ignore_ascii_case("bitbucket.org")) .unwrap_or(false) { if url.set_username(username).is_ok() && url.set_password(Some(password)).is_ok() { return url.into(); } } } } repo_url.as_str().to_string() } } impl Default for Git { /// Equivalent to `Git::new(false)` fn default() -> Self { Self::new(false) } } /// Represents how a repository is cloned. #[derive(Debug, Clone, Copy)] pub enum CloneMode { /// Equivalent to `git clone --bare` Bare, /// Equivalent to `git clone --mirror` Mirror, /// Standard clone with a working tree Checkout, } impl CloneMode { /// Return the CLI argument for this clone mode. pub fn arg(&self) -> Option<&str> { match self { Self::Bare => Some("--bare"), Self::Mirror => Some("--mirror"), Self::Checkout => None, } } } #[cfg(test)] mod tests { use tempfile::TempDir; use super::*; #[test] fn test_git_new() { let git = Git::new(false); assert!(!git.ignore_certs); assert!(git.credentials.is_empty()); assert!(git.bitbucket_access_token.is_none()); temp_env::with_var("KF_GITHUB_TOKEN", Some("test_token"), || { let git = Git::new(false); assert_eq!(git.credentials.len(), 4); }); } #[test] fn test_git_new_bitbucket_oauth() { temp_env::with_var("KF_BITBUCKET_OAUTH_TOKEN", Some("oauth"), || { let git = Git::new(false); assert_eq!(git.credentials.len(), 4); assert!(git.credentials.iter().any(|value| value == BITBUCKET_CREDENTIAL_HELPER)); assert!(git.bitbucket_access_token.is_none()); }); } #[test] fn test_git_new_bitbucket_basic_auth() { temp_env::with_vars( &[ ("KF_BITBUCKET_USERNAME", Some("user")), ("KF_BITBUCKET_APP_PASSWORD", Some("password")), ], || { let git = Git::new(false); assert_eq!(git.credentials.len(), 4); assert!(git.credentials.iter().any(|value| value == BITBUCKET_CREDENTIAL_HELPER)); assert!(git.bitbucket_access_token.is_none()); }, ); } #[test] fn test_repo_arg_for_clone_includes_bitbucket_app_password() { let url = GitUrl::try_from(url::Url::parse("https://bitbucket.org/workspace/demo.git").unwrap()) .unwrap(); temp_env::with_vars( &[ ("KF_BITBUCKET_USERNAME", Some("user")), ("KF_BITBUCKET_APP_PASSWORD", Some("secret")), ], || { let git = Git::new(false); assert_eq!( git.repo_arg_for_clone(&url), "https://user:secret@bitbucket.org/workspace/demo.git" ); }, ); } #[test] fn test_repo_arg_for_clone_uses_token_auth_when_available() { let url = GitUrl::try_from(url::Url::parse("https://bitbucket.org/workspace/demo.git").unwrap()) .unwrap(); temp_env::with_vars(&[("KF_BITBUCKET_OAUTH_TOKEN", Some("token123"))], || { let git = Git::new(false); assert_eq!( git.repo_arg_for_clone(&url), "https://x-token-auth:token123@bitbucket.org/workspace/demo.git" ); }); } #[test] fn test_repo_arg_for_clone_uses_token_only_auth() { let url = GitUrl::try_from(url::Url::parse("https://bitbucket.org/workspace/demo.git").unwrap()) .unwrap(); temp_env::with_vars(&[("KF_BITBUCKET_TOKEN", Some("token123"))], || { let git = Git::new(false); assert_eq!( git.repo_arg_for_clone(&url), "https://x-token-auth:token123@bitbucket.org/workspace/demo.git" ); }); } #[test] fn test_repo_arg_for_clone_leaves_non_bitbucket_urls_untouched() { let url = GitUrl::try_from( url::Url::parse("https://github.com/octocat/Hello-World.git").unwrap(), ) .unwrap(); temp_env::with_vars( &[ ("KF_BITBUCKET_USERNAME", Some("user")), ("KF_BITBUCKET_APP_PASSWORD", Some("secret")), ], || { let git = Git::new(false); assert_eq!(git.repo_arg_for_clone(&url), url.as_str()); }, ); } #[test] fn test_git_new_bitbucket_access_token() { let token = "AT1234567890_ACCESS_TOKEN_EXAMPLE_WITH_UNDERSCORE"; temp_env::with_var("KF_BITBUCKET_TOKEN", Some(token), || { let git = Git::new(false); assert_eq!(git.credentials.len(), 4); assert!(git.credentials.iter().any(|value| value == BITBUCKET_CREDENTIAL_HELPER)); assert_eq!(git.bitbucket_access_token.as_deref(), Some(token)); }); } #[test] fn test_git_new_bitbucket_token_without_username() { temp_env::with_var("KF_BITBUCKET_TOKEN", Some("token123"), || { let git = Git::new(false); assert_eq!(git.credentials.len(), 4); assert!(git.credentials.iter().any(|value| value == BITBUCKET_CREDENTIAL_HELPER)); assert_eq!(git.bitbucket_access_token.as_deref(), None); assert_eq!( git.bitbucket_basic_auth, Some(("x-token-auth".to_string(), "token123".to_string())) ); }); } #[test] fn test_git_new_bitbucket_trims_whitespace() { let trimmed_token = "AT1234567890_ACCESS_TOKEN_EXAMPLE_WITH_UNDERSCORE"; let token = format!(" {trimmed_token} \n"); temp_env::with_vars( &[("KF_BITBUCKET_USERNAME", Some(" user\n")), ("KF_BITBUCKET_TOKEN", Some(&token))], || { let git = Git::new(false); assert_eq!( git.bitbucket_env, vec![ ("KF_BITBUCKET_USERNAME".to_string(), "user".to_string()), ("KF_BITBUCKET_TOKEN".to_string(), trimmed_token.to_string(),), ], ); assert_eq!(git.credentials.len(), 4); assert!(git.credentials.iter().any(|value| value == BITBUCKET_CREDENTIAL_HELPER)); assert_eq!(git.bitbucket_access_token.as_deref(), Some(trimmed_token)); }, ); } #[test] fn test_clone_mode_arg() { assert_eq!(CloneMode::Bare.arg(), Some("--bare")); assert_eq!(CloneMode::Mirror.arg(), Some("--mirror")); assert_eq!(CloneMode::Checkout.arg(), None); } #[test] fn test_create_fresh_clone() -> Result<(), GitError> { let temp_dir = TempDir::new()?; let git = Git::default(); let url = GitUrl::try_from( url::Url::parse("https://github.com/octocat/Hello-World.git").unwrap(), ) .unwrap(); git.create_fresh_clone(&url, temp_dir.path(), CloneMode::Bare)?; assert!(temp_dir.path().join("HEAD").exists()); Ok(()) } #[test] fn test_update_clone() -> Result<(), GitError> { let temp_dir = TempDir::new()?; let git = Git::default(); let url = GitUrl::try_from( url::Url::parse("https://github.com/octocat/Hello-World.git").unwrap(), ) .unwrap(); git.create_fresh_clone(&url, temp_dir.path(), CloneMode::Bare)?; git.update_clone(&url, temp_dir.path())?; Ok(()) } #[test] fn test_git_error() { let temp_dir = TempDir::new().unwrap(); let git = Git::default(); let invalid_url = GitUrl::try_from(url::Url::parse("https://invalid.git").unwrap()).unwrap(); let err = git.create_fresh_clone(&invalid_url, temp_dir.path(), CloneMode::Bare).unwrap_err(); assert!(matches!(err, GitError::GitError { .. })); } }