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, + } + ); + } }