fix(settings): pass the host-boundary guard and follow the default keys

The passphrase box checks which key file is on this machine with
std::fs::metadata, which the host-boundary guard rejected; allowlist it
beside the existing std::fs::read entry for the same client-side key.

An empty key field now resolves to the ~/.ssh defaults build_spec_inner
offers, so a default encrypted key can be given a passphrase from the
form. The key is also re-resolved when host or user change, since they
fill %h/%r in the path. Drop the unused SettingsForget string.
This commit is contained in:
l0ng-ai
2026-09-14 18:32:55 +08:00
parent 52b2742977
commit 2e8a43a35e
6 changed files with 61 additions and 25 deletions
+3
View File
@@ -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
-1
View File
@@ -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 => {
-1
View File
@@ -281,7 +281,6 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
"システムのキーチェーンに保存され、設定ファイルには書き込まれません。"
}
L10nKey::SettingsPasswordHint => "接続時に入力する",
L10nKey::SettingsForget => "削除",
L10nKey::SettingsKeyPassphrase => "鍵のパスフレーズ",
L10nKey::SettingsKeyPassphraseDesc => {
"上の鍵を解錠します。システムのキーチェーンに保存されます。"
-1
View File
@@ -273,7 +273,6 @@ l10n_keys! {
SettingsPassword,
SettingsPasswordDesc,
SettingsPasswordHint,
SettingsForget,
SettingsKeyPassphrase,
SettingsKeyPassphraseDesc,
SettingsPassphraseNeedsKey,
-1
View File
@@ -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 => "先填一个密钥文件——口令是跟着它解锁的那个密钥存的。",
+58 -21
View File
@@ -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<String> {
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<String> {
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<String>,
) -> Option<String> {
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();
}