diff --git a/src-tauri/src/commands/terminal.rs b/src-tauri/src/commands/terminal.rs index 3e22aa0..d2dfc7b 100644 --- a/src-tauri/src/commands/terminal.rs +++ b/src-tauri/src/commands/terminal.rs @@ -9,16 +9,24 @@ use crate::terminal::error::TerminalError; use crate::terminal::manager::{SpawnOptions, TerminalManager}; use crate::terminal::types::TerminalInfo; -/// Build extra env vars and a temp credential file for the terminal session. +/// Temp files created for a terminal credential session. +struct TerminalCredFiles { + cred_file: std::path::PathBuf, + helper_script: std::path::PathBuf, +} + +/// Build extra env vars and temp credential files for the terminal session. /// -/// Uses `GIT_ASKPASS` with a terminal-specific askpass script that reads from -/// a credential store file. This approach does NOT override the user's -/// existing `credential.helper` configuration (e.g. macOS Keychain). +/// Uses `credential.helper` with a custom credential helper script that speaks +/// git's structured credential protocol (host/protocol on stdin, username/password +/// on stdout). This is added via `GIT_CONFIG_COUNT` which APPENDS to the user's +/// existing credential helpers (e.g. macOS Keychain) for multi-valued keys. +/// Our helper is tried first; if it has no match, git falls through to existing helpers. async fn prepare_credential_env( db: &AppDatabase, app_data_dir: &std::path::Path, terminal_id: &str, -) -> (Option>, Option) { +) -> (Option>, Option) { let accounts = match git_credential::load_github_accounts(&db.conn).await { Some(s) if !s.accounts.is_empty() => s.accounts, _ => return (None, None), @@ -30,23 +38,40 @@ async fn prepare_credential_env( return (None, None); } - let askpass_script = - match git_credential::create_terminal_askpass_script(app_data_dir, &cred_file) { - Ok(p) => p, - Err(e) => { - eprintln!("[TERM] failed to create terminal askpass script: {}", e); - let _ = std::fs::remove_file(&cred_file); - return (None, None); - } - }; + let helper_script = match git_credential::create_credential_helper_script( + app_data_dir, + &cred_file, + terminal_id, + ) { + Ok(p) => p, + Err(e) => { + eprintln!("[TERM] failed to create credential helper script: {}", e); + let _ = std::fs::remove_file(&cred_file); + return (None, None); + } + }; + let helper_path_str = helper_script.to_string_lossy().to_string(); + + // GIT_CONFIG_COUNT adds config entries that are tried BEFORE file-based config. + // For multi-valued keys like credential.helper, this means our helper runs first; + // if it exits 0 with no output, git falls through to the user's existing helpers. let mut env = HashMap::new(); + env.insert("GIT_CONFIG_COUNT".to_string(), "1".to_string()); env.insert( - "GIT_ASKPASS".to_string(), - askpass_script.to_string_lossy().to_string(), + "GIT_CONFIG_KEY_0".to_string(), + "credential.helper".to_string(), + ); + env.insert( + "GIT_CONFIG_VALUE_0".to_string(), + helper_path_str, ); - (Some(env), Some(cred_file)) + let files = TerminalCredFiles { + cred_file, + helper_script, + }; + (Some(env), Some(files)) } #[tauri::command] @@ -66,9 +91,13 @@ pub async fn terminal_spawn( .app_data_dir() .map_err(|e| TerminalError::SpawnFailed(e.to_string()))?; - let (extra_env, cred_file) = + let (extra_env, cred_files) = prepare_credential_env(&db, &app_data_dir, &terminal_id).await; + let temp_files = cred_files + .map(|f| vec![f.cred_file, f.helper_script]) + .unwrap_or_default(); + manager.spawn_with_id( SpawnOptions { terminal_id, @@ -76,7 +105,7 @@ pub async fn terminal_spawn( owner_window_label: window.label().to_string(), initial_command, extra_env, - credential_file: cred_file, + temp_files, }, app_handle, ) diff --git a/src-tauri/src/git_credential.rs b/src-tauri/src/git_credential.rs index 253ac85..a3c9776 100644 --- a/src-tauri/src/git_credential.rs +++ b/src-tauri/src/git_credential.rs @@ -44,47 +44,64 @@ pub fn write_credential_store_file( Ok(()) } -/// Create a terminal-specific GIT_ASKPASS script that reads credentials -/// from a credential-store file, selecting by hostname from the git prompt. +/// Create a credential helper script that reads from a credential-store file +/// using git's structured credential protocol. /// -/// Unlike the simple askpass script (which uses env vars for a single credential), -/// this one supports multiple hosts by parsing the credential store file. -pub fn create_terminal_askpass_script( +/// Git's credential helper protocol passes structured key=value pairs on stdin: +/// protocol=https +/// host=github.com +/// +/// And expects the response on stdout: +/// username=xxx +/// password=xxx +/// +/// This is far more reliable than GIT_ASKPASS (which requires parsing English prompts). +/// The script is per-terminal (named with terminal_id) to avoid race conditions. +pub fn create_credential_helper_script( app_data_dir: &Path, cred_store_path: &Path, + terminal_id: &str, ) -> std::io::Result { let cred_store_str = cred_store_path.to_string_lossy(); #[cfg(unix)] { - let script_path = app_data_dir.join("git-askpass-terminal.sh"); - // Always overwrite — cred store path may change between sessions + let script_path = app_data_dir.join(format!("git-credential-codeg-{}.sh", &terminal_id[..8])); let content = format!( r#"#!/bin/sh -# Terminal askpass helper: reads from credential store file. -# Parses hostname from git's prompt (e.g. "Username for 'https://github.com': ") +# Codeg credential helper: reads from credential store file. +# Only responds to "get" action; ignores "store" and "erase". +[ "$1" != "get" ] && exit 0 + CRED_FILE="{cred_file}" -PROMPT="$1" +[ ! -f "$CRED_FILE" ] && exit 0 -# Extract hostname from prompt (between :// and next / or ') -HOST=$(echo "$PROMPT" | sed -n "s|.*://\([^/']*\).*|\1|p") -[ -z "$HOST" ] && exit 1 -[ ! -f "$CRED_FILE" ] && exit 1 +# Read protocol and host from stdin +HOST="" +PROTO="" +while IFS='=' read -r key value; do + [ -z "$key" ] && break + case "$key" in + host) HOST="$value" ;; + protocol) PROTO="$value" ;; + esac +done -# Find matching line in credential store +[ -z "$HOST" ] && exit 0 + +# Find matching line: https://user:pass@host LINE=$(grep -i "@$HOST" "$CRED_FILE" | head -1) -[ -z "$LINE" ] && exit 1 +[ -z "$LINE" ] && exit 0 # Parse username and password from https://user:pass@host -USERPASS=$(echo "$LINE" | sed 's|https://||' | sed 's|@.*||') +USERPASS=$(echo "$LINE" | sed 's|https*://||' | sed 's|@.*||') USER=$(echo "$USERPASS" | cut -d: -f1) PASS=$(echo "$USERPASS" | cut -d: -f2-) -case "$PROMPT" in - *[Uu]sername*) echo "$USER" ;; - *[Pp]assword*) echo "$PASS" ;; - *) exit 1 ;; -esac +[ -z "$USER" ] && exit 0 + +echo "username=$USER" +echo "password=$PASS" "#, cred_file = cred_store_str ); @@ -96,50 +113,44 @@ esac #[cfg(windows)] { - let script_path = app_data_dir.join("git-askpass-terminal.bat"); + let script_path = app_data_dir.join(format!("git-credential-codeg-{}.bat", &terminal_id[..8])); let content = format!( r#"@echo off setlocal enabledelayedexpansion +if not "%~1"=="get" exit /b 0 + set "CRED_FILE={cred_file}" -set "PROMPT=%~1" +if not exist "!CRED_FILE!" exit /b 0 -:: Extract hostname from prompt -for /f "tokens=2 delims=/" %%a in ("!PROMPT!") do ( - for /f "tokens=1 delims=/' " %%b in ("%%a") do set "HOST=%%b" +set "HOST=" +set "PROTO=" +:readloop +set /p "LINE=" || goto :match +for /f "tokens=1,* delims==" %%a in ("!LINE!") do ( + if "%%a"=="host" set "HOST=%%b" + if "%%a"=="protocol" set "PROTO=%%b" ) -if not defined HOST exit /b 1 -if not exist "!CRED_FILE!" exit /b 1 +if defined LINE goto :readloop + +:match +if not defined HOST exit /b 0 -:: Search credential file for matching host for /f "usebackq delims=" %%L in ("!CRED_FILE!") do ( echo %%L | findstr /i "!HOST!" >nul if !errorlevel! equ 0 ( - set "LINE=%%L" - goto :found + set "FOUND=%%L" + goto :parse ) ) -exit /b 1 +exit /b 0 -:found -:: Parse https://user:pass@host -set "LINE=!LINE:https://=!" -for /f "tokens=1 delims=@" %%a in ("!LINE!") do set "USERPASS=%%a" +:parse +set "FOUND=!FOUND:https://=!" +for /f "tokens=1 delims=@" %%a in ("!FOUND!") do set "USERPASS=%%a" for /f "tokens=1,2 delims=:" %%a in ("!USERPASS!") do ( - set "USER=%%a" - set "PASS=%%b" + echo username=%%a + echo password=%%b ) - -echo !PROMPT! | findstr /i "username" >nul -if !errorlevel! equ 0 ( - echo !USER! - exit /b -) -echo !PROMPT! | findstr /i "password" >nul -if !errorlevel! equ 0 ( - echo !PASS! - exit /b -) -exit /b 1 "#, cred_file = cred_store_str ); diff --git a/src-tauri/src/terminal/manager.rs b/src-tauri/src/terminal/manager.rs index 3483515..08e6821 100644 --- a/src-tauri/src/terminal/manager.rs +++ b/src-tauri/src/terminal/manager.rs @@ -17,8 +17,8 @@ struct TerminalInstance { _child: Box, title: String, owner_window_label: String, - /// Temp credential file to clean up on kill. - credential_file: Option, + /// Temp files (credential store + helper script) to clean up on exit. + temp_files: Vec, } pub struct TerminalManager { @@ -139,7 +139,7 @@ pub struct SpawnOptions { pub owner_window_label: String, pub initial_command: Option, pub extra_env: Option>, - pub credential_file: Option, + pub temp_files: Vec, } impl TerminalManager { @@ -205,7 +205,7 @@ impl TerminalManager { _child: child, title: "Terminal".to_string(), owner_window_label: opts.owner_window_label, - credential_file: opts.credential_file, + temp_files: opts.temp_files, }; self.terminals @@ -365,8 +365,11 @@ impl TerminalManager { fn terminate_terminal(instance: &mut TerminalInstance) { let _ = instance._child.kill(); let _ = instance._child.wait(); - // Clean up temp credential file - if let Some(path) = instance.credential_file.take() { + cleanup_temp_files(&mut instance.temp_files); +} + +fn cleanup_temp_files(files: &mut Vec) { + for path in files.drain(..) { let _ = std::fs::remove_file(&path); } } @@ -411,11 +414,9 @@ fn read_loop( } } - // Terminal exited — remove from map and clean up credential file + // Terminal exited — remove from map and clean up temp files if let Some(mut instance) = terminals.lock().unwrap().remove(&terminal_id) { - if let Some(path) = instance.credential_file.take() { - let _ = std::fs::remove_file(&path); - } + cleanup_temp_files(&mut instance.temp_files); } emit_terminal_exit_event(app_handle, &terminal_id);