diff --git a/.github/scripts/check-host-boundary.sh b/.github/scripts/check-host-boundary.sh index bc102de8..e2cf94cf 100755 --- a/.github/scripts/check-host-boundary.sh +++ b/.github/scripts/check-host-boundary.sh @@ -59,6 +59,9 @@ src/ui/app.rs|std::fs::create_dir_all src/ui/ssh_prompt.rs|std::fs::read src/ui/ssh_connect.rs|std::fs::read src/ui/settings.rs|std::fs::read +# The host editor asking which of those keys is on this machine before it offers +# a passphrase box for one — the same client-side key, never a workspace path. +src/ui/settings.rs|std::fs::metadata # Shell history lives in the local user's home (`~/.zsh_history` &co.) and backs # this app's own history search. A remote pane's history is the remote shell's diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index a6673ed2..e1621872 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -278,7 +278,6 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsUserHint => "resolved at connect", L10nKey::SettingsPasswordDesc => "Kept in the system keychain, never in the config file.", L10nKey::SettingsPasswordHint => "Ask when connecting", - L10nKey::SettingsForget => "Forget", L10nKey::SettingsKeyPassphrase => "Key passphrase", L10nKey::SettingsKeyPassphraseDesc => "Unlocks the key above. Kept in the system keychain.", L10nKey::SettingsPassphraseNeedsKey => { diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 544f18ad..1584488b 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -281,7 +281,6 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "システムのキーチェーンに保存され、設定ファイルには書き込まれません。" } L10nKey::SettingsPasswordHint => "接続時に入力する", - L10nKey::SettingsForget => "削除", L10nKey::SettingsKeyPassphrase => "鍵のパスフレーズ", L10nKey::SettingsKeyPassphraseDesc => { "上の鍵を解錠します。システムのキーチェーンに保存されます。" diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 6072b77c..0e0dc7de 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -273,7 +273,6 @@ l10n_keys! { SettingsPassword, SettingsPasswordDesc, SettingsPasswordHint, - SettingsForget, SettingsKeyPassphrase, SettingsKeyPassphraseDesc, SettingsPassphraseNeedsKey, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 6724f8ef..997e0eee 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -251,7 +251,6 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsUserHint => "连接时再定", L10nKey::SettingsPasswordDesc => "存在系统钥匙串里,不会写进配置文件。", L10nKey::SettingsPasswordHint => "连接时再问", - L10nKey::SettingsForget => "清除", L10nKey::SettingsKeyPassphrase => "密钥口令", L10nKey::SettingsKeyPassphraseDesc => "用来解锁上面那个密钥,存在系统钥匙串里。", L10nKey::SettingsPassphraseNeedsKey => "先填一个密钥文件——口令是跟着它解锁的那个密钥存的。", diff --git a/src/ui/settings.rs b/src/ui/settings.rs index b821685f..2de588b2 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -1690,25 +1690,47 @@ fn stored_password(profile: &SshProfile) -> String { .unwrap_or_default() } -/// The first key file a profile names that is actually there. +/// The first key file a profile would offer that is actually there. /// /// A passphrase is accounted by the key's *contents*, not by its path, so a /// file that cannot be read is a key nothing can be stored against. fn first_readable_key(profile: &SshProfile) -> Option { - profile - .expanded_identity_files() - .into_iter() - .find(|p| std::fs::metadata(crate::core::ssh_profile::expand_tilde(p)).is_ok()) + first_readable_key_in(&profile.identity_files, &profile.host, &profile.user) } /// The same answer for a form that has not been collected into a profile yet: /// the key field as typed, with the host and user beside it filling in `%h` /// and `%r`. fn first_readable_key_in(files: &[String], host: &str, user: &str) -> Option { - files - .iter() - .map(|f| crate::core::ssh_profile::expand_identity_placeholders(f, host, user)) - .find(|p| std::fs::metadata(crate::core::ssh_profile::expand_tilde(p)).is_ok()) + first_readable_key_or( + files, + host, + user, + crate::core::ssh_profile::default_identity_candidates, + ) +} + +/// An empty key field is not "no key": `build_spec_inner` offers the `~/.ssh` +/// defaults then, and looks their passphrases up by those exact strings. The +/// box has to follow the same list, or the most common setup — no key named, +/// an encrypted `id_ed25519` — could never be given a passphrase here. +fn first_readable_key_or( + files: &[String], + host: &str, + user: &str, + defaults: impl FnOnce() -> Vec, +) -> Option { + let candidates = if files.is_empty() { + defaults() + } else { + files + .iter() + .map(|f| crate::core::ssh_profile::expand_identity_placeholders(f, host, user)) + .collect() + }; + candidates + .into_iter() + .find(|p| std::fs::metadata(p).is_ok()) } fn stored_passphrase(key_path: &str) -> String { @@ -3857,16 +3879,17 @@ impl Tty7App { // The passphrase belongs to whichever key the field above names, so // when that answer changes the box has to change with it. Without this // a form opened on one key and pointed at another would carry the - // first key's passphrase across and save it over the second's. - subs.push(cx.subscribe_in( - &identity_files, - window, - |this, _i, ev: &InputEvent, window, cx| { - if matches!(ev, InputEvent::Change) { - this.resync_key_passphrase(window, cx); - } - }, - )); + // first key's passphrase across and save it over the second's. Host and + // user count too: they fill in a `%h` / `%r` in the key's path. + for input in [&identity_files, &host, &user] { + subs.push( + cx.subscribe_in(input, window, |this, _i, ev: &InputEvent, window, cx| { + if matches!(ev, InputEvent::Change) { + this.resync_key_passphrase(window, cx); + } + }), + ); + } let mut watch = vec![ &name, &host, @@ -8129,10 +8152,24 @@ mod tests { Some(real.to_string_lossy().to_string()) ); assert_eq!( - first_readable_key_in(&[missing], "example.com", "ada"), + first_readable_key_in(&[missing.clone()], "example.com", "ada"), + None + ); + // An empty field falls back to the defaults the handshake offers — + // and a named key, even a missing one, replaces them entirely. + let defaults = || vec![missing.clone(), real.to_string_lossy().to_string()]; + assert_eq!( + first_readable_key_or(&[], "example.com", "ada", defaults), + Some(real.to_string_lossy().to_string()) + ); + assert_eq!( + first_readable_key_or(&[missing.clone()], "example.com", "ada", defaults), + None + ); + assert_eq!( + first_readable_key_or(&[], "example.com", "ada", Vec::new), None ); - assert_eq!(first_readable_key_in(&[], "example.com", "ada"), None); std::fs::remove_dir_all(&dir).ok(); }