feat(git): remember HTTPS credentials in the app secret store (#321)

Git no longer asks for the HTTPS user name and password on every fetch, pull and push. The password prompt offers "Remember for this host"; the credential is saved in the app's secret store (next to the saved SSH passwords, never in git's global configuration) only after the git command that used it succeeds, and a stored credential the server rejects is forgotten so the next command asks again. Settings › Version Control › Git lists saved credentials and forgets them.

Each git child gets its own id, passed to the askpass helper, so concurrent commands never save or forget each other's credentials. ide-vcs reports command completion through an observer installed at start-up instead of depending on ide-ssh. Credentials are keyed by scheme, host and port, and only git's authentication errors (Authentication failed, HTTP 401/403 responses, invalid username or password) forget a stored credential.

Fixes #272

Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com>
This commit is contained in:
arookieofc
2026-10-30 10:48:29 +08:00
committed by GitHub
co-authored by a-rookie-of-C-language
parent 2c10b63f76
commit e85ffa2ae8
17 changed files with 666 additions and 34 deletions
+2
View File
@@ -115,6 +115,8 @@ pub enum IdeEvent {
id: u64,
prompt: String,
kind: AskpassKind,
/// The dialog offers "Remember" for the answer (git HTTPS passwords).
remember: bool,
},
/// Prompt `id` was answered (in any window) or gave up.
AskpassDone {
+50 -11
View File
@@ -16,6 +16,7 @@ use std::sync::mpsc;
use std::time::Duration;
pub const PORT_ENV: &str = "OPENIDEA_ASKPASS_PORT";
pub const COMMAND_ENV: &str = "OPENIDEA_GIT_COMMAND_ID";
pub const TOKEN_ENV: &str = "OPENIDEA_ASKPASS_TOKEN";
/// An unanswered prompt fails after this long.
const ANSWER_TIMEOUT: Duration = Duration::from_secs(300);
@@ -40,8 +41,16 @@ pub fn classify(prompt: &str) -> AskpassKind {
}
}
/// Whether the UI's credential dialog should offer to remember the answer:
/// git's HTTPS password prompts only (the user name travels inside them).
pub fn remembers(prompt: &str) -> bool {
crate::git_creds::prompt_key(prompt).is_some() && classify(prompt) == AskpassKind::Secret
}
#[derive(Serialize, Deserialize)]
struct Request {
#[serde(default)]
command_id: Option<u64>,
token: String,
prompt: String,
}
@@ -51,7 +60,13 @@ struct Response {
answer: Option<String>,
}
type Pending = Arc<Mutex<HashMap<u64, mpsc::Sender<Option<String>>>>>;
type Pending = Arc<Mutex<HashMap<u64, PendingPrompt>>>;
struct PendingPrompt {
command_id: Option<u64>,
prompt: String,
tx: mpsc::Sender<Option<String>>,
}
/// Serves prompts to `on_prompt`; answers come back through [`AskpassServer::answer`].
pub struct AskpassServer {
@@ -85,10 +100,17 @@ impl AskpassServer {
Ok(Self { port, token, exe, pending })
}
/// Answers prompt `id` (`None`: cancelled).
pub fn answer(&self, id: u64, answer: Option<String>) {
if let Some(tx) = self.pending.lock().remove(&id) {
let _ = tx.send(answer);
/// Answers prompt `id` (`None`: cancelled). `remember` asks the git
/// credential store to keep the answer once the command succeeds.
pub fn answer(&self, id: u64, answer: Option<String>, remember: bool) {
if let Some(p) = self.pending.lock().remove(&id) {
if let Some(a) = answer.as_deref()
&& let Some(command_id) = p.command_id
&& let Some(store) = crate::git_creds::get()
{
store.answered(command_id, &p.prompt, a, remember);
}
let _ = p.tx.send(answer);
}
}
@@ -123,9 +145,20 @@ fn serve(
let answer = if req.token != token {
None
} else {
let (tx, rx) = mpsc::channel();
pending.lock().insert(id, tx);
let prompt = req.prompt.trim().to_owned();
// A saved git HTTPS credential answers silently; a rejected one is
// forgotten by `git_creds::command_finished`, not here (the command
// may still be running and could retry).
if let Some(command_id) = req.command_id
&& let Some(store) = crate::git_creds::get()
&& let Some(answer) = store.answer_saved(command_id, &prompt)
{
let mut out = stream;
let _ = writeln!(out, "{}", serde_json::to_string(&Response { answer: Some(answer) }).unwrap_or_default());
return;
}
let (tx, rx) = mpsc::channel();
pending.lock().insert(id, PendingPrompt { command_id: req.command_id, prompt: prompt.clone(), tx });
on_prompt(AskpassPrompt { id, kind: classify(&prompt), prompt });
let answer = rx.recv_timeout(ANSWER_TIMEOUT).ok().flatten();
pending.lock().remove(&id);
@@ -156,9 +189,10 @@ pub fn run_helper() -> Option<i32> {
let port: u16 = std::env::var(PORT_ENV).ok()?.parse().ok()?;
let token = std::env::var(TOKEN_ENV).unwrap_or_default();
let prompt = std::env::args().nth(1).unwrap_or_else(|| "Password:".into());
let command_id = std::env::var(COMMAND_ENV).ok().and_then(|id| id.parse().ok());
let ask = || -> std::io::Result<Option<String>> {
let mut stream = TcpStream::connect(("127.0.0.1", port))?;
writeln!(stream, "{}", serde_json::to_string(&Request { token, prompt }).unwrap_or_default())?;
writeln!(stream, "{}", serde_json::to_string(&Request { command_id, token, prompt }).unwrap_or_default())?;
let mut line = String::new();
BufReader::new(stream).read_line(&mut line)?;
Ok(serde_json::from_str::<Response>(&line).ok().and_then(|r| r.answer))
@@ -196,8 +230,12 @@ mod tests {
let token = server.token.clone();
let client = std::thread::spawn(move || {
let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap();
writeln!(stream, "{}", serde_json::to_string(&Request { token, prompt: "Password: ".into() }).unwrap())
.unwrap();
writeln!(
stream,
"{}",
serde_json::to_string(&Request { command_id: Some(42), token, prompt: "Password: ".into() }).unwrap()
)
.unwrap();
let mut line = String::new();
BufReader::new(stream).read_line(&mut line).unwrap();
serde_json::from_str::<Response>(&line).unwrap().answer
@@ -209,7 +247,8 @@ mod tests {
}
std::thread::sleep(Duration::from_millis(5));
};
server.answer(id, Some("hunter2".into()));
assert_eq!(server.pending.lock().get(&id).unwrap().command_id, Some(42));
server.answer(id, Some("hunter2".into()), false);
assert_eq!(client.join().unwrap().as_deref(), Some("hunter2"));
assert!(server.env().iter().any(|(k, v)| k == "SSH_ASKPASS" && v == "helper"));
}
+379
View File
@@ -0,0 +1,379 @@
//! Saved git HTTPS credentials, in the app's own secret store (next to the SSH
//! passwords), never in git's global configuration. Answers to askpass prompts
//! are remembered only after the git command that used them succeeded, and a
//! stored credential that fails authentication is forgotten so the next command
//! asks again.
use crate::secrets::SecretFile;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
/// A stored credential: `user` answered `Username for …`, `secret` the password.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GitCredential {
pub user: String,
pub secret: String,
}
/// What [`GitCredentials::lookup`] decided for a prompt.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GitAnswer {
/// Answer the prompt with this text without asking the user.
Auto(String),
/// Nothing stored; the dialog may offer to remember a new credential.
PromptNew,
}
/// Shared store: a [`SecretFile`] of `scheme://host[:port]` → `user\nsecret`, plus the
/// state of each git command currently running (credentials it consumed and
/// answers to save when it succeeds).
#[derive(Clone)]
pub struct GitCredentials {
saved: Arc<SecretFile>,
sessions: Arc<Mutex<HashMap<u64, Session>>>,
}
#[derive(Default)]
struct Session {
/// Stored credentials this command used (possibly wrong): forgotten if the
/// command fails with an authentication error.
used: HashMap<String, GitCredential>,
/// `scheme://host[:port]` → credential the user asked to remember: written when the
/// command succeeds.
pending: HashMap<String, GitCredential>,
}
/// Global instance: the askpass server and every `git` child process agree on it.
static INSTANCE: Mutex<Option<GitCredentials>> = Mutex::new(None);
/// Installs the store of `path` (the app's data directory). Called once at
/// start-up; git prompts before that are simply not remembered.
pub fn init(path: PathBuf) {
*INSTANCE.lock() = Some(GitCredentials { saved: Arc::new(SecretFile::new(path)), sessions: Arc::default() });
}
/// The installed store, if start-up got that far.
pub fn get() -> Option<GitCredentials> {
INSTANCE.lock().clone()
}
/// `scheme://host[:port]` of an HTTPS prompt (`Username for 'https://host'` /
/// `Password for 'https://user@host'`); `None` for anything else (SSH prompts,
/// passphrases, host-key questions — none of git's business here).
pub fn prompt_key(prompt: &str) -> Option<String> {
let quoted = prompt.split('\'').nth(1)?;
let (scheme, rest) = quoted.split_once("://")?;
if !matches!(scheme, "http" | "https") {
return None;
}
let authority = rest.split('/').next()?;
// `user@host` (possibly with a password): keep the host part only.
let host = authority.rsplit('@').next()?;
if host.is_empty() {
return None;
}
Some(format!("{scheme}://{host}"))
}
/// The user name embedded in a password prompt (`https://user@host`), if any.
/// Git URL-encodes it (`%40` for `@`), which a lookup must match.
fn prompt_user(prompt: &str) -> Option<String> {
let quoted = prompt.split('\'').nth(1)?;
let authority = quoted.split_once("://")?.1.split('/').next()?;
let (user, _) = authority.rsplit_once('@')?;
let user = user.split(':').next()?;
(!user.is_empty()).then(|| user.to_owned())
}
/// `stderr` of a failed git command says the stored credential was rejected.
fn is_auth_failure(stderr: &str) -> bool {
let s = stderr.to_ascii_lowercase();
s.contains("authentication failed")
|| s.contains("authentication unsuccessful")
|| s.contains("invalid username or password")
|| s.contains("access denied")
|| s.contains("authorization failed")
|| s.contains("could not read username")
|| s.contains("could not read password")
|| s.contains("the requested url returned error: 403")
|| s.contains("the requested url returned error: 401")
}
impl GitCredentials {
/// The stored credential for `key` (`scheme://host[:port]`).
pub fn credential(&self, key: &str) -> Option<GitCredential> {
let raw = self.saved.get(key)?;
let (user, secret) = raw.split_once('\n')?;
Some(GitCredential { user: user.to_owned(), secret: secret.to_owned() })
}
/// What to do with a git HTTPS `prompt`: answer it from the store or show
/// the dialog.
/// `None`: not a git HTTPS credential prompt.
pub fn lookup(&self, prompt: &str) -> Option<GitAnswer> {
let key = prompt_key(prompt)?;
let wants_user = prompt.to_ascii_lowercase().starts_with("username");
match self.credential(&key) {
Some(c) => {
// The password prompt carries the user name git will send: a
// mismatch means the stored entry belongs to another account.
let answer = if wants_user {
Some(c.user)
} else {
prompt_user(prompt).is_none_or(|u| u == c.user).then_some(c.secret)
};
Some(answer.map_or(GitAnswer::PromptNew, GitAnswer::Auto))
}
None => Some(GitAnswer::PromptNew),
}
}
/// The stored answer to `prompt`; `None` falls through to the UI prompt.
/// Only a consumed *password* marks `key` as used — a stored user name
/// alone proves nothing about the credential's validity.
pub fn answer_saved(&self, command_id: u64, prompt: &str) -> Option<String> {
let key = prompt_key(prompt)?;
let mut sessions = self.sessions.lock();
let credential = self.credential(&key)?;
if prompt.to_ascii_lowercase().starts_with("username") {
return Some(credential.user);
}
if prompt_user(prompt).is_some_and(|user| user != credential.user) {
return None;
}
let answer = credential.secret.clone();
sessions.entry(command_id).or_default().used.insert(key, credential);
Some(answer)
}
/// The user's `answer` to `prompt`, remembered when the current git command
/// succeeds (`remember` = the dialog's "Remember" checkbox). Records the
/// key only for pending saves: a re-typed answer does not consume the
/// stored credential, so an auth failure must not delete it.
pub fn answered(&self, command_id: u64, prompt: &str, answer: &str, remember: bool) {
if !remember {
return;
}
let Some(key) = prompt_key(prompt) else { return };
let mut sessions = self.sessions.lock();
let s = sessions.entry(command_id).or_default();
let wants_user = prompt.to_ascii_lowercase().starts_with("username");
let entry = s.pending.entry(key).or_insert(GitCredential { user: String::new(), secret: String::new() });
if wants_user {
entry.user = answer.to_owned();
} else {
entry.secret = answer.to_owned();
// The password prompt carries the user name git will send.
if entry.user.is_empty()
&& let Some(u) = prompt_user(prompt)
{
entry.user = u;
}
}
}
/// Called when a git child process exits: on success, writes the remembered
/// credentials and marks used ones as verified; on an authentication
/// failure, forgets every credential the command consumed so the next run
/// asks again instead of looping on a wrong password.
pub fn command_finished(&self, command_id: u64, success: bool, stderr: &str) {
let mut sessions = self.sessions.lock();
let Some(session) = sessions.remove(&command_id) else { return };
if success {
for (key, c) in session.pending {
if c.secret.is_empty() {
continue; // a lone user name is not a credential
}
if let Err(e) = self.saved.set(&key, &format!("{}\n{}", c.user, c.secret)) {
tracing::warn!("git credentials: {}", e.message);
}
}
} else if is_auth_failure(stderr) {
// `used` holds exactly the keys whose stored password was sent.
for (key, used) in session.used {
if self.credential(&key).as_ref() == Some(&used) {
self.saved.delete(&key);
}
}
}
}
/// Settings › Git: `scheme://host[:port]` entries with a saved credential.
pub fn list(&self) -> Vec<String> {
self.saved.keys()
}
/// Forgets the credential of `key` (Settings › Git › Clear, or a remote edit).
pub fn forget(&self, key: &str) {
self.saved.delete(key);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> (tempfile::TempDir, GitCredentials) {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("git-credentials.json");
(tmp, GitCredentials { saved: Arc::new(SecretFile::new(path)), sessions: Arc::default() })
}
#[test]
fn parses_https_prompts() {
assert_eq!(prompt_key("Username for 'https://github.com': ").as_deref(), Some("https://github.com"));
assert_eq!(prompt_key("Password for 'https://user@github.com': ").as_deref(), Some("https://github.com"));
assert_eq!(
prompt_key("Password for 'https://user%40mail.com@example.com:8443/x': ").as_deref(),
Some("https://example.com:8443")
);
assert_eq!(prompt_key("git@github.com's password: "), None);
assert_eq!(prompt_key("Enter passphrase for key '/u/.ssh/id_ed25519': "), None);
}
#[test]
fn saves_only_after_success_and_answers_silently() {
let (_t, c) = store();
// First run: prompts the user, who checks "remember".
assert_eq!(c.lookup("Username for 'https://github.com': "), Some(GitAnswer::PromptNew));
c.answered(1, "Username for 'https://github.com': ", "me", true);
c.answered(1, "Password for 'https://me@github.com': ", "secret", true);
// A failure before the password is verified saves nothing.
c.command_finished(1, false, "fatal: some other error");
assert_eq!(c.lookup("Username for 'https://github.com': "), Some(GitAnswer::PromptNew));
c.answered(1, "Username for 'https://github.com': ", "me", true);
c.answered(1, "Password for 'https://me@github.com': ", "secret", true);
c.command_finished(1, true, "");
// Second run: no prompts at all.
assert_eq!(c.lookup("Username for 'https://github.com': "), Some(GitAnswer::Auto("me".into())));
assert_eq!(c.lookup("Password for 'https://me@github.com': "), Some(GitAnswer::Auto("secret".into())));
}
#[test]
fn forgets_a_rejected_credential() {
let (_t, c) = store();
c.answered(1, "Username for 'https://github.com': ", "me", true);
c.answered(1, "Password for 'https://me@github.com': ", "wrong", true);
c.command_finished(1, true, "");
// The next command consumes the stored credential silently.
assert_eq!(c.answer_saved(1, "Username for 'https://github.com': ").as_deref(), Some("me"));
assert_eq!(c.answer_saved(1, "Password for 'https://me@github.com': ").as_deref(), Some("wrong"));
c.command_finished(
1,
false,
"remote: Invalid username or password.\nfatal: Authentication failed for 'https://github.com/x.git/'",
);
assert_eq!(c.lookup("Username for 'https://github.com': "), Some(GitAnswer::PromptNew));
}
#[test]
fn lists_and_forgets() {
let (_t, c) = store();
c.answered(1, "Username for 'https://a.example.com': ", "u", true);
c.answered(1, "Password for 'https://u@a.example.com': ", "p", true);
c.answered(1, "Username for 'https://b.example.com': ", "u", true);
c.answered(1, "Password for 'https://u@b.example.com': ", "p", true);
c.command_finished(1, true, "");
assert_eq!(c.list(), vec!["https://a.example.com", "https://b.example.com"]);
c.forget("https://a.example.com");
assert_eq!(c.list(), vec!["https://b.example.com"]);
}
#[test]
fn interleaved_commands_do_not_share_pending_or_used_credentials() {
for failure_first in [false, true] {
let (_t, c) = store();
let old = "Password for 'https://me@old.example:8443': ";
let new = "Password for 'https://me@new.example:8443': ";
c.answered(1, old, "rejected", true);
c.command_finished(1, true, "");
assert_eq!(c.answer_saved(2, old).as_deref(), Some("rejected"));
c.answered(3, new, "verified", true);
c.command_finished(4, true, "");
assert!(c.credential("https://new.example:8443").is_none());
assert!(c.credential("https://old.example:8443").is_some());
if failure_first {
c.command_finished(2, false, "fatal: Authentication failed");
assert!(c.credential("https://new.example:8443").is_none());
c.command_finished(3, true, "");
} else {
c.command_finished(3, true, "");
assert!(c.credential("https://old.example:8443").is_some());
c.command_finished(2, false, "fatal: Authentication failed");
}
assert!(c.credential("https://old.example:8443").is_none());
assert_eq!(c.credential("https://new.example:8443").unwrap().secret, "verified");
assert!(c.sessions.lock().is_empty());
}
}
#[test]
fn unrelated_success_does_not_save_an_unverified_password() {
let (_t, c) = store();
c.answered(1, "Password for 'https://me@example.com': ", "wrong", true);
c.command_finished(2, true, "");
assert!(c.list().is_empty());
c.command_finished(1, false, "HTTP Basic: Access denied");
assert!(c.list().is_empty());
assert!(c.sessions.lock().is_empty());
}
#[test]
fn stale_failure_does_not_delete_a_newly_verified_password() {
let (_t, c) = store();
let prompt = "Password for 'https://me@example.com': ";
c.answered(1, prompt, "old", true);
c.command_finished(1, true, "");
c.answer_saved(2, prompt).unwrap();
c.answered(3, prompt, "new", true);
c.command_finished(3, true, "");
c.command_finished(2, false, "Authentication failed");
assert_eq!(c.answer_saved(4, prompt).as_deref(), Some("new"));
}
#[test]
fn ports_and_ipv6_are_distinct_credential_keys() {
for (url, key) in [
("http://me@example.com:8080/repo", "http://example.com:8080"),
("https://me@example.com:8443/repo", "https://example.com:8443"),
("https://me@[::1]:8443/repo", "https://[::1]:8443"),
("https://[::1]/repo", "https://[::1]"),
] {
assert_eq!(prompt_key(&format!("Password for '{url}': ")).as_deref(), Some(key));
}
let (_t, c) = store();
c.answered(1, "Password for 'https://me@example.com:8443': ", "secret", true);
c.command_finished(1, true, "");
assert!(c.answer_saved(2, "Password for 'https://me@example.com:9443': ").is_none());
assert!(c.answer_saved(2, "Password for 'https://me@example.com': ").is_none());
}
#[test]
fn recognizes_auth_errors_without_matching_arbitrary_numbers() {
for message in [
"fatal: Authentication failed",
"Authentication unsuccessful",
"Invalid username or password",
"HTTP Basic: Access denied",
"Authorization failed",
"fatal: could not read Username for 'https://example.com'",
"fatal: could not read Password for 'https://example.com'",
"fatal: unable to access: The requested URL returned error: 401",
"fatal: unable to access: The requested URL returned error: 403",
] {
assert!(is_auth_failure(message), "{message}");
}
for message in [
"Failed to connect to example.com port 4010",
"Could not resolve host: server403.example",
"fatal: repository '/repo/401' does not exist",
"remote: error in commit abc403def",
"The requested URL returned error: 500",
] {
assert!(!is_auth_failure(message), "{message}");
}
}
}
+1
View File
@@ -3,6 +3,7 @@
//! processes, SFTP, local port forwarding, and askpass for `ssh` / `git`.
pub mod askpass;
pub mod git_creds;
pub mod manager;
pub mod secrets;
pub mod session;
+6
View File
@@ -39,6 +39,12 @@ impl SecretFile {
}
}
/// Every stored key, sorted (the secrets themselves stay private).
pub fn keys(&self) -> Vec<String> {
let _g = self.lock.lock();
self.read().into_keys().collect()
}
fn read(&self) -> BTreeMap<String, String> {
match std::fs::read(&self.path) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
+80
View File
@@ -27,6 +27,48 @@ pub fn set_askpass_env(env: Vec<(String, String)>) {
*ASKPASS.write().unwrap_or_else(|e| e.into_inner()) = env;
}
/// Called once per git child with its id, success flag, and stderr.
pub type GitCommandObserver = std::sync::Arc<dyn Fn(u64, bool, &str) + Send + Sync>;
static COMMAND_OBSERVER: std::sync::RwLock<Option<(&'static str, GitCommandObserver)>> = std::sync::RwLock::new(None);
/// Installs the completion observer and the environment variable carrying each
/// child's unique id to its credential helper.
pub fn set_git_command_observer(env_name: &'static str, observer: GitCommandObserver) {
*COMMAND_OBSERVER.write().unwrap_or_else(|e| e.into_inner()) = Some((env_name, observer));
}
struct CommandSession {
id: u64,
observer: Option<GitCommandObserver>,
}
impl CommandSession {
fn start(cmd: &mut Command) -> Self {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let hook = COMMAND_OBSERVER.read().unwrap_or_else(|e| e.into_inner()).clone();
let observer = hook.map(|(env_name, observer)| {
cmd.env(env_name, id.to_string());
observer
});
Self { id, observer }
}
fn finish(&mut self, success: bool, stderr: &str) {
if let Some(observer) = self.observer.take() {
observer(self.id, success, stderr);
}
}
}
impl Drop for CommandSession {
fn drop(&mut self) {
// Cancellation or an I/O error discards only this child's pending answers.
self.finish(false, "");
}
}
/// Locates the auto-detected git executable once per process.
///
/// GUI apps on macOS start with a minimal `PATH`, so a few well-known install
@@ -143,6 +185,7 @@ pub(crate) fn run_raw_env(
.stdout(Stdio::piped())
.stderr(Stdio::piped());
tracing::debug!(?args, "git");
let mut session = CommandSession::start(&mut cmd);
let mut child = cmd.spawn().map_err(spawn_error)?;
let writer = match (stdin, child.stdin.take()) {
(Some(data), Some(mut pipe)) => {
@@ -158,6 +201,7 @@ pub(crate) fn run_raw_env(
if let Some(w) = writer {
let _ = w.join();
}
session.finish(out.status.success(), &String::from_utf8_lossy(&out.stderr));
Ok(Output { stdout: out.stdout, stderr: out.stderr, code: out.status.code() })
}
@@ -274,6 +318,7 @@ pub(crate) fn run_streaming_env(
cmd.process_group(0);
}
tracing::debug!(?args, "git (streaming)");
let mut session = CommandSession::start(&mut cmd);
let mut child = cmd.spawn().map_err(spawn_error)?;
let (tx, rx) = mpsc::channel::<Event>();
@@ -332,11 +377,13 @@ pub(crate) fn run_streaming_env(
let _ = r.join();
}
if status.success() {
session.finish(true, &stderr_tail.join("\n"));
return Ok(());
}
let relevant: Vec<&str> =
conflicts.iter().chain(stderr_tail.iter()).map(String::as_str).filter(|l| !is_progress_line(l)).collect();
let msg = relevant.join("\n");
session.finish(false, &stderr_tail.join("\n"));
Err(failure(args, msg.as_bytes(), b"", status.code()))
}
@@ -420,3 +467,36 @@ fn kill(child: &mut Child) {
}
let _ = child.kill();
}
#[cfg(test)]
mod command_session_tests {
use super::*;
#[test]
fn command_ids_reach_children_and_completion_is_once_per_session() {
let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let seen = events.clone();
set_git_command_observer(
"OPENIDEA_TEST_COMMAND_ID",
std::sync::Arc::new(move |id, success, stderr| {
seen.lock().unwrap().push((id, success, stderr.to_owned()));
}),
);
let mut first_cmd = Command::new("git");
let mut second_cmd = Command::new("git");
let mut first = CommandSession::start(&mut first_cmd);
let second = CommandSession::start(&mut second_cmd);
let (first_id, second_id) = (first.id, second.id);
assert_ne!(first_id, second_id);
for (cmd, id) in [(&first_cmd, first_id), (&second_cmd, second_id)] {
let value = cmd.get_envs().find(|(key, _)| *key == "OPENIDEA_TEST_COMMAND_ID").unwrap().1.unwrap();
assert_eq!(value, std::ffi::OsStr::new(&id.to_string()));
}
drop(second); // cancellation discards this command's state
first.finish(true, "done");
drop(first);
let events = events.lock().unwrap();
let ours: Vec<_> = events.iter().filter(|(id, _, _)| *id == first_id || *id == second_id).cloned().collect();
assert_eq!(ours, vec![(second_id, false, String::new()), (first_id, true, "done".into())]);
}
}
+4 -1
View File
@@ -455,7 +455,10 @@ mod status;
mod text_encoding;
mod write;
pub use cli::{detected_git_executable, git_version, set_askpass_env, set_git_executable};
pub use cli::{
GitCommandObserver, detected_git_executable, git_version, set_askpass_env, set_git_command_observer,
set_git_executable,
};
pub use diff::build_partial_patch;
pub use ignore::{IgnoreDestination, add_to_ignore};
pub use manage::{MergeOptions, RebaseOptions, RemoteEdit, Worktree};
+2
View File
@@ -424,6 +424,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
ssh::ssh_connected,
ssh::ssh_disconnect,
ssh::ssh_askpass_answer,
ssh::git_credentials,
ssh::git_credential_forget,
ssh::ssh_terminal_command,
deploy::deploy_upload,
deploy::deploy_download,
+18 -2
View File
@@ -61,13 +61,29 @@ pub fn ssh_disconnect(state: State<'_, AppState>, id: String) {
/// Answers an `IdeEvent::Askpass` prompt (`None`: cancelled); every window drops it.
#[tauri::command]
#[specta::specta]
pub fn ssh_askpass_answer(state: State<'_, AppState>, id: u64, answer: Option<String>) {
pub fn ssh_askpass_answer(state: State<'_, AppState>, id: u64, answer: Option<String>, remember: bool) {
if let Some(server) = &state.askpass {
server.answer(id, answer);
server.answer(id, answer, remember);
}
state.sink.emit(IdeEvent::AskpassDone { id });
}
/// Settings › Git › Saved credentials: `scheme://host` of every saved entry.
#[tauri::command]
#[specta::specta]
pub fn git_credentials(_state: State<'_, AppState>) -> Vec<String> {
ide_ssh::git_creds::get().map(|s| s.list()).unwrap_or_default()
}
/// Forgets the credential of `key` (a `git_credentials` entry).
#[tauri::command]
#[specta::specta]
pub fn git_credential_forget(_state: State<'_, AppState>, key: String) {
if let Some(store) = ide_ssh::git_creds::get() {
store.forget(&key);
}
}
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct SshTerminalCommand {
+17 -1
View File
@@ -66,6 +66,8 @@ impl AppState {
let data_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&config_dir)?;
std::fs::create_dir_all(&data_dir)?;
// Saved git HTTPS credentials (askpass answers them silently).
ide_ssh::git_creds::init(data_dir.join("git-credentials.json"));
// File › Invalidate Caches… requested at the last run.
let removed = ide_workspace::caches::apply_pending(&data_dir);
if !removed.is_empty() {
@@ -158,10 +160,24 @@ fn start_askpass(app: &AppHandle) -> Option<ide_ssh::askpass::AskpassServer> {
let on_prompt = Arc::new(move |p: ide_ssh::askpass::AskpassPrompt| {
// A focused Settings window hands the prompt to its project window.
let window = crate::windows::target_window(&handle).map(|w| AppState::owner_window(w.label()).to_owned());
TauriSink { app: handle.clone(), window }.emit(IdeEvent::Askpass { id: p.id, prompt: p.prompt, kind: p.kind });
let remember = ide_ssh::askpass::remembers(&p.prompt);
TauriSink { app: handle.clone(), window }.emit(IdeEvent::Askpass {
id: p.id,
prompt: p.prompt,
kind: p.kind,
remember,
});
});
match ide_ssh::askpass::AskpassServer::start(exe, on_prompt) {
Ok(server) => {
ide_vcs::set_git_command_observer(
ide_ssh::askpass::COMMAND_ENV,
Arc::new(|id, success, stderr| {
if let Some(store) = ide_ssh::git_creds::get() {
store.command_finished(id, success, stderr);
}
}),
);
ide_vcs::set_askpass_env(server.env());
Some(server)
}
+8 -2
View File
@@ -909,7 +909,11 @@ export const commands = {
sshConnected: () => __TAURI_INVOKE<string[]>("ssh_connected"),
sshDisconnect: (id: string) => __TAURI_INVOKE<void>("ssh_disconnect", { id }),
/** Answers an `IdeEvent::Askpass` prompt (`None`: cancelled); every window drops it. */
sshAskpassAnswer: (id: number, answer: string | null) => __TAURI_INVOKE<void>("ssh_askpass_answer", { id, answer }),
sshAskpassAnswer: (id: number, answer: string | null, remember: boolean) => __TAURI_INVOKE<void>("ssh_askpass_answer", { id, answer, remember }),
/** Settings › Git › Saved credentials: `scheme://host` of every saved entry. */
gitCredentials: () => __TAURI_INVOKE<string[]>("git_credentials"),
/** Forgets the credential of `key` (a `git_credentials` entry). */
gitCredentialForget: (key: string) => __TAURI_INVOKE<void>("git_credential_forget", { key }),
/**
* Tools › Start SSH Session: the system `ssh` command line for configuration `id`
* (run in a terminal tab, so prompts and host-key questions are answered there).
@@ -3368,7 +3372,9 @@ cancellable: boolean } |
*/
{ type: "projectRootsChanged"; root: IpcPath } |
/** A child process (`ssh`, `git`) asks for a credential; answered with `ssh_askpass_answer`. */
{ type: "askpass"; id: number; prompt: string; kind: AskpassKind } |
{ type: "askpass"; id: number; prompt: string; kind: AskpassKind;
/** The dialog offers "Remember" for the answer (git HTTPS passwords). */
remember: boolean } |
/** Prompt `id` was answered (in any window) or gave up. */
{ type: "askpassDone"; id: number } |
/**
+63
View File
@@ -139,6 +139,58 @@ function ProtectedBranchesRow() {
);
}
/** Saved git HTTPS credentials (Settings › Git › Passwords): list and forget. */
function GitCredentialsRow() {
const [hosts, setHosts] = useState<string[]>([]);
useEffect(() => {
if (!isTauri()) return;
void commands.gitCredentials().then(setHosts, () => undefined);
}, []);
const forget = async (key: string) => {
await commands.gitCredentialForget(key);
setHosts((h) => h.filter((k) => k !== key));
};
if (hosts.length === 0)
return (
<Row
label="Passwords"
hint={
'Credentials the IDE answers git\'s HTTPS prompts with. Saved after a successful fetch / pull / push when "Remember for this host" was checked.'
}
>
<span className={styles.empty}>
<T>No saved credentials</T>
</span>
</Row>
);
return (
<Row
label="Passwords"
stacked
hint={
"Credentials the IDE answers git's HTTPS prompts with, stored in the app's own secret file (not git's credential.helper). A rejected credential is forgotten automatically."
}
>
<div className={styles.tableBox}>
<table className={styles.table} aria-label={tx("Saved git credentials")}>
<tbody>
{hosts.map((h) => (
<tr key={h}>
<td className={styles.mono}>{h}</td>
<td style={{ width: 90 }}>
<Button size="small" onClick={() => void forget(h)}>
<T>Forget</T>
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Row>
);
}
function LargeFilesRow() {
return (
<ToggleNumber
@@ -520,6 +572,17 @@ export const VCS_PAGES: PageSpec[] = [
},
],
},
{
title: "Passwords",
options: [
{
kind: "custom",
label: "Passwords",
keywords: ["credentials", "remember", "forget", "https"],
component: GitCredentialsRow,
},
],
},
{
title: "Stash",
options: [
+5 -2
View File
@@ -1,7 +1,7 @@
/** The oldest pending askpass prompt as a modal: password / text field, or Yes / No for a host key. */
import { useState } from "react";
import { useTx } from "@/platform/i18n";
import { Dialog, Input } from "@/ui";
import { Checkbox, Dialog, Input } from "@/ui";
import { answerAskpass, askpassAnswer, useAskpass, type AskpassPrompt } from "./askpass";
import styles from "./Ssh.module.css";
@@ -13,7 +13,9 @@ export function AskpassHost() {
function AskpassDialog({ prompt: p }: { prompt: AskpassPrompt }) {
const tx = useTx();
const [text, setText] = useState("");
const done = (choice: Parameters<typeof askpassAnswer>[1]) => answerAskpass(p.id, askpassAnswer(p.kind, choice));
const [remember, setRemember] = useState(true);
const done = (choice: Parameters<typeof askpassAnswer>[1]) =>
answerAskpass(p, askpassAnswer(p.kind, choice), remember);
const cancel = () => done(null);
if (p.kind === "confirm")
@@ -62,6 +64,7 @@ function AskpassDialog({ prompt: p }: { prompt: AskpassPrompt }) {
value={text}
onChange={(e) => setText(e.target.value)}
/>
{p.remember && <Checkbox checked={remember} onChange={setRemember} label={tx("Remember for this host")} />}
</form>
</Dialog>
);
+12 -3
View File
@@ -9,6 +9,8 @@ export interface AskpassPrompt {
id: number;
prompt: string;
kind: AskpassKind;
/** The dialog offers "Remember" (git HTTPS passwords). */
remember: boolean;
}
export const useAskpass = create<{ queue: AskpassPrompt[] }>(() => ({ queue: [] }));
@@ -32,7 +34,14 @@ export function askpassAnswer(
return kind === "confirm" ? null : choice.text;
}
export function answerAskpass(id: number, answer: string | null, send = commands.sshAskpassAnswer) {
dropAskpass(id);
void send(id, answer).catch(() => {});
export function answerAskpass(
p: AskpassPrompt,
answer: string | null,
remember: boolean,
send = commands.sshAskpassAnswer,
) {
dropAskpass(p.id);
// The backend ties `remember` to this prompt; the git command saves it only
// on success and drops it again when authentication fails.
void send(p.id, answer, remember).catch(() => {});
}
+1 -1
View File
@@ -13,7 +13,7 @@ import { chooseSshSession, SSH_SETTINGS_PAGE, startSshSession } from "./session"
export function activate(): () => void {
const disposers = [
registerOverlay({ id: "ssh.askpass", component: AskpassHost }),
onIdeEvent("askpass", (e) => enqueueAskpass({ id: e.id, prompt: e.prompt, kind: e.kind })),
onIdeEvent("askpass", (e) => enqueueAskpass({ id: e.id, prompt: e.prompt, kind: e.kind, remember: e.remember })),
onIdeEvent("askpassDone", (e) => dropAskpass(e.id)),
registerActions([
{
+10 -10
View File
@@ -7,26 +7,26 @@ describe("askpass queue", () => {
beforeEach(() => useAskpass.setState({ queue: [] }));
it("queues prompts in arrival order, once each", () => {
enqueueAskpass({ id: 1, prompt: "Password:", kind: "secret" });
enqueueAskpass({ id: 2, prompt: "Continue connecting (yes/no)?", kind: "confirm" });
enqueueAskpass({ id: 1, prompt: "Password:", kind: "secret" });
enqueueAskpass({ id: 1, prompt: "Password:", kind: "secret", remember: false });
enqueueAskpass({ id: 2, prompt: "Continue connecting (yes/no)?", kind: "confirm", remember: false });
enqueueAskpass({ id: 1, prompt: "Password:", kind: "secret", remember: false });
expect(useAskpass.getState().queue.map((p) => p.id)).toEqual([1, 2]);
});
it("drops a prompt answered elsewhere", () => {
enqueueAskpass({ id: 1, prompt: "a", kind: "text" });
enqueueAskpass({ id: 2, prompt: "b", kind: "text" });
enqueueAskpass({ id: 1, prompt: "a", kind: "text", remember: false });
enqueueAskpass({ id: 2, prompt: "b", kind: "text", remember: false });
dropAskpass(1);
dropAskpass(9);
expect(useAskpass.getState().queue.map((p) => p.id)).toEqual([2]);
});
it("answers the backend and shows the next prompt", () => {
const send = vi.fn((_id: number, _answer: string | null) => Promise.resolve());
enqueueAskpass({ id: 1, prompt: "a", kind: "secret" });
enqueueAskpass({ id: 2, prompt: "b", kind: "secret" });
answerAskpass(1, "hunter2", send);
expect(send).toHaveBeenCalledWith(1, "hunter2");
const send = vi.fn((_id: number, _answer: string | null, _remember: boolean) => Promise.resolve());
enqueueAskpass({ id: 1, prompt: "a", kind: "secret", remember: true });
enqueueAskpass({ id: 2, prompt: "b", kind: "secret", remember: false });
answerAskpass({ id: 1, prompt: "a", kind: "secret", remember: true }, "hunter2", true, send);
expect(send).toHaveBeenCalledWith(1, "hunter2", true);
expect(useAskpass.getState().queue.map((p) => p.id)).toEqual([2]);
});
+8 -1
View File
@@ -4829,5 +4829,12 @@
"JDK used for Gradle sync, tasks, run configurations and tests. An explicit JAVA_HOME in a run configuration takes precedence.": "用于 Gradle 同步、任务、运行配置和测试的 JDK。运行配置中显式设置的 JAVA_HOME 优先。",
"Uses the Gradle wrapper when present, otherwise the project-pinned mise/asdf Gradle, then Gradle on PATH.": "优先使用 Gradle 包装器,否则使用项目通过 mise/asdf 固定的 Gradle,最后使用 PATH 中的 Gradle。",
"Uses bin/gradle (bin/gradle.bat on Windows) instead of the wrapper or pinned toolchain.": "使用 bin/gradle(Windows 上为 bin/gradle.bat),覆盖包装器或固定的工具链。",
"Sets GRADLE_USER_HOME. Empty uses Gradle's default.": "设置 GRADLE_USER_HOME。留空则使用 Gradle 默认值。"
"Sets GRADLE_USER_HOME. Empty uses Gradle's default.": "设置 GRADLE_USER_HOME。留空则使用 Gradle 默认值。",
"Passwords": "密码",
"Credentials the IDE answers git's HTTPS prompts with. Saved after a successful fetch / pull / push when \"Remember for this host\" was checked.": "IDE 用于应答 git HTTPS 提示的凭据。勾选「记住此主机」后,在 fetch / pull / push 成功时保存。",
"No saved credentials": "没有已保存的凭据",
"Credentials the IDE answers git's HTTPS prompts with, stored in the app's own secret file (not git's credential.helper). A rejected credential is forgotten automatically.": "IDE 用于应答 git HTTPS 提示的凭据,存储在应用自己的密钥文件中(而非 git 的 credential.helper)。被拒绝的凭据会自动删除。",
"Saved git credentials": "已保存的 git 凭据",
"Forget": "忘记",
"Remember for this host": "记住此主机"
}