fix(settings): refuse a half-filled SSH profile instead of saving it

The SSH profile form saved whatever was in it. An empty host wrote a
profile that renders as a blank row in the host list and hands
`TcpStream::connect` an empty name — and Connect had no gate at all, so
the first thing the user heard about it was a DNS error naming nothing.
A jump host with a typo in it resolved to `None` and saved as a direct
connection, with the field blank the next time the form opened. And
`parse_host_port` was `parse().unwrap_or(0)`, so `proxy.example.com`,
`proxy.example.com:` and `proxy.example.com:88O` all saved a proxy on
port 0, which the socket layer got to explain (#492, #493, #494).

The rules now live in `validate_ssh_draft`, a plain function over a
plain-String snapshot of the form, which returns both the profile the
form would save and what is wrong with it. Both, always: the Escape
prompt asks whether the form differs from the config, and handing back
only the errors would make a brand-new invalid profile compare equal to
the nothing on disk — Escape would throw the typing away without asking.

Only the host is required. A name is not, because the list already falls
back to the address and every host imported from ~/.ssh/config arrives
without one. A blank port still means 22, but a non-empty one has to be
a port, so "0", "abc" and "70000" are refused rather than saved as
written or quietly rewritten. A proxy address with no port takes the
scheme's default (1080 / 8080) and `host_port_text` writes that back
into the field, so the number it picked is visible; a colon with nothing
usable after it is an error. `map_proxy` also stops treating port 0 as a
proxy, because configs written before this are already on disk.

Each complaint prints under the field it is about, and Save and Connect
are disabled while any of them stands; a section holding one unfolds so
the disabled button always has a visible reason. The "needs a host" line
waits until the name/host/port/user group has something in it — every
field notifies per keystroke, so otherwise a new host would be told off
before anyone had typed a character. Consequence: on a pristine new form
Save is now disabled where it used to be enabled.
This commit is contained in:
l0ng-ai
2026-08-11 19:58:33 +08:00
parent 0f5e63701e
commit 84947b2deb
6 changed files with 609 additions and 85 deletions
+7
View File
@@ -229,6 +229,8 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsNameDesc => "A label for this connection.",
L10nKey::SettingsHost => "Host",
L10nKey::SettingsHostDesc => "Hostname or IP address.",
L10nKey::SettingsHostRequired => "Needs a host — won't be saved.",
L10nKey::SettingsPortInvalid => "Port must be 1-65535 — blank means 22.",
L10nKey::SettingsUser => "User",
L10nKey::SettingsUserDesc => "Login user (blank = resolve at connect).",
L10nKey::SettingsAuth => "Auth",
@@ -242,6 +244,8 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsJumpHostDesc => {
"Name of another profile to tunnel through (blank = direct)."
}
L10nKey::SettingsJumpHostUnknown => "No host profile named {jump_name} — won't be saved.",
L10nKey::SettingsJumpHostSelf => "A host can't be its own jump host — won't be saved.",
L10nKey::SettingsNoneSummary => "(none)",
L10nKey::SettingsPortForwarding => "Port forwarding",
L10nKey::SettingsRulesOpenedWithConnection => "1 rule, opened with the connection",
@@ -274,6 +278,9 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsSocks5ProxyDesc => "host:port (blank = none).",
L10nKey::SettingsHttpProxy => "HTTP proxy",
L10nKey::SettingsHttpProxyDesc => "host:port (blank = none).",
L10nKey::SettingsProxyPortInvalid => {
"Port must be 1-65535 — the host on its own takes the default port."
}
L10nKey::SettingsKexAlgorithms => "KEX algorithms",
L10nKey::SettingsKexAlgorithmsDesc => "Comma-separated (blank = library default).",
L10nKey::SettingsCiphers => "Ciphers",
+11
View File
@@ -226,6 +226,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsNameDesc => "この接続の表示名",
L10nKey::SettingsHost => "ホスト名",
L10nKey::SettingsHostDesc => "ホスト名または IP アドレス",
L10nKey::SettingsHostRequired => "ホスト名が必要です — 保存されません",
L10nKey::SettingsPortInvalid => "ポートは 1-65535 の範囲です — 空欄なら 22 です",
L10nKey::SettingsUser => "ユーザー名",
L10nKey::SettingsUserDesc => "ログインユーザー (空欄 = 接続時に解決)",
L10nKey::SettingsAuth => "認証方式",
@@ -239,6 +241,12 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsJumpHostDesc => {
"トンネリングに使用する別のプロファイル名 (空欄 = 直接接続)"
}
L10nKey::SettingsJumpHostUnknown => {
"{jump_name} という名前のホストプロファイルはありません — 保存されません"
}
L10nKey::SettingsJumpHostSelf => {
"ホストを自分自身のジャンプホストにはできません — 保存されません"
}
L10nKey::SettingsNoneSummary => "(なし)",
L10nKey::SettingsPortForwarding => "ポートフォワーディング",
L10nKey::SettingsRulesOpenedWithConnection => "接続と同時に開くルール 1 件",
@@ -271,6 +279,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsSocks5ProxyDesc => "host:port(空欄 = なし)",
L10nKey::SettingsHttpProxy => "HTTP プロキシ",
L10nKey::SettingsHttpProxyDesc => "host:port(空欄 = なし)",
L10nKey::SettingsProxyPortInvalid => {
"ポートは 1-65535 の範囲です — ホストだけならデフォルトポートを使います"
}
L10nKey::SettingsKexAlgorithms => "KEX アルゴリズム",
L10nKey::SettingsKexAlgorithmsDesc => "カンマ区切り(空欄 = ライブラリのデフォルト)",
L10nKey::SettingsCiphers => "暗号方式",
+5
View File
@@ -244,6 +244,8 @@ l10n_keys! {
SettingsNameDesc,
SettingsHost,
SettingsHostDesc,
SettingsHostRequired,
SettingsPortInvalid,
SettingsUser,
SettingsUserDesc,
SettingsAuth,
@@ -255,6 +257,8 @@ l10n_keys! {
SettingsAuthMode2Fa,
SettingsJumpHost,
SettingsJumpHostDesc,
SettingsJumpHostUnknown,
SettingsJumpHostSelf,
SettingsNoneSummary,
SettingsNoneLower,
SettingsPortForwarding,
@@ -284,6 +288,7 @@ l10n_keys! {
SettingsSocks5ProxyDesc,
SettingsHttpProxy,
SettingsHttpProxyDesc,
SettingsProxyPortInvalid,
SettingsKexAlgorithms,
SettingsKexAlgorithmsDesc,
SettingsCiphers,
+5
View File
@@ -204,6 +204,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsNameDesc => "此连接的标签。",
L10nKey::SettingsHost => "主机",
L10nKey::SettingsHostDesc => "主机名或 IP 地址。",
L10nKey::SettingsHostRequired => "需要填写主机——不会被保存。",
L10nKey::SettingsPortInvalid => "端口必须在 1-65535 之间——留空表示 22。",
L10nKey::SettingsUser => "用户",
L10nKey::SettingsUserDesc => "登录用户(留空表示连接时解析)。",
L10nKey::SettingsAuth => "认证",
@@ -215,6 +217,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsAuthMode2Fa => "2FA",
L10nKey::SettingsJumpHost => "跳板主机",
L10nKey::SettingsJumpHostDesc => "用于中转的另一个主机配置的名称(留空 = 直连)。",
L10nKey::SettingsJumpHostUnknown => "没有名为 {jump_name} 的主机配置——不会被保存。",
L10nKey::SettingsJumpHostSelf => "主机不能把自己当作跳板——不会被保存。",
L10nKey::SettingsNoneSummary => "(无)",
L10nKey::SettingsPortForwarding => "端口转发",
L10nKey::SettingsRulesOpenedWithConnection => "1 条规则,随连接打开",
@@ -243,6 +247,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsSocks5ProxyDesc => "host:port(留空 = 无)。",
L10nKey::SettingsHttpProxy => "HTTP 代理",
L10nKey::SettingsHttpProxyDesc => "host:port(留空 = 无)。",
L10nKey::SettingsProxyPortInvalid => "端口必须在 1-65535 之间——只写主机则使用默认端口。",
L10nKey::SettingsKexAlgorithms => "KEX 算法",
L10nKey::SettingsKexAlgorithmsDesc => "逗号分隔(留空 = 库默认值)。",
L10nKey::SettingsCiphers => "加密算法",
+557 -83
View File
@@ -840,6 +840,19 @@ pub(crate) struct SshProfileForm {
_subs: Vec<Subscription>,
}
impl SshProfileForm {
/// Whether the group that identifies the host — name, host, port, user —
/// is still untouched. Every field notifies on change, so the form
/// re-renders on each keystroke; without this a new host would be told it
/// needs a host before anyone had the chance to type one. Same deal the
/// forward rows strike with `ForwardRuleForm::is_blank`.
fn core_is_blank(&self, cx: &App) -> bool {
[&self.name, &self.host, &self.port, &self.user]
.iter()
.all(|e| e.read(cx).value().trim().is_empty())
}
}
pub(crate) struct ForwardRuleForm {
pub(crate) kind: ForwardKind,
pub(crate) bind_host: Entity<InputState>,
@@ -911,14 +924,39 @@ pub(crate) fn humanize_action(action: &str) -> String {
out
}
fn parse_host_port(s: &str) -> Option<HostPort> {
/// What a blank port field means. The same number `SshProfile`'s serde default
/// writes for a config that never mentioned a port, which is why leaving the
/// field empty has to stay legal: every host imported from `~/.ssh/config`
/// leaves it empty.
const DEFAULT_SSH_PORT: u16 = 22;
/// The port each proxy scheme listens on when the field names only a host.
const DEFAULT_SOCKS_PORT: u16 = 1080;
const DEFAULT_HTTP_PROXY_PORT: u16 = 8080;
/// A port as a form field spells it. Nothing here accepts 0: every port in a
/// profile is one something has to connect to, and no listener answers on 0.
fn parse_port(s: &str) -> Option<u16> {
s.trim().parse::<u16>().ok().filter(|p| *p > 0)
}
/// A proxy address as the form spells it: blank is "no proxy", a bare host
/// takes the scheme's default port, and anything else has to carry a port that
/// exists. This used to be `parse().unwrap_or(0)`, so `proxy.example.com:88O`
/// saved a proxy on port 0 and the failure surfaced far away, in the socket
/// layer. The default port is not a secret either — `host_port_text` writes it
/// back into the field the next time the form opens.
fn parse_host_port_checked(s: &str, default_port: u16) -> Result<Option<HostPort>, SshFieldError> {
let s = s.trim();
if s.is_empty() {
return None;
return Ok(None);
}
match s.rsplit_once(':') {
Some((h, p)) => Some(HostPort::new(h.trim(), p.trim().parse().unwrap_or(0))),
None => Some(HostPort::new(s, 0)),
Some((h, p)) => match parse_port(p) {
Some(port) => Ok(Some(HostPort::new(h.trim(), port))),
None => Err(SshFieldError::ProxyPortRange),
},
None => Ok(Some(HostPort::new(s, default_port))),
}
}
@@ -944,6 +982,207 @@ fn split_lines(s: &str) -> Vec<String> {
.collect()
}
/// Why one field of the SSH profile form cannot be saved. A value rather than
/// a finished sentence, so the rules stay a plain function a test can call —
/// the wording, and the locale it is written in, belong to the render pass.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SshFieldError {
/// Nothing to connect to. Saved anyway, the profile used to render as an
/// empty row in the host list and hand `TcpStream::connect` an empty name.
HostMissing,
/// A port field that is neither blank nor a port.
PortRange,
/// The same, for the port half of a proxy address.
ProxyPortRange,
/// The jump field names a profile no host list has.
JumpUnknown(String),
/// The jump field names the profile being edited.
JumpIsSelf,
}
impl SshFieldError {
fn message(&self) -> String {
match self {
Self::HostMissing => t(L10nKey::SettingsHostRequired).to_string(),
Self::PortRange => t(L10nKey::SettingsPortInvalid).to_string(),
Self::ProxyPortRange => t(L10nKey::SettingsProxyPortInvalid).to_string(),
Self::JumpUnknown(name) => {
t_fmt(L10nKey::SettingsJumpHostUnknown, &[("jump_name", name)])
}
Self::JumpIsSelf => t(L10nKey::SettingsJumpHostSelf).to_string(),
}
}
}
/// What the form has to fix before it can be saved, one slot per field so each
/// complaint can be printed under the control it is about.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct SshFormErrors {
host: Option<SshFieldError>,
port: Option<SshFieldError>,
jump: Option<SshFieldError>,
socks: Option<SshFieldError>,
http: Option<SshFieldError>,
}
impl SshFormErrors {
fn is_empty(&self) -> bool {
self.host.is_none()
&& self.port.is_none()
&& self.jump.is_none()
&& self.socks.is_none()
&& self.http.is_none()
}
}
/// The SSH profile form as plain text, lifted out of the `InputState` entities
/// it lives in. The rules that turn it into a profile are the part worth
/// testing, and a GPUI entity is not something a unit test can hand them, so
/// the window layer's job stops at reading the strings out.
#[derive(Debug, Clone, Default)]
pub(crate) struct SshFormDraft {
id: Uuid,
name: String,
group: Option<String>,
host: String,
port: String,
user: String,
jump: String,
proxy_command: String,
socks: String,
http: String,
auth: AuthMode,
identity_files: String,
agent_forward: bool,
credential_ref: Option<CredentialRef>,
forwards: Vec<ForwardRule>,
keepalive_interval: String,
keepalive_count: String,
connect_timeout: String,
warn_on_close: Option<bool>,
skip_banner: bool,
shell_integration: bool,
login_scripts: String,
x11: bool,
kex: String,
cipher: String,
mac: String,
hostkey: String,
compression: String,
verify_host_keys: Option<bool>,
}
/// The one place that decides what the form would save and what is wrong with
/// it. Both, always — never one or the other: the Escape prompt asks whether
/// the form differs from what is on disk, and a form that is merely invalid
/// still holds everything the user typed. Handing back only the errors would
/// make a brand-new invalid profile compare equal to the nothing on disk, and
/// Escape would throw the typing away without asking.
///
/// A missing `name` is deliberately not an error: the host list already falls
/// back to the host for a nameless profile, and requiring one would refuse
/// every host imported from `~/.ssh/config`.
fn validate_ssh_draft(draft: SshFormDraft, profiles: &[SshProfile]) -> (SshProfile, SshFormErrors) {
let mut errors = SshFormErrors::default();
let host = draft.host.trim().to_string();
if host.is_empty() {
errors.host = Some(SshFieldError::HostMissing);
}
let port_text = draft.port.trim();
let port = match port_text.is_empty() {
true => DEFAULT_SSH_PORT,
false => parse_port(port_text).unwrap_or_else(|| {
errors.port = Some(SshFieldError::PortRange);
DEFAULT_SSH_PORT
}),
};
// The field is a name but the profile stores an id, so a jump host already
// survives its target being renamed. What it never survived was a name
// nobody has: the lookup returned `None`, the profile saved as a direct
// connection, and reopening the form showed an empty field.
let jump_name = draft.jump.trim();
let jump_host = if jump_name.is_empty() {
None
} else {
let named = |p: &&SshProfile| p.name == jump_name;
// Duplicate names resolve to whichever profile comes first, as they
// always have. The one profile that can never be the answer is the one
// being edited, and typing its own name is worth saying out loud
// rather than quietly connecting direct.
match profiles.iter().filter(named).find(|p| p.id != draft.id) {
Some(p) => Some(p.id),
None => {
errors.jump = Some(match profiles.iter().any(|p| p.name == jump_name) {
true => SshFieldError::JumpIsSelf,
false => SshFieldError::JumpUnknown(jump_name.to_string()),
});
None
}
}
};
let proxy = |text: &str, default_port: u16, slot: &mut Option<SshFieldError>| {
match parse_host_port_checked(text, default_port) {
Ok(hp) => hp,
Err(e) => {
*slot = Some(e);
None
}
}
};
let socks_proxy = proxy(&draft.socks, DEFAULT_SOCKS_PORT, &mut errors.socks);
let http_proxy = proxy(&draft.http, DEFAULT_HTTP_PROXY_PORT, &mut errors.http);
let proxy_command = draft.proxy_command.trim();
let profile = SshProfile {
id: draft.id,
name: draft.name.trim().to_string(),
group: draft.group,
host,
port,
user: draft.user.trim().to_string(),
jump_host,
proxy_command: (!proxy_command.is_empty()).then(|| proxy_command.to_string()),
socks_proxy,
http_proxy,
auth: draft.auth,
identity_files: split_lines(&draft.identity_files),
agent_forward: draft.agent_forward,
credential_ref: draft.credential_ref,
forwards: draft.forwards,
keepalive_interval_s: draft.keepalive_interval.trim().parse().ok(),
keepalive_count_max: draft.keepalive_count.trim().parse().ok(),
connect_timeout_s: draft.connect_timeout.trim().parse().ok(),
warn_on_close: draft.warn_on_close,
skip_banner: draft.skip_banner,
shell_integration: draft.shell_integration,
login_scripts: split_lines(&draft.login_scripts),
x11: draft.x11,
algorithms: Algorithms {
kex: split_list(&draft.kex),
cipher: split_list(&draft.cipher),
mac: split_list(&draft.mac),
hostkey: split_list(&draft.hostkey),
compression: split_list(&draft.compression),
},
verify_host_keys: draft.verify_host_keys,
};
(profile, errors)
}
/// The inline complaint under a field: one line, in the danger colour, in the
/// column the control sits in. Built before the row rather than inside a
/// `when` closure so it borrows the app for the length of one call.
fn field_error(message: impl Into<String>, cx: &App) -> Div {
div()
.text_xs()
.text_color(cx.theme().danger)
.child(message.into())
}
fn forward_row_inputs(row: &ForwardRuleForm) -> [&Entity<InputState>; 5] {
[
&row.bind_host,
@@ -3049,59 +3288,62 @@ impl Tty7App {
cx.notify();
}
fn ssh_form_collect(&self, cx: &App) -> Option<SshProfile> {
/// Reads the form out of its entities and runs it past
/// [`validate_ssh_draft`]. The profile that comes back is what the form
/// would save; the errors are what stands in the way.
fn ssh_form_collect(&self, cx: &App) -> Option<(SshProfile, SshFormErrors)> {
let form = self.active_settings()?.ssh_form.as_ref()?;
let id = form.editing;
let val = |e: &Entity<InputState>| e.read(cx).value().trim().to_string();
// The multi-line and comma-separated fields do their own splitting, so
// they travel whole rather than trimmed.
let raw = |e: &Entity<InputState>| e.read(cx).value().to_string();
let jump_name = val(&form.jump);
let jump_host = if jump_name.is_empty() {
None
} else {
cx.global::<Config>()
.ssh_profiles
.iter()
.find(|p| p.name == jump_name && p.id != id)
.map(|p| p.id)
};
Some(SshProfile {
id,
let draft = SshFormDraft {
id: form.editing,
name: val(&form.name),
group: form.carry_group.clone(),
host: val(&form.host),
port: val(&form.port).parse().unwrap_or(22),
port: val(&form.port),
user: val(&form.user),
jump_host,
proxy_command: (!val(&form.proxy_command).is_empty()).then(|| val(&form.proxy_command)),
socks_proxy: parse_host_port(&val(&form.socks)),
http_proxy: parse_host_port(&val(&form.http)),
jump: val(&form.jump),
proxy_command: val(&form.proxy_command),
socks: val(&form.socks),
http: val(&form.http),
auth: form.auth,
identity_files: split_lines(&form.identity_files.read(cx).value()),
identity_files: raw(&form.identity_files),
agent_forward: form.agent_forward,
credential_ref: form.carry_credential_ref.clone(),
forwards: form.forwards.iter().filter_map(|r| r.collect(cx)).collect(),
keepalive_interval_s: val(&form.keepalive_interval).parse().ok(),
keepalive_count_max: val(&form.keepalive_count).parse().ok(),
connect_timeout_s: val(&form.connect_timeout).parse().ok(),
keepalive_interval: val(&form.keepalive_interval),
keepalive_count: val(&form.keepalive_count),
connect_timeout: val(&form.connect_timeout),
warn_on_close: form.warn_on_close,
skip_banner: form.skip_banner,
shell_integration: form.shell_integration,
login_scripts: split_lines(&form.login_scripts.read(cx).value()),
login_scripts: raw(&form.login_scripts),
x11: form.x11,
algorithms: Algorithms {
kex: split_list(&form.kex.read(cx).value()),
cipher: split_list(&form.cipher.read(cx).value()),
mac: split_list(&form.mac.read(cx).value()),
hostkey: split_list(&form.hostkey.read(cx).value()),
compression: split_list(&form.compression.read(cx).value()),
},
kex: raw(&form.kex),
cipher: raw(&form.cipher),
mac: raw(&form.mac),
hostkey: raw(&form.hostkey),
compression: raw(&form.compression),
verify_host_keys: form.verify_host_keys,
})
};
Some(validate_ssh_draft(
draft,
&cx.global::<Config>().ssh_profiles,
))
}
pub(crate) fn save_editing_profile(&mut self, cx: &mut Context<Self>) -> Option<Uuid> {
let profile = self.ssh_form_collect(cx)?;
let (profile, errors) = self.ssh_form_collect(cx)?;
// Save and Connect are both disabled while anything is wrong, but this
// is the door all of them go through, and what gets past it lands in
// the config file — where a host-less profile is a blank row nobody
// can identify or delete on sight.
if !errors.is_empty() {
return None;
}
let id = profile.id;
self.update_config(cx, |cfg| {
if let Some(slot) = cfg.ssh_profiles.iter_mut().find(|p| p.id == id) {
@@ -3120,7 +3362,9 @@ impl Tty7App {
/// Whether the SSH profile form on screen holds edits that were never
/// saved. Save is enabled off exactly this, so closing on it is the same
/// question the button already answers.
/// question the button already answers — and it compares what the form
/// would save even when the form cannot be saved yet, so a half-typed new
/// host is still something Escape has to ask about.
pub(crate) fn ssh_form_dirty(&self, cx: &App) -> bool {
let Some(form) = self.active_settings().and_then(|s| s.ssh_form.as_ref()) else {
return false;
@@ -3131,7 +3375,7 @@ impl Tty7App {
.iter()
.find(|p| p.id == form.editing)
.cloned();
self.ssh_form_collect(cx) != saved
self.ssh_form_collect(cx).map(|(profile, _)| profile) != saved
}
/// Closing from Escape or the X is the user leaving; every other caller
@@ -3310,7 +3554,8 @@ impl Tty7App {
.iter()
.find(|p| p.id == editing)
.cloned();
let collected = self.ssh_form_collect(cx);
let (collected, errors) = self.ssh_form_collect(cx).unzip();
let errors = errors.unwrap_or_default();
let dirty = collected != saved;
let address = collected
.as_ref()
@@ -3379,20 +3624,38 @@ impl Tty7App {
Button::new("ssh-form-save")
.label(t(L10nKey::Save))
.small()
.disabled(!dirty)
.disabled(!dirty || !errors.is_empty())
.on_click(cx.listener(|this, _, _w, cx| this.save_ssh_form(cx))),
)
.child(
// Connect saves first, so it answers to the same
// rules. Before this it answered to none at all, and
// an empty host reached the socket layer as a DNS
// error about a name nobody typed.
Button::new("ssh-form-connect")
.label(t(L10nKey::Connect))
.primary()
.small()
.disabled(!errors.is_empty())
.on_click(cx.listener(|this, _, window, cx| {
this.save_and_connect_profile(window, cx)
})),
),
);
// Every field notifies on change, so this form re-renders on each
// keystroke: telling a brand-new host that it needs a host is
// something it would say before the user had typed a character. Hold
// that one line back until the group it belongs to has something in
// it. A malformed value has nothing to wait for and says so at once.
let core_blank = form.core_is_blank(cx);
let host_error = errors
.host
.as_ref()
.filter(|_| !core_blank)
.map(|e| field_error(e.message(), cx));
let port_error = errors.port.as_ref().map(|e| field_error(e.message(), cx));
let core = v_flex()
.gap_3()
.child(
@@ -3411,21 +3674,28 @@ impl Tty7App {
self.settings_row(
t(L10nKey::SettingsHost),
t(L10nKey::SettingsHostDesc),
h_flex()
.gap_2()
v_flex()
.gap_1()
.max_w_full()
.child(
div()
.w(px(172.))
.min_w_0()
.child(Input::new(&form.host).small()),
)
.child(
div()
.w(px(80.))
.flex_shrink_0()
.child(Input::new(&form.port).small()),
h_flex()
.gap_2()
.max_w_full()
.child(
div()
.w(px(172.))
.min_w_0()
.child(Input::new(&form.host).small()),
)
.child(
div()
.w(px(80.))
.flex_shrink_0()
.child(Input::new(&form.port).small()),
),
)
.when_some(host_error, |col, line| col.child(line))
.when_some(port_error, |col, line| col.child(line))
.into_any_element(),
cx,
),
@@ -3478,9 +3748,9 @@ impl Tty7App {
.gap_4()
.child(header)
.child(core)
.child(self.render_ssh_profile_jump_section(form, cx))
.child(self.render_ssh_profile_jump_section(form, &errors, cx))
.child(self.render_ssh_profile_forwards_section(form, cx))
.child(self.render_ssh_profile_advanced_section(form, cx))
.child(self.render_ssh_profile_advanced_section(form, &errors, cx))
.into_any_element()
}
@@ -3526,6 +3796,7 @@ impl Tty7App {
fn render_ssh_profile_jump_section(
&self,
form: &SshProfileForm,
errors: &SshFormErrors,
cx: &mut Context<Self>,
) -> AnyElement {
let summary = {
@@ -3536,11 +3807,15 @@ impl Tty7App {
name
}
};
// A complaint nobody can see is a Save button that is greyed out for
// no reason the user can read, and the field keeps its text whether
// this section is folded or not — so an error holds it open.
let open = form.show_jump || errors.jump.is_some();
let mut section = v_flex().child(self.disclosure_header(
"ssh-sec-jump",
t(L10nKey::SettingsJumpHost),
&summary,
form.show_jump,
open,
cx,
|this, cx| {
if let Some(f) = this.ssh_form_mut() {
@@ -3549,15 +3824,18 @@ impl Tty7App {
}
},
));
if form.show_jump {
if open {
let error = errors.jump.as_ref().map(|e| field_error(e.message(), cx));
section = section.child(
self.settings_row(
t(L10nKey::SettingsJumpHost),
t(L10nKey::SettingsJumpHostDesc),
div()
v_flex()
.gap_1()
.w(px(260.))
.max_w_full()
.child(Input::new(&form.jump).small())
.when_some(error, |col, line| col.child(line))
.into_any_element(),
cx,
),
@@ -3635,14 +3913,21 @@ impl Tty7App {
cx: &mut Context<Self>,
) -> AnyElement {
let muted = cx.theme().muted_foreground;
let danger = cx.theme().danger;
let needs_target = row.kind != ForwardKind::Dynamic;
let kind_idx = match row.kind {
ForwardKind::Local => 0,
ForwardKind::Remote => 1,
ForwardKind::Dynamic => 2,
};
let incomplete = row.collect(cx).is_none() && !row.is_blank(cx);
let incomplete = (row.collect(cx).is_none() && !row.is_blank(cx)).then(|| {
field_error(
match needs_target {
true => t(L10nKey::SettingsFwdNeedsBoth),
false => t(L10nKey::SettingsFwdNeedsListen),
},
cx,
)
});
// Below `SPLIT_FORWARD_ROW_BELOW` the five controls stop fitting on one
// line. The kind switch, the description and the remove button keep the
@@ -3746,13 +4031,7 @@ impl Tty7App {
.gap_0p5()
.py_1()
.child(rule)
.when(incomplete, |col| {
col.child(div().text_xs().text_color(danger).child(if needs_target {
t(L10nKey::SettingsFwdNeedsBoth)
} else {
t(L10nKey::SettingsFwdNeedsListen)
}))
})
.when_some(incomplete, |col, line| col.child(line))
.into_any_element()
}
@@ -3788,13 +4067,18 @@ impl Tty7App {
fn render_ssh_profile_advanced_section(
&self,
form: &SshProfileForm,
errors: &SshFormErrors,
cx: &mut Context<Self>,
) -> AnyElement {
// This section opens folded, and a proxy address saved back when the
// form wrote port 0 is wrong the moment the profile is opened. Let the
// error unfold it, or Save is disabled over something out of sight.
let open = form.show_advanced || errors.socks.is_some() || errors.http.is_some();
let mut section = v_flex().child(self.disclosure_header(
"ssh-sec-adv",
t(L10nKey::SettingsAdvanced),
t(L10nKey::SettingsAdvancedSummary),
form.show_advanced,
open,
cx,
|this, cx| {
if let Some(f) = this.ssh_form_mut() {
@@ -3803,7 +4087,7 @@ impl Tty7App {
}
},
));
if !form.show_advanced {
if !open {
return section.into_any_element();
}
@@ -3823,6 +4107,28 @@ impl Tty7App {
cx,
)
};
// The two proxy addresses are the only advanced fields with a rule of
// their own, so they carry room for the complaint under the control.
let proxy_row = |this: &Self,
label: &str,
desc: &str,
input: &Entity<InputState>,
error: Option<&SshFieldError>,
cx: &mut Context<Self>| {
let line = error.map(|e| field_error(e.message(), cx));
this.settings_row(
label.to_string(),
desc.to_string(),
v_flex()
.gap_1()
.w(px(260.))
.max_w_full()
.child(Input::new(input).small())
.when_some(line, |col, line| col.child(line))
.into_any_element(),
cx,
)
};
let on_off = |b: bool| {
if b {
@@ -3877,18 +4183,20 @@ impl Tty7App {
&form.proxy_command,
cx,
))
.child(text_row(
.child(proxy_row(
self,
t(L10nKey::SettingsSocks5Proxy),
t(L10nKey::SettingsSocks5ProxyDesc),
&form.socks,
errors.socks.as_ref(),
cx,
))
.child(text_row(
.child(proxy_row(
self,
t(L10nKey::SettingsHttpProxy),
t(L10nKey::SettingsHttpProxyDesc),
&form.http,
errors.http.as_ref(),
cx,
))
.child(self.subgroup_header(L10nKey::SettingsGroupAlgorithms, cx))
@@ -5774,19 +6082,14 @@ impl Tty7App {
let http_proxy_value = http_proxy_input.read(cx).value().trim().to_string();
let http_proxy_invalid = !http_proxy_value.is_empty()
&& !tty7_core::daemon::install::proxy::is_valid_manual(&http_proxy_value);
let http_proxy_error =
http_proxy_invalid.then(|| field_error(t(L10nKey::SettingsAppHttpProxyInvalid), cx));
let http_proxy_control = v_flex()
.gap_1()
.w(px(260.))
.max_w_full()
.child(Input::new(&http_proxy_input).small())
.when(http_proxy_invalid, |this| {
this.child(
div()
.text_xs()
.text_color(danger)
.child(t(L10nKey::SettingsAppHttpProxyInvalid)),
)
})
.when_some(http_proxy_error, |this, line| this.child(line))
.into_any_element();
let logo = Arc::new(Image::from_bytes(
@@ -6572,11 +6875,182 @@ mod tests {
#[test]
fn parse_host_port_handles_blank_and_ports() {
assert!(parse_host_port(" ").is_none());
let hp = parse_host_port("example.com:2222").unwrap();
assert!(
parse_host_port_checked(" ", DEFAULT_SOCKS_PORT)
.unwrap()
.is_none()
);
let hp = parse_host_port_checked("example.com:2222", DEFAULT_SOCKS_PORT)
.unwrap()
.unwrap();
assert_eq!(hp.host, "example.com");
assert_eq!(hp.port, 2222);
assert_eq!(parse_host_port("host").unwrap().port, 0);
// Used to be port 0, which no proxy answers on.
assert_eq!(
parse_host_port_checked("host", DEFAULT_SOCKS_PORT)
.unwrap()
.unwrap()
.port,
DEFAULT_SOCKS_PORT
);
}
/// A form with the one field that is genuinely required, and nothing else.
fn draft_with_host() -> SshFormDraft {
SshFormDraft {
host: "example.com".to_string(),
..Default::default()
}
}
#[test]
fn a_profile_with_no_host_is_not_saveable() {
let (_, errors) = validate_ssh_draft(SshFormDraft::default(), &[]);
assert_eq!(errors.host, Some(SshFieldError::HostMissing));
assert!(!errors.is_empty());
}
#[test]
fn spaces_are_not_a_host() {
let draft = SshFormDraft {
host: " ".to_string(),
..Default::default()
};
let (profile, errors) = validate_ssh_draft(draft, &[]);
assert_eq!(errors.host, Some(SshFieldError::HostMissing));
assert_eq!(profile.host, "");
}
#[test]
fn a_name_is_not_required() {
// Every host imported from ~/.ssh/config arrives without one, and the
// list falls back to the address.
let (profile, errors) = validate_ssh_draft(draft_with_host(), &[]);
assert_eq!(profile.name, "");
assert!(errors.is_empty());
}
#[test]
fn a_blank_port_still_means_22() {
let (profile, errors) = validate_ssh_draft(draft_with_host(), &[]);
assert_eq!(profile.port, 22);
assert_eq!(errors.port, None);
}
#[test]
fn a_port_that_is_not_a_port_is_refused() {
// "0" parses as a u16 and used to be saved as written; the other two
// failed to parse and were silently rewritten to 22.
for text in ["0", "abc", "70000", "-1", "22 "] {
let draft = SshFormDraft {
port: text.to_string(),
..draft_with_host()
};
let (profile, errors) = validate_ssh_draft(draft, &[]);
match text {
"22 " => {
assert_eq!(errors.port, None, "{text:?} is a port with spare space");
assert_eq!(profile.port, 22);
}
_ => {
assert_eq!(errors.port, Some(SshFieldError::PortRange), "{text:?}");
assert!(!errors.is_empty());
}
}
}
}
#[test]
fn a_jump_host_that_exists_is_kept_by_id() {
let bastion = SshProfile::new("bastion");
let draft = SshFormDraft {
jump: "bastion".to_string(),
..draft_with_host()
};
let (profile, errors) = validate_ssh_draft(draft, &[bastion.clone()]);
assert_eq!(profile.jump_host, Some(bastion.id));
assert!(errors.is_empty());
}
#[test]
fn a_mistyped_jump_host_says_which_name_it_could_not_find() {
let draft = SshFormDraft {
jump: "bastian".to_string(),
..draft_with_host()
};
let (profile, errors) = validate_ssh_draft(draft, &[SshProfile::new("bastion")]);
assert_eq!(
errors.jump,
Some(SshFieldError::JumpUnknown("bastian".to_string()))
);
assert_eq!(
profile.jump_host, None,
"a typo never saves as a direct connection"
);
}
#[test]
fn a_host_cannot_jump_through_itself() {
let me = SshProfile::new("prod");
let draft = SshFormDraft {
id: me.id,
jump: "prod".to_string(),
..draft_with_host()
};
let (profile, errors) = validate_ssh_draft(draft, &[me]);
assert_eq!(errors.jump, Some(SshFieldError::JumpIsSelf));
assert_eq!(profile.jump_host, None);
}
#[test]
fn a_bare_proxy_host_takes_the_scheme_default_port() {
let draft = SshFormDraft {
socks: "socks.example.com".to_string(),
http: "http.example.com".to_string(),
..draft_with_host()
};
let (profile, errors) = validate_ssh_draft(draft, &[]);
assert_eq!(
profile.socks_proxy,
Some(HostPort::new("socks.example.com", 1080))
);
assert_eq!(
profile.http_proxy,
Some(HostPort::new("http.example.com", 8080))
);
assert!(errors.is_empty());
}
#[test]
fn a_proxy_address_with_a_colon_and_no_port_is_refused() {
for text in ["proxy.example.com:", "proxy.example.com:abc", "proxy:0"] {
let draft = SshFormDraft {
socks: text.to_string(),
..draft_with_host()
};
let (profile, errors) = validate_ssh_draft(draft, &[]);
assert_eq!(
errors.socks,
Some(SshFieldError::ProxyPortRange),
"{text:?}"
);
assert_eq!(profile.socks_proxy, None, "{text:?}");
}
}
#[test]
fn a_form_that_cannot_be_saved_still_reports_what_it_would_save() {
// The Escape prompt asks whether the form differs from the config, so
// an invalid form has to hand back a profile to compare — otherwise a
// half-typed new host looks identical to the nothing on disk and
// Escape throws it away without asking.
let draft = SshFormDraft {
name: "half typed".to_string(),
..Default::default()
};
let (profile, errors) = validate_ssh_draft(draft, &[]);
assert!(!errors.is_empty());
assert_eq!(profile.name, "half typed");
}
}
+24 -2
View File
@@ -265,8 +265,12 @@ fn map_proxy(profile: &SshProfile) -> SshProxy {
return SshProxy::Command(cmd.clone());
}
}
// Port 0 is not somewhere a proxy listens. The settings form used to write
// it whenever the address had no port or an unparseable one, so configs
// carrying it are already on disk; connecting direct is the honest reading
// of an address that names nowhere.
if let Some(HostPort { host, port }) = &profile.socks_proxy {
if !host.is_empty() {
if !host.is_empty() && *port != 0 {
return SshProxy::Socks {
host: host.clone(),
port: *port,
@@ -274,7 +278,7 @@ fn map_proxy(profile: &SshProfile) -> SshProxy {
}
}
if let Some(HostPort { host, port }) = &profile.http_proxy {
if !host.is_empty() {
if !host.is_empty() && *port != 0 {
return SshProxy::Http {
host: host.clone(),
port: *port,
@@ -551,4 +555,22 @@ mod tests {
SshProxy::Command(_)
));
}
#[test]
fn a_proxy_on_port_zero_is_no_proxy() {
// What the settings form wrote for `proxy.example.com` before it
// checked the port, and what is still sitting in configs saved then.
let store = InMemoryCredentialStore::new();
let mut p = profile("web", "h", "u");
p.socks_proxy = Some(HostPort::new("socks", 0));
assert!(matches!(
build_native_ssh_spec(&p, &[], &store, true).proxy,
SshProxy::None
));
p.http_proxy = Some(HostPort::new("http", 8080));
assert!(matches!(
build_native_ssh_spec(&p, &[], &store, true).proxy,
SshProxy::Http { .. }
));
}
}