mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
co-authored by
a-rookie-of-C-language
parent
2c10b63f76
commit
e85ffa2ae8
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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())]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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 } |
|
||||
/**
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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": "记住此主机"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user