use k8s_openapi::api::core::v1::Secret;
use kube::{Api, Client};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use super::auth::GitCredential;
use super::error::{GitError, Result};
const SERVICE_ACCOUNT_NAMESPACE_PATH: &str = "/var/run/secrets/kubernetes.io/serviceaccount/namespace";
const DEFAULT_ARGOCD_NAMESPACE: &str = "argocd";
pub struct ArgoCDCredentialDiscovery {
client: Client,
namespace: String,
secret_cache: Arc<Mutex<HashMap<String, Secret>>>,
}
impl ArgoCDCredentialDiscovery {
pub fn new(client: Client) -> Result<Self> {
let namespace = Self::detect_namespace().unwrap_or_else(|| DEFAULT_ARGOCD_NAMESPACE.to_string());
tracing::debug!("Using namespace '{}' for ArgoCD credential discovery", namespace);
Ok(Self {
client,
namespace,
secret_cache: Arc::new(Mutex::new(HashMap::new())),
})
}
pub fn with_namespace(client: Client, namespace: String) -> Result<Self> {
Ok(Self {
client,
namespace,
secret_cache: Arc::new(Mutex::new(HashMap::new())),
})
}
fn detect_namespace() -> Option<String> {
std::fs::read_to_string(SERVICE_ACCOUNT_NAMESPACE_PATH)
.ok()
.map(|ns| ns.trim().to_string())
}
pub async fn discover_credentials(&self) -> Result<HashMap<String, GitCredential>> {
let secrets = self.query_repository_secrets().await?;
tracing::debug!(
"Discovered {} ArgoCD repository secret candidates in namespace {}",
secrets.len(),
self.namespace
);
let mut credentials = HashMap::new();
for (name, secret) in secrets {
match Self::extract_credential_from_secret(&name, &secret) {
Ok(Some((url, credential))) => {
credentials.insert(url, credential);
}
Ok(None) => {
tracing::debug!("Skipping secret {} - incomplete credential data", name);
}
Err(e) => {
tracing::warn!("Failed to extract credential from secret {}: {}", name, e);
}
}
}
tracing::debug!(
"Extracted {} usable Git credentials from ArgoCD secrets in namespace {}",
credentials.len(),
self.namespace
);
Ok(credentials)
}
pub async fn find_credential_for_url(&self, url: &str) -> Result<Option<GitCredential>> {
let secrets = self.query_repository_secrets().await?;
let mut exact_match: Option<GitCredential> = None;
let mut pattern_match: Option<GitCredential> = None;
let mut hostname_match: Option<GitCredential> = None;
for (name, secret) in &secrets {
let secret_type = secret
.metadata
.labels
.as_ref()
.and_then(|labels| labels.get("argocd.argoproj.io/secret-type"))
.map_or("unknown", |s| s.as_str());
if let Ok(Some((secret_url, credential))) = Self::extract_credential_from_secret(name, secret) {
match secret_type {
"repository" => {
let normalized_secret = normalize_url_for_matching(&secret_url);
let normalized_requested = normalize_url_for_matching(url);
if normalized_secret == normalized_requested {
exact_match = Some(credential);
} else if matches_repository_url(&secret_url, url) && hostname_match.is_none() {
hostname_match = Some(credential);
}
}
"repo-creds" => {
if matches_repo_creds_pattern(&secret_url, url) && pattern_match.is_none() {
pattern_match = Some(credential);
}
}
_ => {
tracing::debug!("Unknown secret type: {}", secret_type);
}
}
}
}
Ok(exact_match.or(pattern_match).or(hostname_match))
}
async fn query_repository_secrets(&self) -> Result<HashMap<String, Secret>> {
{
let cache = self.secret_cache.lock().unwrap();
if !cache.is_empty() {
return Ok(cache.clone());
}
}
let secrets_api: Api<Secret> = Api::namespaced(self.client.clone(), &self.namespace);
let repo_lp = kube::api::ListParams::default().labels("argocd.argoproj.io/secret-type=repository");
let repo_secrets = secrets_api
.list(&repo_lp)
.await
.map_err(|e| GitError::ArgoCDSecretQueryFailed(e.to_string()))?;
tracing::trace!(
"Found {} secrets labeled repository in namespace {}",
repo_secrets.items.len(),
self.namespace
);
let creds_lp = kube::api::ListParams::default().labels("argocd.argoproj.io/secret-type=repo-creds");
let creds_secrets = secrets_api
.list(&creds_lp)
.await
.map_err(|e| GitError::ArgoCDSecretQueryFailed(e.to_string()))?;
tracing::trace!(
"Found {} secrets labeled repo-creds in namespace {}",
creds_secrets.items.len(),
self.namespace
);
let mut secrets = HashMap::new();
for secret in repo_secrets.items.into_iter().chain(creds_secrets.items.into_iter()) {
let repo_type = secret
.data
.as_ref()
.and_then(|d| d.get("type"))
.and_then(|t| String::from_utf8(t.0.clone()).ok())
.unwrap_or_else(|| "git".to_string());
if repo_type == "git" {
if let Some(name) = secret.metadata.name.clone() {
secrets.insert(name, secret);
}
} else {
tracing::debug!(
"Skipping secret {} with type={}",
secret.metadata.name.as_deref().unwrap_or("unknown"),
repo_type
);
}
}
{
let mut cache = self.secret_cache.lock().unwrap();
(*cache).clone_from(&secrets);
}
Ok(secrets)
}
fn extract_credential_from_secret(secret_name: &str, secret: &Secret) -> Result<Option<(String, GitCredential)>> {
let Some(data) = &secret.data else { return Ok(None) };
let url = match data.get("url") {
Some(url_bytes) => decode_base64_string(url_bytes)?,
None => return Ok(None),
};
if let Some(ssh_private_key_bytes) = data.get("sshPrivateKey") {
let private_key = decode_base64_string(ssh_private_key_bytes)?;
let username = data
.get("username")
.and_then(|u| decode_base64_string(u).ok())
.unwrap_or_else(|| "git".to_string());
let credential = GitCredential::SshKey {
username,
private_key,
public_key: None,
passphrase: None,
};
Ok(Some((url, credential)))
} else if let Some(username_bytes) = data.get("username") {
let username = decode_base64_string(username_bytes)?;
let token = match data.get("password") {
Some(password_bytes) => decode_base64_string(password_bytes)?,
None => {
return Err(GitError::InvalidCredentialFormat {
secret_name: secret_name.to_string(),
reason: "HTTPS credential missing password field".to_string(),
})
}
};
let credential = GitCredential::HttpsToken { username, token };
Ok(Some((url, credential)))
} else {
Ok(None)
}
}
}
fn decode_base64_string(bytes: &k8s_openapi::ByteString) -> Result<String> {
String::from_utf8(bytes.0.clone()).map_err(|e| GitError::InvalidCredentialFormat {
secret_name: "unknown".to_string(),
reason: format!("Invalid UTF-8 in base64 data: {}", e),
})
}
pub fn matches_repository_url(secret_url: &str, requested_url: &str) -> bool {
let normalized_secret = normalize_url_for_matching(secret_url);
let normalized_requested = normalize_url_for_matching(requested_url);
if normalized_secret == normalized_requested {
return true;
}
extract_hostname_for_matching(&normalized_secret)
.zip(extract_hostname_for_matching(&normalized_requested))
.is_some_and(|(h1, h2)| h1 == h2)
}
pub fn matches_repo_creds_pattern(pattern: &str, url: &str) -> bool {
let normalized_pattern = normalize_url_for_matching(pattern);
let normalized_url = normalize_url_for_matching(url);
let escaped = regex::escape(&normalized_pattern.replace('*', "\x00")).replace('\x00', ".*");
let regex_pattern = format!("^{}$", escaped);
if let Ok(re) = regex::Regex::new(®ex_pattern) {
re.is_match(&normalized_url)
} else {
tracing::warn!("Invalid regex pattern generated from: {}", pattern);
false
}
}
fn normalize_url_for_matching(url: &str) -> String {
let mut normalized = url.to_lowercase();
if std::path::Path::new(&normalized)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("git"))
{
normalized = normalized[..normalized.len() - 4].to_string();
}
if let Some(at_pos) = normalized.find('@') {
if !normalized.starts_with("http") && !normalized.starts_with("ssh://") {
if let Some(colon_pos) = normalized[at_pos..].find(':') {
let username_host = &normalized[..at_pos + colon_pos];
let path = &normalized[at_pos + colon_pos + 1..];
normalized = format!("ssh://{}/{}", username_host, path);
}
}
}
if normalized.ends_with('/') {
normalized = normalized[..normalized.len() - 1].to_string();
}
normalized
}
fn extract_hostname_for_matching(url: &str) -> Option<String> {
if let Some(at_pos) = url.find('@') {
if !url.starts_with("http") && !url.starts_with("ssh://") {
if let Some(colon_pos) = url[at_pos..].find(':') {
let host = &url[at_pos + 1..at_pos + colon_pos];
return Some(host.to_lowercase());
}
}
}
if let Some(scheme_end) = url.find("://") {
let after_scheme = &url[scheme_end + 3..];
let host_end = after_scheme
.find('/')
.or_else(|| after_scheme.find(':'))
.unwrap_or(after_scheme.len());
let host_part = &after_scheme[..host_end];
if let Some(at_pos) = host_part.rfind('@') {
return Some(host_part[at_pos + 1..].to_lowercase());
} else {
return Some(host_part.to_lowercase());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_url_matching() {
assert!(matches_repository_url(
"https://github.com/org/repo",
"https://github.com/org/repo"
));
assert!(matches_repository_url(
"https://github.com/org/repo.git",
"https://github.com/org/repo"
));
assert!(matches_repository_url(
"git@github.com:org/repo",
"ssh://git@github.com/org/repo"
));
assert!(matches_repository_url(
"https://github.com/org/repo1",
"https://github.com/org/repo2"
));
assert!(!matches_repository_url(
"https://github.com/org/repo",
"https://gitlab.com/org/repo"
));
}
#[test]
fn test_normalize_url() {
assert_eq!(
normalize_url_for_matching("https://github.com/org/repo.git"),
"https://github.com/org/repo"
);
assert_eq!(
normalize_url_for_matching("git@github.com:org/repo.git"),
"ssh://git@github.com/org/repo"
);
}
#[test]
fn test_extract_hostname() {
assert_eq!(
extract_hostname_for_matching("https://github.com/org/repo"),
Some("github.com".to_string())
);
assert_eq!(
extract_hostname_for_matching("git@github.com:org/repo"),
Some("github.com".to_string())
);
assert_eq!(
extract_hostname_for_matching("ssh://git@gitlab.com/org/repo"),
Some("gitlab.com".to_string())
);
}
}