diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index 95fcca2c..1261a077 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -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 { diff --git a/crates/ide-ssh/src/askpass.rs b/crates/ide-ssh/src/askpass.rs index 8e84d7d3..e5414f3d 100644 --- a/crates/ide-ssh/src/askpass.rs +++ b/crates/ide-ssh/src/askpass.rs @@ -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, token: String, prompt: String, } @@ -51,7 +60,13 @@ struct Response { answer: Option, } -type Pending = Arc>>>>; +type Pending = Arc>>; + +struct PendingPrompt { + command_id: Option, + prompt: String, + tx: mpsc::Sender>, +} /// 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) { - 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, 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 { 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> { 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::(&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::(&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")); } diff --git a/crates/ide-ssh/src/git_creds.rs b/crates/ide-ssh/src/git_creds.rs new file mode 100644 index 00000000..bdc818e1 --- /dev/null +++ b/crates/ide-ssh/src/git_creds.rs @@ -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, + sessions: Arc>>, +} + +#[derive(Default)] +struct Session { + /// Stored credentials this command used (possibly wrong): forgotten if the + /// command fails with an authentication error. + used: HashMap, + /// `scheme://host[:port]` → credential the user asked to remember: written when the + /// command succeeds. + pending: HashMap, +} + +/// Global instance: the askpass server and every `git` child process agree on it. +static INSTANCE: Mutex> = 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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}"); + } + } +} diff --git a/crates/ide-ssh/src/lib.rs b/crates/ide-ssh/src/lib.rs index ec862701..1ab25a18 100644 --- a/crates/ide-ssh/src/lib.rs +++ b/crates/ide-ssh/src/lib.rs @@ -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; diff --git a/crates/ide-ssh/src/secrets.rs b/crates/ide-ssh/src/secrets.rs index 0e869dfa..4cd6a9cb 100644 --- a/crates/ide-ssh/src/secrets.rs +++ b/crates/ide-ssh/src/secrets.rs @@ -39,6 +39,12 @@ impl SecretFile { } } + /// Every stored key, sorted (the secrets themselves stay private). + pub fn keys(&self) -> Vec { + let _g = self.lock.lock(); + self.read().into_keys().collect() + } + fn read(&self) -> BTreeMap { match std::fs::read(&self.path) { Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| { diff --git a/crates/ide-vcs/src/cli.rs b/crates/ide-vcs/src/cli.rs index 1f2e9f08..1ff173dc 100644 --- a/crates/ide-vcs/src/cli.rs +++ b/crates/ide-vcs/src/cli.rs @@ -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; + +static COMMAND_OBSERVER: std::sync::RwLock> = 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, +} + +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::(); @@ -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())]); + } +} diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index aa16e84f..bb25f4d5 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -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}; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ac8dd155..fe9264ef 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -424,6 +424,8 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src-tauri/src/commands/ssh.rs b/src-tauri/src/commands/ssh.rs index 0c58ebc7..390c31fc 100644 --- a/src-tauri/src/commands/ssh.rs +++ b/src-tauri/src/commands/ssh.rs @@ -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) { +pub fn ssh_askpass_answer(state: State<'_, AppState>, id: u64, answer: Option, 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 { + 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 { diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 29f5ffb3..7f265e72 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -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 { 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) } diff --git a/src/bindings.ts b/src/bindings.ts index 22408372..401a6f5d 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -909,7 +909,11 @@ export const commands = { sshConnected: () => __TAURI_INVOKE("ssh_connected"), sshDisconnect: (id: string) => __TAURI_INVOKE("ssh_disconnect", { id }), /** Answers an `IdeEvent::Askpass` prompt (`None`: cancelled); every window drops it. */ - sshAskpassAnswer: (id: number, answer: string | null) => __TAURI_INVOKE("ssh_askpass_answer", { id, answer }), + sshAskpassAnswer: (id: number, answer: string | null, remember: boolean) => __TAURI_INVOKE("ssh_askpass_answer", { id, answer, remember }), + /** Settings › Git › Saved credentials: `scheme://host` of every saved entry. */ + gitCredentials: () => __TAURI_INVOKE("git_credentials"), + /** Forgets the credential of `key` (a `git_credentials` entry). */ + gitCredentialForget: (key: string) => __TAURI_INVOKE("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 } | /** diff --git a/src/features/settings/pages/vcs.tsx b/src/features/settings/pages/vcs.tsx index 9bb511d1..225095a0 100644 --- a/src/features/settings/pages/vcs.tsx +++ b/src/features/settings/pages/vcs.tsx @@ -139,6 +139,58 @@ function ProtectedBranchesRow() { ); } +/** Saved git HTTPS credentials (Settings › Git › Passwords): list and forget. */ +function GitCredentialsRow() { + const [hosts, setHosts] = useState([]); + 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 ( + + + No saved credentials + + + ); + return ( + +
+ + + {hosts.map((h) => ( + + + + + ))} + +
{h} + +
+
+
+ ); +} + function LargeFilesRow() { return ( [1]) => answerAskpass(p.id, askpassAnswer(p.kind, choice)); + const [remember, setRemember] = useState(true); + const done = (choice: Parameters[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 && } ); diff --git a/src/features/ssh/askpass.ts b/src/features/ssh/askpass.ts index 2f5b2248..6069870d 100644 --- a/src/features/ssh/askpass.ts +++ b/src/features/ssh/askpass.ts @@ -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(() => {}); } diff --git a/src/features/ssh/index.ts b/src/features/ssh/index.ts index 7de79f9c..38c7e12f 100644 --- a/src/features/ssh/index.ts +++ b/src/features/ssh/index.ts @@ -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([ { diff --git a/src/features/ssh/ssh.test.ts b/src/features/ssh/ssh.test.ts index a72852cf..190608c1 100644 --- a/src/features/ssh/ssh.test.ts +++ b/src/features/ssh/ssh.test.ts @@ -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]); }); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 0adc22a3..62a3bfc5 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "记住此主机" }