kingfisher/src/git_binary.rs
2026-04-17 16:53:21 -07:00

594 lines
21 KiB
Rust

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<u8>, stderr: Vec<u8>, 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<String> {
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<String>,
ignore_certs: bool,
bitbucket_access_token: Option<String>,
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<String> {
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() {
temp_env::with_vars(
&[
("KF_GITHUB_TOKEN", None::<&str>),
("KF_BITBUCKET_OAUTH_TOKEN", None::<&str>),
("KF_BITBUCKET_ACCESS_TOKEN", None::<&str>),
("KF_BITBUCKET_USERNAME", None::<&str>),
("KF_BITBUCKET_APP_PASSWORD", None::<&str>),
],
|| {
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 { .. }));
}
}