From d99c76b43c45632f061095c4abc1dca60ab0ace2 Mon Sep 17 00:00:00 2001 From: Christian Visintin Date: Tue, 1 Sep 2026 12:26:05 +0200 Subject: [PATCH] feat(ssh): auto-fill ssh config parameters in auth form Resolve SSH host parameters in auth forms and CLI connections while preserving explicit user, bookmark, and parsed alias values. Continue forwarding SSH config files for HostName and other SSH options, and document the precedence in English and Chinese. Closes #441 --- docs/en-US/configuration/configuration.md | 12 ++ docs/en-US/getting-started/connecting.md | 18 ++- docs/zh-CN/configuration/configuration.md | 6 + docs/zh-CN/getting-started/connecting.md | 8 +- src/activity_manager.rs | 181 ++++++++++++++++++++-- src/cli/remote.rs | 40 ++++- src/filetransfer/remotefs_builder.rs | 29 ---- src/host.rs | 2 + src/ui/activities/auth.rs | 89 +++++++++++ src/ui/activities/auth/update.rs | 79 ++++++++-- src/ui/activities/auth/view/mounting.rs | 1 + src/ui/context.rs | 18 +++ src/utils/parser.rs | 37 ++++- src/utils/parser/remote.rs | 22 ++- src/utils/ssh.rs | 75 ++++++++- 15 files changed, 548 insertions(+), 69 deletions(-) diff --git a/docs/en-US/configuration/configuration.md b/docs/en-US/configuration/configuration.md index 3317f8a..8a052a9 100644 --- a/docs/en-US/configuration/configuration.md +++ b/docs/en-US/configuration/configuration.md @@ -48,3 +48,15 @@ The following parameters can be configured: attributes supported by termscp are listed at [the ssh2-config exposed attributes](https://github.com/veeso/ssh2-config#exposed-attributes). See also [SSH key storage](ssh-keys.md). + +## SSH configuration behavior + +In either SFTP or SCP authentication pane, after you change and leave the +`Host` field, termscp keeps the entered alias visible and fills the visible +`Port` and `Username` fields from the matching SSH configuration entry. If the +new Host does not match an entry, it resets Port to `22` and Username to empty. +`HostName` never replaces the alias in the form. + +Bookmarks retain their saved Port and User values; the SSH configuration does +not silently override them. The configured file still provides `HostName` and +other supported SSH options when termscp connects. diff --git a/docs/en-US/getting-started/connecting.md b/docs/en-US/getting-started/connecting.md index c042e32..9f07d14 100644 --- a/docs/en-US/getting-started/connecting.md +++ b/docs/en-US/getting-started/connecting.md @@ -18,6 +18,18 @@ When termscp starts without an address, it shows the authentication form. Fill in the protocol, address, port, username, and password, then connect. termscp will open the dual-pane explorer once the connection succeeds. +## SSH configuration precedence + +For SFTP and SCP connections, termscp resolves CLI Username and Port values in +this order: explicit `Username`/`Port`, SSH configuration `User`/`Port`, then +the current OS user and Port `22`. Username and Port are resolved independently, +so an explicit value for one does not prevent SSH configuration from supplying +the other. + +For example, `termscp myhost` uses the configured Port and User for `myhost`. +`termscp alice@myhost:22` uses `alice` and `22`, regardless of the SSH +configuration. + ## Address argument syntax The generic address argument has the following syntax: @@ -30,8 +42,10 @@ This syntax is convenient, and you will probably use it instead of the interactive form. Here are some examples. Connect using the default protocol (defined in your configuration) to -`192.168.1.31`. If the port is not provided, the default port for the selected -protocol is used. The username is the current user's name. +`192.168.1.31`. For SFTP and SCP, an omitted Port or Username is taken from a +matching SSH configuration entry. If no value is configured, it falls back to +the protocol default port or the current OS user. Other protocols use their +default port and the current OS user. ```sh termscp 192.168.1.31 diff --git a/docs/zh-CN/configuration/configuration.md b/docs/zh-CN/configuration/configuration.md index 2fdcaaa..ecfc020 100644 --- a/docs/zh-CN/configuration/configuration.md +++ b/docs/zh-CN/configuration/configuration.md @@ -25,3 +25,9 @@ termscp 要求以下路径可访问: - **启用通知**:如果设置为 `Yes`,则会显示桌面通知。参见 [通知](notifications.md)。 - **通知:最小传输大小**:如果传输大小大于或等于指定值,则显示传输通知。可接受的格式为 `{UNSIGNED} B/KB/MB/GB/TB/PB`。 - **SSH 配置路径**:连接到 SCP/SFTP 服务器时使用的 SSH 配置文件。如果留空,则不使用任何文件。你可以指定以 `~` 开头的路径来表示主目录(例如 `~/.ssh/config`)。termscp 支持的属性列于 [ssh2-config 公开的属性](https://github.com/veeso/ssh2-config#exposed-attributes)。另请参见 [SSH 密钥存储](ssh-keys.md)。 + +## SSH 配置行为 + +在 SFTP 或 SCP 的任一认证面板中,更改并离开 `Host` 字段后,termscp 会保持输入的别名可见,并使用匹配的 SSH 配置条目填充可见的 `Port` 和 `Username` 字段。如果新的 Host 不匹配任何条目,Port 会重置为 `22`,Username 会重置为空。`HostName` 永远不会替换表单中的别名。 + +书签会保留其保存的 Port 和 User 值;SSH 配置不会在幕后覆盖它们。连接时,已配置的文件仍会提供 `HostName` 和其他受支持的 SSH 选项。 diff --git a/docs/zh-CN/getting-started/connecting.md b/docs/zh-CN/getting-started/connecting.md index 012e5cb..20c664a 100644 --- a/docs/zh-CN/getting-started/connecting.md +++ b/docs/zh-CN/getting-started/connecting.md @@ -12,6 +12,12 @@ termscp 可以根据你传入的参数以三种不同的方式启动。 当 termscp 在不带地址的情况下启动时,会显示认证表单。填写协议、地址、端口、用户名和密码,然后进行连接。连接成功后,termscp 将打开双面板浏览器。 +## SSH 配置优先级 + +对于 SFTP 和 SCP 连接,termscp 会按以下顺序解析 CLI 的 Username 和 Port 值:显式指定的 `Username`/`Port`、SSH 配置中的 `User`/`Port`,然后是当前 OS 用户和 Port `22`。Username 和 Port 会独立解析,因此其中一个值被显式指定不会阻止 SSH 配置提供另一个值。 + +例如,`termscp myhost` 会使用为 `myhost` 配置的 Port 和 User。无论 SSH 配置为何,`termscp alice@myhost:22` 都会使用 `alice` 和 `22`。 + ## 地址参数语法 通用地址参数采用以下语法: @@ -22,7 +28,7 @@ termscp 可以根据你传入的参数以三种不同的方式启动。 这种语法很方便,你很可能会用它来代替交互式表单。下面是一些示例。 -使用默认协议(在你的配置中定义)连接到 `192.168.1.31`。如果未提供端口,则使用所选协议的默认端口。用户名为当前用户的名称。 +使用默认协议(在你的配置中定义)连接到 `192.168.1.31`。对于 SFTP 和 SCP,未提供的 Port 或 Username 会从匹配的 SSH 配置条目中获取。如果没有配置相应的值,则会回退到协议默认 Port 或当前 OS 用户。其他协议会使用其默认 Port 和当前 OS 用户。 ```sh termscp 192.168.1.31 diff --git a/src/activity_manager.rs b/src/activity_manager.rs index 5846654..63c399e 100644 --- a/src/activity_manager.rs +++ b/src/activity_manager.rs @@ -7,12 +7,13 @@ use std::path::PathBuf; use std::time::Duration; use remotefs_ssh::SshKeyStorage as SshKeyStorageTrait; +use ssh2_config::SshConfig; use crate::cli::{Remote, RemoteArgs}; use crate::filetransfer::{ FileTransferParams, FileTransferProtocol, HostBridgeParams, ProtocolParams, }; -use crate::host::HostError; +use crate::host::{HostError, HostErrorType}; use crate::system::bookmarks_client::BookmarksClient; use crate::system::config_client::ConfigClient; use crate::system::environment; @@ -23,7 +24,7 @@ use crate::ui::activities::filetransfer::FileTransferActivity; use crate::ui::activities::setup::SetupActivity; use crate::ui::activities::{Activity, ExitReason}; use crate::ui::context::Context; -use crate::utils::{fmt, tty}; +use crate::utils::{fmt, ssh as ssh_utils, tty}; /// NextActivity identifies the next identity to run once the current has ended pub enum NextActivity { @@ -67,7 +68,19 @@ impl ActivityManager { }; let error = error_config.or(error_bookmark); let theme_provider: ThemeProvider = Self::init_theme_provider(); - let ctx: Context = Context::new(bookmarks_client, config_client, theme_provider, error); + let ssh_config = config_client + .get_ssh_config() + .map(ssh_utils::parse_ssh2_config) + .transpose() + .map_err(|err| HostError::from(HostErrorType::InvalidSshConfig(err)))?; + + let ctx: Context = Context::new( + bookmarks_client, + config_client, + theme_provider, + ssh_config, + error, + ); Ok(ActivityManager { context: Some(ctx), ticks, @@ -83,13 +96,20 @@ impl ActivityManager { ¶ms.name, params.password.as_deref(), ), - Remote::Host(host_params) => self.set_host_params( - HostParams::HostBridge(HostBridgeParams::Remote( - host_params.file_transfer_params.protocol, - host_params.file_transfer_params.params, - )), - host_params.password.as_deref(), - ), + Remote::Host(host_params) => { + let params = apply_ssh_config_to_omitted_cli_parameters( + host_params.file_transfer_params, + host_params.port_explicit, + self.context_ref()?.ssh_config(), + ); + self.set_host_params( + HostParams::HostBridge(HostBridgeParams::Remote( + params.protocol, + params.params, + )), + host_params.password.as_deref(), + ) + } Remote::None => { // local dir is remote_args.local_dir if set, otherwise current dir let local_dir = remote_args @@ -112,7 +132,11 @@ impl ActivityManager { self.resolve_bookmark_name(Host::Remote, ¶ms.name, params.password.as_deref()) } Remote::Host(host_params) => self.set_host_params( - HostParams::Remote(host_params.file_transfer_params), + HostParams::Remote(apply_ssh_config_to_omitted_cli_parameters( + host_params.file_transfer_params, + host_params.port_explicit, + self.context_ref()?.ssh_config(), + )), host_params.password.as_deref(), ), Remote::None => Ok(()), @@ -530,3 +554,138 @@ impl ActivityManager { } } } + +/// Applies SSH configuration values only to CLI parameters omitted by the user. +fn apply_ssh_config_to_omitted_cli_parameters( + mut file_transfer_params: FileTransferParams, + port_explicit: bool, + ssh_config: Option<&SshConfig>, +) -> FileTransferParams { + if !matches!( + file_transfer_params.protocol, + FileTransferProtocol::Scp | FileTransferProtocol::Sftp, + ) { + return file_transfer_params; + } + + if let ProtocolParams::Generic(params) = &mut file_transfer_params.params { + let resolved = ssh_utils::resolve_ssh_host_params(ssh_config, params.address.as_str()); + if !port_explicit { + params.port = resolved.port; + } + if params.username.is_none() { + params.username = resolved.username; + } + } + + file_transfer_params +} + +#[cfg(test)] +mod test { + use pretty_assertions::assert_eq; + + use super::apply_ssh_config_to_omitted_cli_parameters; + use crate::filetransfer::params::GenericProtocolParams; + use crate::filetransfer::{FileTransferParams, FileTransferProtocol, ProtocolParams}; + use crate::utils::ssh::parse_ssh2_config; + use crate::utils::test_helpers; + + #[test] + fn should_apply_ssh_config_to_omitted_cli_port_and_username() { + let config = ssh_config(); + let params = ssh_params(22, None); + + let resolved = apply_ssh_config_to_omitted_cli_parameters(params, false, Some(&config)); + let resolved = resolved.params.generic_params().unwrap(); + + assert_eq!(resolved.port, 2222); + assert_eq!(resolved.username.as_deref(), Some("configured-user")); + } + + #[test] + fn should_preserve_explicit_cli_port_over_ssh_config() { + let config = ssh_config(); + let params = ssh_params(22, None); + + let resolved = apply_ssh_config_to_omitted_cli_parameters(params, true, Some(&config)); + let resolved = resolved.params.generic_params().unwrap(); + + assert_eq!(resolved.port, 22); + assert_eq!(resolved.username.as_deref(), Some("configured-user")); + } + + #[test] + fn should_preserve_explicit_cli_username_over_ssh_config() { + let config = ssh_config(); + let params = ssh_params(22, Some("cli-user")); + + let resolved = apply_ssh_config_to_omitted_cli_parameters(params, false, Some(&config)); + let resolved = resolved.params.generic_params().unwrap(); + + assert_eq!(resolved.port, 2222); + assert_eq!(resolved.username.as_deref(), Some("cli-user")); + } + + #[test] + fn should_apply_only_omitted_cli_ssh_parameters() { + let config = ssh_config(); + let params = ssh_params(2200, Some("cli-user")); + + let resolved = apply_ssh_config_to_omitted_cli_parameters(params, true, Some(&config)); + let resolved = resolved.params.generic_params().unwrap(); + + assert_eq!(resolved.port, 2200); + assert_eq!(resolved.username.as_deref(), Some("cli-user")); + } + + #[test] + fn should_default_omitted_cli_ssh_port_without_configuration() { + let params = ssh_params(22, None); + + let resolved = apply_ssh_config_to_omitted_cli_parameters(params, false, None); + + assert_eq!(resolved.params.generic_params().unwrap().port, 22); + } + + #[test] + fn should_leave_non_ssh_cli_parameters_unchanged() { + let params = FileTransferParams::new( + FileTransferProtocol::Ftp(false), + ProtocolParams::Generic( + GenericProtocolParams::default() + .address("configured-host") + .port(21) + .username(Some("ftp-user")), + ), + ); + + let resolved = + apply_ssh_config_to_omitted_cli_parameters(params, false, Some(&ssh_config())); + let resolved = resolved.params.generic_params().unwrap(); + + assert_eq!(resolved.port, 21); + assert_eq!(resolved.username.as_deref(), Some("ftp-user")); + } + + fn ssh_params(port: u16, username: Option<&str>) -> FileTransferParams { + FileTransferParams::new( + FileTransferProtocol::Scp, + ProtocolParams::Generic( + GenericProtocolParams::default() + .address("configured-host") + .port(port) + .username(username), + ), + ) + } + + fn ssh_config() -> ssh2_config::SshConfig { + let config_file = test_helpers::create_sample_file_with_content( + "Host configured-host\n Port 2222\n User configured-user\n", + ); + + parse_ssh2_config(&config_file.path().to_string_lossy()) + .expect("test SSH configuration should parse") + } +} diff --git a/src/cli/remote.rs b/src/cli/remote.rs index 3ccaa85..c3d2767 100644 --- a/src/cli/remote.rs +++ b/src/cli/remote.rs @@ -70,8 +70,13 @@ impl TryFrom<&Args> for RemoteArgs { } let remote = match addr_type { - AddrType::Address => Self::parse_remote_address(arg) - .map(|x| Remote::Host(HostParams::new(x, password)))?, + AddrType::Address => Self::parse_remote_address(arg).map(|parsed| { + Remote::Host(HostParams::new( + parsed.file_transfer_params, + parsed.port_explicit, + password, + )) + })?, AddrType::Bookmark => Remote::Bookmark(BookmarkParams::new(arg, password.as_ref())), }; @@ -99,8 +104,9 @@ impl TryFrom<&Args> for RemoteArgs { impl RemoteArgs { /// Parse remote address - fn parse_remote_address(remote: &str) -> Result { - utils::parser::parse_remote_opt(remote).map_err(|e| format!("Bad address option: {e}")) + fn parse_remote_address(remote: &str) -> Result { + utils::parser::parse_remote_opt_with_metadata(remote) + .map_err(|e| format!("Bad address option: {e}")) } } @@ -137,6 +143,8 @@ pub struct BookmarkParams { pub struct HostParams { /// file transfer parameters pub file_transfer_params: FileTransferParams, + /// Whether the address explicitly provided a port. + pub port_explicit: bool, /// host password specified in arguments pub password: Option, } @@ -151,9 +159,14 @@ impl BookmarkParams { } impl HostParams { - pub fn new>(params: FileTransferParams, password: Option) -> Self { + pub fn new>( + params: FileTransferParams, + port_explicit: bool, + password: Option, + ) -> Self { Self { file_transfer_params: params, + port_explicit, password: password.map(|x| x.as_ref().to_string()), } } @@ -179,6 +192,23 @@ mod test { assert_eq!(remote_args.local_dir, None); } + #[test] + fn should_preserve_explicit_port_from_positional_remote() { + for (remote, port_explicit) in [("scp://host", false), ("scp://host:22", true)] { + let args = Args { + positional: vec![remote.to_string()], + ..Default::default() + }; + + let remote_args = RemoteArgs::try_from(&args).unwrap(); + let Remote::Host(params) = remote_args.remote else { + panic!("expected positional remote to be a host"); + }; + + assert_eq!(params.port_explicit, port_explicit, "{remote}"); + } + } + #[test] fn test_should_make_remote_args_from_args_two_remotes() { let args = Args { diff --git a/src/filetransfer/remotefs_builder.rs b/src/filetransfer/remotefs_builder.rs index 23f1db1..b564add 100644 --- a/src/filetransfer/remotefs_builder.rs +++ b/src/filetransfer/remotefs_builder.rs @@ -29,7 +29,6 @@ use super::params::{KubeProtocolParams, WebDAVProtocolParams}; use super::{FileTransferProtocol, ProtocolParams}; use crate::system::config_client::ConfigClient; use crate::system::sshkey_storage::SshKeyStorage; -use crate::utils::ssh as ssh_utils; /// Remotefs builder pub struct RemoteFsBuilder; @@ -242,37 +241,9 @@ impl RemoteFsBuilder { .key_storage(Box::new(Self::make_ssh_storage(config_client))) .ssh_agent_identity(Some(SshAgentIdentity::All)) .port(params.port); - // get ssh config - let ssh_config = config_client - .get_ssh_config() - .and_then(|path| { - debug!("reading ssh config at {}", path); - ssh_utils::parse_ssh2_config(path).ok() - }) - .map(|config| config.query(¶ms.address)); - - //* override port - if let Some(port) = ssh_config.as_ref().and_then(|config| config.port) { - opts = opts.port(port); - } - - //* get username. Case 1 provided in params if let Some(username) = params.username { opts = opts.username(username); - } else if let Some(ssh_config) = &ssh_config { - debug!("no username was provided, checking whether a user is set for this host"); - if let Some(username) = &ssh_config.user { - debug!("found username from config: {username}"); - opts = opts.username(username); - } else { - //* case 3: use system username; can't be None - debug!("no username was provided, using current username"); - if let Ok(username) = whoami::username() { - opts = opts.username(username); - } - } } else if let Ok(username) = whoami::username() { - debug!("no username was provided, using current username"); opts = opts.username(username); } // For SSH protocols, only set password if explicitly provided and non-empty. diff --git a/src/host.rs b/src/host.rs index a74e5ff..8479f0f 100644 --- a/src/host.rs +++ b/src/host.rs @@ -36,6 +36,8 @@ pub enum HostErrorType { ExecutionFailed, #[error("Could not delete file")] DeleteFailed, + #[error("Invalid SSH configuration: {0}")] + InvalidSshConfig(String), #[cfg(win)] #[error("Not implemented")] NotImplemented, diff --git a/src/ui/activities/auth.rs b/src/ui/activities/auth.rs index e304193..0f04cfa 100644 --- a/src/ui/activities/auth.rs +++ b/src/ui/activities/auth.rs @@ -243,6 +243,18 @@ enum FormTab { const STORE_KEY_LATEST_VERSION: &str = "AUTH_LATEST_VERSION"; const STORE_KEY_RELEASE_NOTES: &str = "AUTH_RELEASE_NOTES"; +fn should_resolve_ssh_host_params( + protocol: FileTransferProtocol, + mounted_address: &str, + address: &str, + force: bool, +) -> bool { + matches!( + protocol, + FileTransferProtocol::Scp | FileTransferProtocol::Sftp + ) && (force || mounted_address != address) +} + /// AuthActivity is the data holder for the authentication activity pub struct AuthActivity { app: Application, @@ -256,7 +268,11 @@ pub struct AuthActivity { redraw: bool, /// Host bridge protocol host_bridge_protocol: HostBridgeProtocol, + /// Last Host address applied to the Host Bridge form. + last_host_bridge_address: String, last_form_tab: FormTab, + /// Last Host address applied to the Remote form. + last_remote_address: String, /// Remote file transfer protocol remote_protocol: FileTransferProtocol, context: Option, @@ -273,6 +289,8 @@ impl AuthActivity { bookmarks_list: Vec::new(), exit_reason: None, last_form_tab: FormTab::Remote, + last_host_bridge_address: String::new(), + last_remote_address: String::new(), recents_list: Vec::new(), redraw: true, host_bridge_protocol: HostBridgeProtocol::Localhost, @@ -317,6 +335,20 @@ impl AuthActivity { self.remote_protocol = protocol; } + fn last_mounted_address(&self, form_tab: FormTab) -> &str { + match form_tab { + FormTab::HostBridge => self.last_host_bridge_address.as_str(), + FormTab::Remote => self.last_remote_address.as_str(), + } + } + + fn set_last_mounted_address(&mut self, form_tab: FormTab, address: &str) { + match form_tab { + FormTab::HostBridge => self.last_host_bridge_address = address.to_string(), + FormTab::Remote => self.last_remote_address = address.to_string(), + } + } + /// Get current input mask to show fn host_bridge_input_mask(&self) -> InputMask { match self.host_bridge_protocol { @@ -444,4 +476,61 @@ mod tests { FileTransferProtocol::GoogleCloudStorage ); } + + #[test] + fn should_resolve_ssh_params_only_after_host_change_or_forced_ssh_transition() { + assert!(should_resolve_ssh_host_params( + FileTransferProtocol::Sftp, + "saved-host", + "edited-host", + false + )); + assert!(!should_resolve_ssh_host_params( + FileTransferProtocol::Sftp, + "saved-host", + "saved-host", + false + )); + assert!(should_resolve_ssh_host_params( + FileTransferProtocol::Scp, + "saved-host", + "saved-host", + true + )); + assert!(!should_resolve_ssh_host_params( + FileTransferProtocol::Ftp(false), + "saved-host", + "edited-host", + true + )); + } + + #[test] + fn should_track_host_bridge_and_remote_addresses_independently() { + let mut activity = AuthActivity::new(Duration::ZERO); + + activity.set_last_mounted_address(FormTab::HostBridge, "bookmark-host"); + activity.set_last_mounted_address(FormTab::Remote, "recent-host"); + + assert_eq!( + activity.last_mounted_address(FormTab::HostBridge), + "bookmark-host" + ); + assert_eq!( + activity.last_mounted_address(FormTab::Remote), + "recent-host" + ); + assert!(!should_resolve_ssh_host_params( + FileTransferProtocol::Sftp, + activity.last_mounted_address(FormTab::Remote), + "recent-host", + false + )); + assert!(should_resolve_ssh_host_params( + FileTransferProtocol::Sftp, + activity.last_mounted_address(FormTab::HostBridge), + "edited-host", + false + )); + } } diff --git a/src/ui/activities/auth/update.rs b/src/ui/activities/auth/update.rs index 3b8d813..c2ba759 100644 --- a/src/ui/activities/auth/update.rs +++ b/src/ui/activities/auth/update.rs @@ -6,8 +6,10 @@ use tuirealm::state::{State, StateValue}; use super::{ AuthActivity, AuthFormId, ExitReason, FormMsg, FormTab, HostBridgeProtocol, Id, InputMask, Msg, - UiAuthFormMsg, UiMsg, + UiAuthFormMsg, UiMsg, should_resolve_ssh_host_params, }; +use crate::filetransfer::FileTransferProtocol; +use crate::utils::ssh::resolve_ssh_host_params; impl AuthActivity { pub(super) fn update(&mut self, msg: Option) -> Option { @@ -24,6 +26,8 @@ impl AuthActivity { fn update_form(&mut self, msg: FormMsg) -> Option { match msg { FormMsg::Connect => { + self.resolve_ssh_host_params_if_needed(FormTab::Remote, false); + self.resolve_ssh_host_params_if_needed(FormTab::HostBridge, false); let remote_params = match self.collect_remote_host_params() { Ok(remote_params) => remote_params, Err(err) => { @@ -142,24 +146,36 @@ impl AuthActivity { self.host_bridge_protocol = protocol; // Update port let port: u16 = self.get_input_port(FormTab::HostBridge); - if let HostBridgeProtocol::Remote(remote_protocol) = protocol - && Self::is_port_standard(port) - { - self.mount_port( - FormTab::HostBridge, - Self::get_default_port_for_protocol(remote_protocol), - ); + if let HostBridgeProtocol::Remote(remote_protocol) = protocol { + match remote_protocol { + FileTransferProtocol::Scp | FileTransferProtocol::Sftp => { + self.resolve_ssh_host_params_if_needed(FormTab::HostBridge, true); + } + _ if Self::is_port_standard(port) => { + self.mount_port( + FormTab::HostBridge, + Self::get_default_port_for_protocol(remote_protocol), + ); + } + _ => {} + } } } FormMsg::RemoteProtocolChanged(protocol) => { self.remote_protocol = protocol; // Update port let port: u16 = self.get_input_port(FormTab::Remote); - if Self::is_port_standard(port) { - self.mount_port( - FormTab::Remote, - Self::get_default_port_for_protocol(protocol), - ); + match protocol { + FileTransferProtocol::Scp | FileTransferProtocol::Sftp => { + self.resolve_ssh_host_params_if_needed(FormTab::Remote, true); + } + _ if Self::is_port_standard(port) => { + self.mount_port( + FormTab::Remote, + Self::get_default_port_for_protocol(protocol), + ); + } + _ => {} } } FormMsg::Quit => { @@ -253,6 +269,7 @@ impl AuthActivity { fn update_host_bridge_ui(&mut self, msg: UiAuthFormMsg) { match msg { UiAuthFormMsg::AddressBlurDown => { + self.resolve_ssh_host_params_if_needed(FormTab::HostBridge, false); let id = if cfg!(windows) && self.host_bridge_input_mask() == InputMask::Smb { Id::HostBridge(AuthFormId::SmbShare) } else { @@ -261,9 +278,11 @@ impl AuthActivity { self.activate_component(id); } UiAuthFormMsg::AddressBlurUp => { + self.resolve_ssh_host_params_if_needed(FormTab::HostBridge, false); self.activate_component(Id::HostBridge(AuthFormId::Protocol)); } UiAuthFormMsg::ChangeFormTab => { + self.resolve_ssh_host_params_if_needed(FormTab::HostBridge, false); self.last_form_tab = FormTab::Remote; self.activate_component(Id::Remote(AuthFormId::Protocol)); } @@ -278,6 +297,7 @@ impl AuthActivity { self.activate_component(id); } UiAuthFormMsg::ParamsFormBlur => { + self.resolve_ssh_host_params_if_needed(FormTab::HostBridge, false); self.activate_component(Id::BookmarksList); } UiAuthFormMsg::PasswordBlurDown => { @@ -496,6 +516,7 @@ impl AuthActivity { fn update_remote_ui(&mut self, msg: UiAuthFormMsg) { match msg { UiAuthFormMsg::AddressBlurDown => { + self.resolve_ssh_host_params_if_needed(FormTab::Remote, false); let id = if cfg!(windows) && self.remote_input_mask() == InputMask::Smb { Id::Remote(AuthFormId::SmbShare) } else { @@ -504,9 +525,11 @@ impl AuthActivity { self.activate_component(id); } UiAuthFormMsg::AddressBlurUp => { + self.resolve_ssh_host_params_if_needed(FormTab::Remote, false); self.activate_component(Id::Remote(AuthFormId::Protocol)); } UiAuthFormMsg::ChangeFormTab => { + self.resolve_ssh_host_params_if_needed(FormTab::Remote, false); self.last_form_tab = FormTab::HostBridge; self.activate_component(Id::HostBridge(AuthFormId::Protocol)); } @@ -517,6 +540,7 @@ impl AuthActivity { self.activate_component(Id::Remote(AuthFormId::RemoteDirectory)); } UiAuthFormMsg::ParamsFormBlur => { + self.resolve_ssh_host_params_if_needed(FormTab::Remote, false); self.activate_component(Id::BookmarksList); } UiAuthFormMsg::PasswordBlurDown => { @@ -732,6 +756,35 @@ impl AuthActivity { } } + fn resolve_ssh_host_params_if_needed(&mut self, form_tab: FormTab, force: bool) { + let protocol = match form_tab { + FormTab::HostBridge => match self.host_bridge_protocol { + HostBridgeProtocol::Localhost => return, + HostBridgeProtocol::Remote(protocol) => protocol, + }, + FormTab::Remote => self.remote_protocol, + }; + let address = self.get_input_addr(form_tab); + let should_resolve = should_resolve_ssh_host_params( + protocol, + self.last_mounted_address(form_tab), + address.as_str(), + force, + ); + if !should_resolve { + return; + } + + let params = resolve_ssh_host_params(self.context().ssh_config(), address.as_str()); + self.mount_port(form_tab, 22); + self.mount_username(form_tab, ""); + self.mount_port(form_tab, params.port); + if let Some(username) = params.username { + self.mount_username(form_tab, username.as_str()); + } + self.set_last_mounted_address(form_tab, address.as_str()); + } + fn activate_component(&mut self, id: Id) { if let Err(err) = self.app.active(&id) { error!("Failed to activate component: {err}"); diff --git a/src/ui/activities/auth/view/mounting.rs b/src/ui/activities/auth/view/mounting.rs index 4405136..2a888cb 100644 --- a/src/ui/activities/auth/view/mounting.rs +++ b/src/ui/activities/auth/view/mounting.rs @@ -320,6 +320,7 @@ impl AuthActivity { form_tab: FormTab, address: &str, ) { + self.set_last_mounted_address(form_tab, address); let addr_color = self.theme().auth_address; let id = Self::form_tab_id(form_tab, AuthFormId::Address); if let Err(err) = self.app.remount( diff --git a/src/ui/context.rs b/src/ui/context.rs index 943b521..3f4854c 100644 --- a/src/ui/context.rs +++ b/src/ui/context.rs @@ -2,6 +2,7 @@ //! //! `Context` is the module which provides all the functionalities related to the UI data holder, called Context +use ssh2_config::SshConfig; // Locals use tuirealm::terminal::{CrosstermTerminalAdapter, TerminalAdapter}; @@ -13,13 +14,23 @@ use crate::system::theme_provider::ThemeProvider; /// Context holds data structures shared by the activities pub struct Context { + /// Parameters used to build the host bridge. host_bridge_params: Option, + /// Parameters used to build the remote file transfer client. remote_params: Option, + /// Client for persistent bookmarks, when initialization succeeded. bookmarks_client: Option, + /// Client for persisted application configuration. config_client: ConfigClient, + /// SSH configuration parsed once during application startup. + ssh_config: Option, + /// Shared state managed by UI activities. pub(crate) store: Store, + /// Terminal adapter used to render the user interface. pub(crate) terminal: CrosstermTerminalAdapter, + /// Provider for the active user interface theme. theme_provider: ThemeProvider, + /// Error pending display to the user. error: Option, } @@ -29,6 +40,7 @@ impl Context { bookmarks_client: Option, config_client: ConfigClient, theme_provider: ThemeProvider, + ssh_config: Option, error: Option, ) -> Context { let mut terminal = CrosstermTerminalAdapter::new().expect("Could not initialize terminal"); @@ -45,6 +57,7 @@ impl Context { config_client, host_bridge_params: None, remote_params: None, + ssh_config, store: Store::init(), terminal, theme_provider, @@ -78,6 +91,11 @@ impl Context { &mut self.config_client } + /// Returns the SSH configuration parsed during application startup. + pub fn ssh_config(&self) -> Option<&SshConfig> { + self.ssh_config.as_ref() + } + pub(crate) fn store(&self) -> &Store { &self.store } diff --git a/src/utils/parser.rs b/src/utils/parser.rs index f9d0fc1..10089a1 100644 --- a/src/utils/parser.rs +++ b/src/utils/parser.rs @@ -64,6 +64,15 @@ static SEMVER_REGEX: Lazy = lazy_regex!(r"v?((0|[1-9]\d*)\.(0|[1-9]\d*)\. */ static BYTESIZE_REGEX: Lazy = lazy_regex!(r"(:?([0-9])+)( )*(:?[KMGTP])?B$"); +/// Parsed remote parameters together with CLI syntax metadata. +#[derive(Debug)] +pub(crate) struct ParsedRemote { + /// Parsed file transfer parameters. + pub(crate) file_transfer_params: FileTransferParams, + /// Whether the remote address explicitly provided a port. + pub(crate) port_explicit: bool, +} + /// Parse remote option string. Returns in case of success a RemoteOptions struct /// For ssh if username is not provided, current user will be used. /// In case of error, message is returned @@ -99,8 +108,20 @@ static BYTESIZE_REGEX: Lazy = lazy_regex!(r"(:?([0-9])+)( )*(:?[KMGTP])?B /// /// `\\
\[\path]` /// +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "The public parser API is retained while CLI callers need port metadata." + ) +)] pub fn parse_remote_opt(s: &str) -> Result { - remote::parse_remote_opt(s) + parse_remote_opt_with_metadata(s).map(|parsed| parsed.file_transfer_params) +} + +/// Parse a remote option while retaining metadata needed by CLI precedence rules. +pub(crate) fn parse_remote_opt_with_metadata(s: &str) -> Result { + remote::parse_remote_opt_with_metadata(s) } /// Parse semver string @@ -352,6 +373,20 @@ mod tests { assert!(result.remote_path.is_none()); } + #[test] + fn parsed_remote_should_track_whether_the_port_was_explicit() { + for (remote, port_explicit) in [ + ("scp://host", false), + ("scp://host:/path", false), + ("scp://host:22", true), + ("scp://host:2222", true), + ] { + let parsed = remote::parse_remote_opt_with_metadata(remote).unwrap(); + + assert_eq!(parsed.port_explicit, port_explicit, "{remote}"); + } + } + #[test] fn test_should_parse_webdav_opt() { let result = diff --git a/src/utils/parser/remote.rs b/src/utils/parser/remote.rs index f0eeed0..4e3a31a 100644 --- a/src/utils/parser/remote.rs +++ b/src/utils/parser/remote.rs @@ -25,12 +25,13 @@ use crate::filetransfer::{FileTransferParams, FileTransferProtocol}; use crate::system::config_client::ConfigClient; #[cfg(not(test))] use crate::system::environment; +use crate::utils::parser::ParsedRemote; -pub(super) fn parse_remote_opt(s: &str) -> Result { +pub(super) fn parse_remote_opt_with_metadata(s: &str) -> Result { let default_protocol = default_protocol(); let (protocol, remote) = parse_remote_opt_protocol(s, default_protocol)?; - match protocol { + let file_transfer_params = match protocol { FileTransferProtocol::AwsS3 => parse_s3_remote_opt(remote.as_str()), FileTransferProtocol::GoogleCloudStorage => parse_gcs_remote_opt(remote.as_str()), FileTransferProtocol::Kube => parse_kube_remote_opt(remote.as_str()), @@ -45,8 +46,13 @@ pub(super) fn parse_remote_opt(s: &str) -> Result { parse_webdav_remote_opt(remote.as_str(), prefix) } - protocol => parse_generic_remote_opt(remote.as_str(), protocol), - } + protocol => return parse_generic_remote_opt(remote.as_str(), protocol), + }?; + + Ok(ParsedRemote { + file_transfer_params, + port_explicit: false, + }) } #[cfg(not(test))] @@ -71,13 +77,14 @@ fn default_protocol() -> FileTransferProtocol { fn parse_generic_remote_opt( s: &str, protocol: FileTransferProtocol, -) -> Result { +) -> Result { let groups = REMOTE_GENERIC_OPT_REGEX .captures(s) .ok_or_else(|| String::from("Bad remote host syntax!"))?; let username = optional_capture(&groups, 1); let address = required_capture(&groups, 2, "address")?; + let port_explicit = groups.get(3).is_some(); let port = parse_port(groups.get(3), default_port_for_protocol(protocol))?; let remote_path = groups.get(4).map(|group| PathBuf::from(group.as_str())); let params = ProtocolParams::Generic( @@ -87,7 +94,10 @@ fn parse_generic_remote_opt( .username(username), ); - Ok(FileTransferParams::new(protocol, params).remote_path(remote_path)) + Ok(ParsedRemote { + file_transfer_params: FileTransferParams::new(protocol, params).remote_path(remote_path), + port_explicit, + }) } fn parse_webdav_remote_opt(s: &str, prefix: &str) -> Result { diff --git a/src/utils/ssh.rs b/src/utils/ssh.rs index 775aaa4..b21e552 100644 --- a/src/utils/ssh.rs +++ b/src/utils/ssh.rs @@ -5,6 +5,18 @@ use ssh2_config::{ParseRule, SshConfig}; +/// The standard port used when an SSH configuration does not define one. +const DEFAULT_SSH_PORT: u16 = 22; + +/// Connection parameters resolved from an SSH host configuration. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct SshHostParams { + /// Resolved SSH port. + pub(crate) port: u16, + /// Resolved SSH username, when configured. + pub(crate) username: Option, +} + /// Parses an OpenSSH-style config file into an `ssh2_config::SshConfig`. pub fn parse_ssh2_config(path: &str) -> Result { use std::fs::File; @@ -18,10 +30,23 @@ pub fn parse_ssh2_config(path: &str) -> Result { .map_err(|e| format!("Failed to parse ssh2 config: {e}")) } +/// Resolves SSH connection parameters for a host from the startup-parsed configuration. +pub(crate) fn resolve_ssh_host_params(config: Option<&SshConfig>, host: &str) -> SshHostParams { + let params = config.map(|config| config.query(host)); + + SshHostParams { + port: params + .as_ref() + .and_then(|params| params.port) + .unwrap_or(DEFAULT_SSH_PORT), + username: params.and_then(|params| params.user), + } +} + #[cfg(test)] mod test { - use crate::utils::ssh::parse_ssh2_config; + use super::{SshHostParams, parse_ssh2_config, resolve_ssh_host_params}; use crate::utils::test_helpers; #[test] @@ -53,4 +78,52 @@ Host test .is_ok() ); } + + #[test] + fn ssh_host_params_should_resolve_exact_and_wildcard_hosts() { + let ssh_config_file = test_helpers::create_sample_file_with_content( + r#" +Host exact-host + Port 2222 + User exact-user + +Host *.example.com + Port 2200 + User wildcard-user +"#, + ); + let config = parse_ssh2_config(&ssh_config_file.path().to_string_lossy()) + .expect("test SSH configuration should parse"); + + assert_eq!( + resolve_ssh_host_params(Some(&config), "exact-host"), + SshHostParams { + port: 2222, + username: Some("exact-user".to_string()), + } + ); + assert_eq!( + resolve_ssh_host_params(Some(&config), "server.example.com"), + SshHostParams { + port: 2200, + username: Some("wildcard-user".to_string()), + } + ); + } + + #[test] + fn ssh_host_params_should_default_when_configuration_has_no_values() { + let ssh_config_file = + test_helpers::create_sample_file_with_content("Host unconfigured-host\n"); + let config = parse_ssh2_config(&ssh_config_file.path().to_string_lossy()) + .expect("test SSH configuration should parse"); + + assert_eq!( + resolve_ssh_host_params(Some(&config), "unconfigured-host"), + SshHostParams { + port: 22, + username: None, + } + ); + } }