496 lines
15 KiB
Rust
496 lines
15 KiB
Rust
use std::path::{Path, PathBuf};
|
|
|
|
use sea_orm::DatabaseConnection;
|
|
|
|
use crate::db::service::app_metadata_service;
|
|
use crate::models::system::{GitHubAccount, GitHubAccountsSettings};
|
|
|
|
const GITHUB_ACCOUNTS_KEY: &str = "github_accounts";
|
|
|
|
/// Create a credential helper that calls the app binary directly with
|
|
/// `--credential-helper` flag. The app binary opens the DB, looks up
|
|
/// the matching account, and outputs credentials to stdout.
|
|
///
|
|
/// This is the simplest and most reliable approach:
|
|
/// - No HTTP server, no temp credential files
|
|
/// - Always reads latest accounts from DB
|
|
/// - No special-character / encoding issues (Rust handles strings natively)
|
|
/// - Single shared script across all terminals
|
|
pub fn create_credential_helper_script(
|
|
app_data_dir: &Path,
|
|
app_binary_path: &Path,
|
|
) -> std::io::Result<PathBuf> {
|
|
let binary_str = app_binary_path.to_string_lossy();
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
let script_path = app_data_dir.join("git-credential-codeg.sh");
|
|
let content = format!(
|
|
r#"#!/bin/sh
|
|
# Codeg credential helper — calls the app binary to look up credentials.
|
|
# Only responds to "get" action; ignores "store" and "erase".
|
|
[ "$1" != "get" ] && exit 0
|
|
exec "{binary}" --credential-helper < /dev/stdin
|
|
"#,
|
|
binary = binary_str
|
|
);
|
|
std::fs::write(&script_path, content)?;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))?;
|
|
Ok(script_path)
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
let script_path = app_data_dir.join("git-credential-codeg.bat");
|
|
let content = format!(
|
|
r#"@echo off
|
|
if not "%~1"=="get" exit /b 0
|
|
"{binary}" --credential-helper
|
|
"#,
|
|
binary = binary_str
|
|
);
|
|
std::fs::write(&script_path, content)?;
|
|
Ok(script_path)
|
|
}
|
|
}
|
|
|
|
/// Run the credential helper mode (called from main when `--credential-helper` is detected).
|
|
///
|
|
/// Reads git's credential protocol from stdin (host=xxx, protocol=xxx),
|
|
/// opens the DB, finds the matching account, and outputs username/password
|
|
/// to stdout. Exits immediately — does NOT start the Tauri GUI.
|
|
pub fn run_credential_helper() {
|
|
use std::io::BufRead;
|
|
|
|
// Read host from stdin (git credential protocol)
|
|
let mut host = String::new();
|
|
let stdin = std::io::stdin();
|
|
for line in stdin.lock().lines() {
|
|
let line = match line {
|
|
Ok(l) => l,
|
|
Err(_) => break,
|
|
};
|
|
if line.is_empty() {
|
|
break;
|
|
}
|
|
if let Some(value) = line.strip_prefix("host=") {
|
|
host = value.to_string();
|
|
}
|
|
}
|
|
|
|
if host.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// Resolve app data dir the same way Tauri does: ~/Library/Application Support/<identifier>
|
|
let app_data_dir = match resolve_app_data_dir() {
|
|
Some(d) => d,
|
|
None => return,
|
|
};
|
|
|
|
let db_path = app_data_dir.join("codeg.db");
|
|
if !db_path.exists() {
|
|
return;
|
|
}
|
|
|
|
// Open DB with a lightweight synchronous connection (no async runtime needed)
|
|
let db_url = format!(
|
|
"sqlite:{}?mode=ro",
|
|
urlencoding::encode(&db_path.to_string_lossy())
|
|
);
|
|
|
|
// Use a minimal tokio runtime just for the DB query
|
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
{
|
|
Ok(rt) => rt,
|
|
Err(_) => return,
|
|
};
|
|
|
|
rt.block_on(async {
|
|
let opts = sea_orm::ConnectOptions::new(db_url);
|
|
let conn = match sea_orm::Database::connect(opts).await {
|
|
Ok(c) => c,
|
|
Err(_) => return,
|
|
};
|
|
|
|
let settings = match load_github_accounts(&conn).await {
|
|
Some(s) => s,
|
|
None => return,
|
|
};
|
|
|
|
let remote_url = format!("https://{}", host);
|
|
if let Some(account) = find_matching_account(&settings.accounts, &remote_url) {
|
|
println!("username={}", account.username);
|
|
println!("password={}", account.token);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Resolve the app data directory (same path Tauri uses).
|
|
fn resolve_app_data_dir() -> Option<std::path::PathBuf> {
|
|
// On macOS: ~/Library/Application Support/app.codeg
|
|
// On Linux: ~/.local/share/app.codeg
|
|
// On Windows: %APPDATA%/app.codeg
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
dirs::data_dir().map(|d| d.join("app.codeg"))
|
|
}
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
dirs::data_dir().map(|d| d.join("app.codeg"))
|
|
}
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
dirs::data_dir().map(|d| d.join("app.codeg"))
|
|
}
|
|
}
|
|
|
|
/// Ensure the GIT_ASKPASS helper script exists in the app data directory.
|
|
/// Returns the path to the script.
|
|
pub fn ensure_askpass_script(app_data_dir: &Path) -> std::io::Result<PathBuf> {
|
|
#[cfg(unix)]
|
|
{
|
|
let script_path = app_data_dir.join("git-askpass.sh");
|
|
if !script_path.exists() {
|
|
let content = r#"#!/bin/sh
|
|
case "$1" in
|
|
*[Uu]sername*) echo "$CODEG_GIT_USERNAME" ;;
|
|
*[Pp]assword*) echo "$CODEG_GIT_PASSWORD" ;;
|
|
esac
|
|
"#;
|
|
std::fs::write(&script_path, content)?;
|
|
// Make executable
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))?;
|
|
}
|
|
Ok(script_path)
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
let script_path = app_data_dir.join("git-askpass.bat");
|
|
if !script_path.exists() {
|
|
let content = r#"@echo off
|
|
echo %1 | findstr /i "username" >nul
|
|
if %errorlevel% equ 0 (
|
|
echo %CODEG_GIT_USERNAME%
|
|
exit /b
|
|
)
|
|
echo %1 | findstr /i "password" >nul
|
|
if %errorlevel% equ 0 (
|
|
echo %CODEG_GIT_PASSWORD%
|
|
exit /b
|
|
)
|
|
"#;
|
|
std::fs::write(&script_path, content)?;
|
|
}
|
|
Ok(script_path)
|
|
}
|
|
}
|
|
|
|
/// Inject GitHub credentials into a git command via GIT_ASKPASS.
|
|
pub fn inject_credentials(
|
|
cmd: &mut tokio::process::Command,
|
|
username: &str,
|
|
token: &str,
|
|
askpass_path: &Path,
|
|
) {
|
|
cmd.env("GIT_ASKPASS", askpass_path)
|
|
.env("CODEG_GIT_USERNAME", username)
|
|
.env("CODEG_GIT_PASSWORD", token)
|
|
.env("GIT_TERMINAL_PROMPT", "0");
|
|
}
|
|
|
|
/// Get the remote URL for the "origin" remote of a repository.
|
|
pub async fn get_remote_url(repo_path: &str) -> Option<String> {
|
|
let output = crate::process::tokio_command("git")
|
|
.args(["remote", "get-url", "origin"])
|
|
.current_dir(repo_path)
|
|
.output()
|
|
.await
|
|
.ok()?;
|
|
|
|
if !output.status.success() {
|
|
return None;
|
|
}
|
|
|
|
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if url.is_empty() { None } else { Some(url) }
|
|
}
|
|
|
|
/// Extract the hostname from a git remote URL.
|
|
///
|
|
/// Handles both HTTPS and SSH URLs:
|
|
/// - `https://github.com/user/repo.git` → `github.com`
|
|
/// - `git@github.com:user/repo.git` → `github.com`
|
|
fn extract_host(remote_url: &str) -> Option<String> {
|
|
let url = remote_url.trim();
|
|
|
|
// HTTPS: https://github.com/...
|
|
if let Some(after_scheme) = url
|
|
.strip_prefix("https://")
|
|
.or_else(|| url.strip_prefix("http://"))
|
|
{
|
|
// Strip optional user@ prefix (e.g. https://user@github.com/...)
|
|
let after_at = after_scheme
|
|
.find('@')
|
|
.map(|i| &after_scheme[i + 1..])
|
|
.unwrap_or(after_scheme);
|
|
return after_at.split('/').next().map(|h| h.to_lowercase());
|
|
}
|
|
|
|
// SSH: git@github.com:user/repo.git
|
|
if let Some(at_pos) = url.find('@') {
|
|
let after_at = &url[at_pos + 1..];
|
|
return after_at.split(':').next().map(|h| h.to_lowercase());
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Find the best matching account for a given remote URL.
|
|
///
|
|
/// Only returns an account whose server_url hostname matches the remote URL host.
|
|
/// When multiple accounts match the same hostname, prefers the one marked `is_default`.
|
|
/// Does NOT fall back to unrelated accounts — if no hostname matches, returns None
|
|
/// so the caller can fall back to git config defaults.
|
|
pub fn find_matching_account<'a>(
|
|
accounts: &'a [GitHubAccount],
|
|
remote_url: &str,
|
|
) -> Option<&'a GitHubAccount> {
|
|
if accounts.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let remote_host = extract_host(remote_url)?;
|
|
|
|
let matching: Vec<&GitHubAccount> = accounts
|
|
.iter()
|
|
.filter(|a| {
|
|
let account_host = extract_host(&a.server_url)
|
|
.unwrap_or_else(|| a.server_url.trim().trim_end_matches('/').to_lowercase());
|
|
account_host == remote_host
|
|
})
|
|
.collect();
|
|
|
|
// Prefer the default account among matches, otherwise take the first
|
|
matching
|
|
.iter()
|
|
.find(|a| a.is_default)
|
|
.or(matching.first())
|
|
.copied()
|
|
}
|
|
|
|
/// Load GitHub accounts from the database.
|
|
pub async fn load_github_accounts(
|
|
conn: &DatabaseConnection,
|
|
) -> Option<GitHubAccountsSettings> {
|
|
let raw = app_metadata_service::get_value(conn, GITHUB_ACCOUNTS_KEY)
|
|
.await
|
|
.ok()??;
|
|
|
|
serde_json::from_str::<GitHubAccountsSettings>(&raw).ok()
|
|
}
|
|
|
|
/// Resolve the commit author (name + email) from the matching account for a repo.
|
|
///
|
|
/// Returns `Some((name, email))` if a matching account is found.
|
|
/// Uses GitHub's noreply email format: `username@users.noreply.github.com`.
|
|
pub async fn resolve_commit_author(
|
|
repo_path: &str,
|
|
conn: &DatabaseConnection,
|
|
) -> Option<(String, String)> {
|
|
let settings = load_github_accounts(conn).await?;
|
|
if settings.accounts.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let remote_url = get_remote_url(repo_path).await?;
|
|
let account = find_matching_account(&settings.accounts, &remote_url)?;
|
|
|
|
let host = extract_host(&remote_url).unwrap_or_default();
|
|
let email = if host == "github.com" {
|
|
format!("{}@users.noreply.github.com", account.username)
|
|
} else {
|
|
// For non-GitHub hosts, use username@host as a reasonable fallback
|
|
format!("{}@{}", account.username, host)
|
|
};
|
|
|
|
Some((account.username.clone(), email))
|
|
}
|
|
|
|
/// Resolve credentials for a git repository and inject them into the command.
|
|
///
|
|
/// This is the main entry point: given a repo path and a git command,
|
|
/// it finds the matching GitHub account and injects credentials.
|
|
/// Returns `true` if credentials were injected.
|
|
pub async fn try_inject_for_repo(
|
|
cmd: &mut tokio::process::Command,
|
|
repo_path: &str,
|
|
conn: &DatabaseConnection,
|
|
app_data_dir: &Path,
|
|
) -> bool {
|
|
let settings = match load_github_accounts(conn).await {
|
|
Some(s) if !s.accounts.is_empty() => s,
|
|
_ => return false,
|
|
};
|
|
|
|
let remote_url = match get_remote_url(repo_path).await {
|
|
Some(url) => url,
|
|
None => return false,
|
|
};
|
|
|
|
// Only inject for HTTPS URLs (SSH uses keys, not tokens)
|
|
if !remote_url.starts_with("https://") && !remote_url.starts_with("http://") {
|
|
return false;
|
|
}
|
|
|
|
let account = match find_matching_account(&settings.accounts, &remote_url) {
|
|
Some(a) => a,
|
|
None => return false,
|
|
};
|
|
|
|
let askpass = match ensure_askpass_script(app_data_dir) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
eprintln!("[GIT_CRED] failed to create askpass script: {}", e);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
inject_credentials(cmd, &account.username, &account.token, &askpass);
|
|
true
|
|
}
|
|
|
|
/// Same as `try_inject_for_repo` but for clone operations where
|
|
/// we don't have a repo path yet — just a URL.
|
|
pub async fn try_inject_for_url(
|
|
cmd: &mut tokio::process::Command,
|
|
clone_url: &str,
|
|
conn: &DatabaseConnection,
|
|
app_data_dir: &Path,
|
|
) -> bool {
|
|
if !clone_url.starts_with("https://") && !clone_url.starts_with("http://") {
|
|
return false;
|
|
}
|
|
|
|
let settings = match load_github_accounts(conn).await {
|
|
Some(s) if !s.accounts.is_empty() => s,
|
|
_ => return false,
|
|
};
|
|
|
|
let account = match find_matching_account(&settings.accounts, clone_url) {
|
|
Some(a) => a,
|
|
None => return false,
|
|
};
|
|
|
|
let askpass = match ensure_askpass_script(app_data_dir) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
eprintln!("[GIT_CRED] failed to create askpass script: {}", e);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
inject_credentials(cmd, &account.username, &account.token, &askpass);
|
|
true
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_extract_host_https() {
|
|
assert_eq!(
|
|
extract_host("https://github.com/user/repo.git"),
|
|
Some("github.com".to_string())
|
|
);
|
|
assert_eq!(
|
|
extract_host("https://user@github.com/user/repo.git"),
|
|
Some("github.com".to_string())
|
|
);
|
|
assert_eq!(
|
|
extract_host("https://gitlab.example.com/org/repo"),
|
|
Some("gitlab.example.com".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_host_ssh() {
|
|
assert_eq!(
|
|
extract_host("git@github.com:user/repo.git"),
|
|
Some("github.com".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_matching_account() {
|
|
let accounts = vec![
|
|
GitHubAccount {
|
|
id: "1".into(),
|
|
server_url: "https://github.com".into(),
|
|
username: "user1".into(),
|
|
token: "tok1".into(),
|
|
scopes: vec![],
|
|
avatar_url: None,
|
|
is_default: false,
|
|
created_at: String::new(),
|
|
},
|
|
GitHubAccount {
|
|
id: "2".into(),
|
|
server_url: "https://gitlab.example.com".into(),
|
|
username: "user2".into(),
|
|
token: "tok2".into(),
|
|
scopes: vec![],
|
|
avatar_url: None,
|
|
is_default: true,
|
|
created_at: String::new(),
|
|
},
|
|
];
|
|
|
|
let matched = find_matching_account(&accounts, "https://github.com/org/repo.git");
|
|
assert_eq!(matched.unwrap().username, "user1");
|
|
|
|
let matched = find_matching_account(&accounts, "https://gitlab.example.com/org/repo");
|
|
assert_eq!(matched.unwrap().username, "user2");
|
|
|
|
// Unknown host returns None — no fallback to unrelated accounts
|
|
let matched = find_matching_account(&accounts, "https://unknown.com/repo");
|
|
assert!(matched.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_matching_account_prefers_default() {
|
|
let accounts = vec![
|
|
GitHubAccount {
|
|
id: "1".into(),
|
|
server_url: "https://github.com".into(),
|
|
username: "personal".into(),
|
|
token: "tok1".into(),
|
|
scopes: vec![],
|
|
avatar_url: None,
|
|
is_default: false,
|
|
created_at: String::new(),
|
|
},
|
|
GitHubAccount {
|
|
id: "2".into(),
|
|
server_url: "https://github.com".into(),
|
|
username: "work".into(),
|
|
token: "tok2".into(),
|
|
scopes: vec![],
|
|
avatar_url: None,
|
|
is_default: true,
|
|
created_at: String::new(),
|
|
},
|
|
];
|
|
|
|
// Should pick the default account when multiple match the same host
|
|
let matched = find_matching_account(&accounts, "https://github.com/org/repo.git");
|
|
assert_eq!(matched.unwrap().username, "work");
|
|
}
|
|
}
|