mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 08:02:21 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -281,7 +281,6 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
"システムのキーチェーンに保存され、設定ファイルには書き込まれません。"
|
||||
}
|
||||
L10nKey::SettingsPasswordHint => "接続時に入力する",
|
||||
L10nKey::SettingsForget => "削除",
|
||||
L10nKey::SettingsKeyPassphrase => "鍵のパスフレーズ",
|
||||
L10nKey::SettingsKeyPassphraseDesc => {
|
||||
"上の鍵を解錠します。システムのキーチェーンに保存されます。"
|
||||
|
||||
@@ -273,7 +273,6 @@ l10n_keys! {
|
||||
SettingsPassword,
|
||||
SettingsPasswordDesc,
|
||||
SettingsPasswordHint,
|
||||
SettingsForget,
|
||||
SettingsKeyPassphrase,
|
||||
SettingsKeyPassphraseDesc,
|
||||
SettingsPassphraseNeedsKey,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user