Compare commits

...
Author SHA1 Message Date
Christian Visintin 99b76f2fd9 ci: disable unused GnuTLS compression backends
Linux musl probe / build-x86_64-unknown-linux-musl (push) Has been cancelled
Linux musl probe / build-aarch64-unknown-linux-musl (push) Has been cancelled
2026-09-03 09:01:12 +02:00
Christian Visintin a57f020e4a ci: restore workspace before Cargo patch
Linux musl probe / build-x86_64-unknown-linux-musl (push) Has been cancelled
Linux musl probe / build-aarch64-unknown-linux-musl (push) Has been cancelled
2026-09-02 23:34:39 +02:00
Christian Visintin da23372d4b ci: build musl releases with static Samba dependencies 2026-09-02 23:30:07 +02:00
Christian Visintin 5304bc3224 ci: use compatible static gettext package 2026-09-02 23:22:12 +02:00
Christian Visintin 6de1cdb39c ci: add static gettext dependency 2026-09-02 23:22:12 +02:00
Christian Visintin 57a710eff5 ci: align musl dependencies with pavao 2026-09-02 23:22:12 +02:00
Christian Visintin 6e6d077532 ci: install samba perl parser dependency 2026-09-02 23:22:12 +02:00
Christian Visintin 63f7f0d084 ci: fix temporary samba patch rewrite 2026-09-02 23:22:12 +02:00
Christian Visintin 9d2c3746d0 ci: probe musl samba configure workaround 2026-09-02 23:22:11 +02:00
Christian Visintin 0b787fee34 ci: add static musl crypto dependencies 2026-09-02 23:22:11 +02:00
Christian Visintin 48d3701678 ci: probe static musl release builds 2026-09-02 23:22:11 +02:00
Christian Visintin 622a64ac58 build: bump remotefs-smb 0.5.0 2026-09-02 22:27:56 +02:00
Christian Visintin 751f68f6d4 feat(smb): add SMB dialect selection and persistence (#445)
Deploy docs to GitHub Pages / deploy (push) Has been cancelled
Install.sh / build (ubuntu-latest) (push) Has been cancelled
CI / toolchain (push) Has been cancelled
CI / fmt (push) Has been cancelled
CI / install-scripts (push) Has been cancelled
Install.sh / build (macos-latest) (push) Has been cancelled
Site / build-site (push) Has been cancelled
CI / crates-macos-latest (push) Has been cancelled
CI / crates-ubuntu-latest (push) Has been cancelled
CI / crates-windows-latest (push) Has been cancelled
CI / doc (push) Has been cancelled
CI / deny (push) Has been cancelled
Support Auto, SMB1, SMB2, and SMB3 selection on Unix, bound negotiation to the selected dialect family, preserve legacy bookmarks as Auto, and document the new option. Windows keeps operating-system-managed negotiation.

Closes #439
2026-09-02 14:14:23 +02:00
Christian Visintin e966a83220 chore(just): add run_signed script
Deploy docs to GitHub Pages / deploy (push) Has been cancelled
CI / toolchain (push) Has been cancelled
CI / fmt (push) Has been cancelled
CI / install-scripts (push) Has been cancelled
Install.sh / build (macos-latest) (push) Has been cancelled
Install.sh / build (ubuntu-latest) (push) Has been cancelled
Site / build-site (push) Has been cancelled
CI / crates-macos-latest (push) Has been cancelled
CI / crates-ubuntu-latest (push) Has been cancelled
CI / crates-windows-latest (push) Has been cancelled
CI / doc (push) Has been cancelled
CI / deny (push) Has been cancelled
2026-09-01 12:52:10 +02:00
Christian Visintin d99c76b43c 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
2026-09-01 12:52:10 +02:00
Christian Visintin 7434c51063 feat: add support for all ssh2 config parameters.
Achieved by bumping `remotefs-ssh` to `0.9`.

Added support for these parameters:

- Compression
- Host key certificates
- CA signature algorithms
- keys to agents
- ProxyJump
- Server alive intervals
- Agent forwarding
- Remote forwarding
- Bind address
- Bind interface
- Connection attempts
- TCP Keepalive
- Accepted public key algos
- Certificate files

Refs #441
2026-09-01 12:52:10 +02:00
Christian Visintin 9fbd617f88 fix: print actual reason for failed host params collecting
previously, we didn't show any reason for failed collecting of host params in the auth form, but just a generic message

refs #441
2026-09-01 12:52:10 +02:00
35 changed files with 1707 additions and 798 deletions
+154
View File
@@ -0,0 +1,154 @@
name: Linux musl probe
on:
push:
branches:
- "test/440-musl-release"
paths:
- ".github/workflows/musl.yml"
- "Cargo.lock"
- "Cargo.toml"
- "src/**"
workflow_dispatch:
permissions:
contents: read
jobs:
build:
name: build-${{ matrix.target }}
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-musl
runner: ubuntu-24.04
- target: aarch64-unknown-linux-musl
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Build and audit static binary
env:
TARGET: ${{ matrix.target }}
run: |
docker run --rm \
--env TARGET \
--volume "$GITHUB_WORKSPACE:/work" \
--workdir /work \
rust:1.98-alpine3.22 \
sh -euxc '
apk add --no-cache \
bison \
binutils \
build-base \
file \
flex \
git \
gnutls-dev \
libgit2-dev \
libgit2-static \
libunistring-dev \
libunistring-static \
linux-headers \
openssl-dev \
openssl-libs-static \
perl \
perl-parse-yapp \
pkgconf \
python3 \
wget \
xz \
zlib-dev \
zlib-static
rustup target add "$TARGET"
cargo fetch --locked
NATIVE_CFLAGS="-O2 -fPIC"
if [ "$TARGET" = "aarch64-unknown-linux-musl" ]; then
NATIVE_CFLAGS="$NATIVE_CFLAGS -mno-outline-atomics"
fi
export CFLAGS="$NATIVE_CFLAGS"
mkdir -p /tmp/native
wget -q https://ftp.gnu.org/gnu/nettle/nettle-3.10.1.tar.gz \
-O /tmp/native/nettle.tar.gz
tar -xzf /tmp/native/nettle.tar.gz -C /tmp/native
cd /tmp/native/nettle-3.10.1
./configure \
--prefix=/tmp/native/nettle \
--disable-shared \
--enable-static \
--disable-documentation \
--enable-mini-gmp
make -j$(getconf _NPROCESSORS_ONLN)
make install
wget -q https://www.gnupg.org/ftp/gcrypt/gnutls/v3.8/gnutls-3.8.13.tar.xz \
-O /tmp/native/gnutls.tar.xz
tar -xf /tmp/native/gnutls.tar.xz -C /tmp/native
cd /tmp/native/gnutls-3.8.13
PKG_CONFIG_PATH=/tmp/native/nettle/lib/pkgconfig \
./configure \
--prefix=/tmp/native/gnutls \
--disable-shared \
--enable-static \
--disable-doc \
--disable-tests \
--disable-nls \
--disable-hardware-acceleration \
--with-nettle-mini \
--with-included-libtasn1 \
--with-included-unistring \
--without-idn \
--without-p11-kit \
--without-brotli \
--without-zstd \
--without-zlib
make -j$(getconf _NPROCESSORS_ONLN)
make install
mkdir -p /tmp/native/pkgconfig
sed \
-e "s#^Libs:.*#Libs: -L/tmp/native/gnutls/lib -lgnutls -latomic -L/tmp/native/nettle/lib -lhogweed -lnettle#" \
-e "/^Requires.private:/d" \
-e "s#^Cflags:.*#Cflags: -I/tmp/native/gnutls/include -I/tmp/native/nettle/include#" \
/tmp/native/gnutls/lib/pkgconfig/gnutls.pc \
> /tmp/native/pkgconfig/gnutls.pc
cd /work
PAVAO_SRC=$(find "${CARGO_HOME:-/usr/local/cargo}/registry/src" \
-type d -name "pavao-src-4.24.6" -print -quit)
test -n "$PAVAO_SRC"
cp -R "$PAVAO_SRC" /tmp/pavao-src
perl -0pi -e "s#( \\\"lib/replace/replace\\.c\\\",\\n)#\$1 \\\"lib/replace/closefrom.c\\\",\\n \\\"lib/replace/strptime.c\\\",\\n#" \
/tmp/pavao-src/src/lib.rs
cat >> Cargo.toml <<EOF
[patch.crates-io]
pavao-src = { path = "/tmp/pavao-src" }
EOF
cargo update -p pavao-src@4.24.6
export PKG_CONFIG_ALL_STATIC=1
export PKG_CONFIG_PATH=/tmp/native/pkgconfig:/tmp/native/nettle/lib/pkgconfig:/usr/lib/pkgconfig
export RUSTFLAGS="-C target-feature=+crt-static -C link-arg=-static"
cargo build --locked --release --target "$TARGET" \
--features smb-vendored
file "target/$TARGET/release/termscp"
readelf -l "target/$TARGET/release/termscp" | \
tee /tmp/program-headers.txt
readelf -d "target/$TARGET/release/termscp" | \
tee /tmp/dynamic-section.txt
! grep -q INTERP /tmp/program-headers.txt
! grep -q NEEDED /tmp/dynamic-section.txt
'
- name: Upload verified binary
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.target }}
path: target/${{ matrix.target }}/release/termscp
if-no-files-found: error
retention-days: 7
Generated
+179 -617
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -62,8 +62,8 @@ remotefs = "0.3"
remotefs-aws-s3 = "0.4"
remotefs-gcs = "0.1"
remotefs-kube = "0.4"
remotefs-smb = { version = "0.3", optional = true }
remotefs-ssh = { version = "0.8", default-features = false, features = ["russh"] }
remotefs-smb = { version = "0.5", default-features = false, optional = true, features = ["find", "pavao"] }
remotefs-ssh = { version = "0.9", default-features = false, features = ["russh"] }
remotefs-webdav = "0.2"
rpassword = "7"
self_update = { version = "0.42", default-features = false, features = ["archive-tar", "archive-zip", "compression-flate2", "compression-zip-deflate", "rustls"] }
@@ -72,7 +72,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
shellexpand = "3"
simplelog = "0.12"
ssh2-config = "0.7"
ssh2-config = "0.8"
tempfile = "3"
thiserror = "2"
tokio = { version = "1", features = ["rt"] }
+1
View File
@@ -2,6 +2,7 @@ import "./just/build.just"
import "./just/changelog.just"
import "./just/code_check.just"
import "./just/publish.just"
import "./just/run.just"
import "./just/site.just"
import "./just/test.just"
-3
View File
@@ -33,9 +33,6 @@ ignore = [
{ id = "RUSTSEC-2026-0195", reason = "owner: termscp maintainers; review by 2026-12-31; upgrade remotefs-webdav/self_update to quick-xml >=0.41" },
# The SSH stack requires RSA, for which RustSec reports no safe upgrade.
{ id = "RUSTSEC-2023-0071", reason = "owner: termscp maintainers; review by 2026-12-31; replace RSA dependency when upstream fix exists" },
# russh-keys retains an older russh-cryptovec line and cannot select the
# fixed 0.60.3 release without an upstream dependency update.
{ id = "RUSTSEC-2026-0153", reason = "owner: termscp maintainers; review by 2026-12-31; upgrade remotefs-ssh/russh-keys to russh-cryptovec >=0.60.3" },
# rustls-pemfile is retained by the WebDAV and Kubernetes clients. Both
# versions are unmaintained but have no reported vulnerability.
{ id = "RUSTSEC-2025-0134", reason = "owner: termscp maintainers; review by 2026-12-31; upgrade WebDAV/Kubernetes clients away from rustls-pemfile" },
+12
View File
@@ -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.
+16 -2
View File
@@ -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
@@ -169,8 +169,28 @@ Authentication-form fields:
- Password
- Port (other systems only; default `445`)
- Workgroup (other systems only)
- SMB version (other systems only; default `Auto`)
On Windows the port and workgroup fields are not used.
On Windows the port, workgroup and SMB version fields are not used: the
operating system manages SMB protocol negotiation.
The SMB version field bounds the dialects offered during negotiation:
| Selection | Dialects negotiated |
| --------- | ----------------------- |
| Auto | SMB 2.0.2 through 3.1.1 |
| SMB1 | NT1 (CIFS) only |
| SMB2 | SMB 2.0.2 through 2.1 |
| SMB3 | SMB 3.0 through 3.1.1 |
`Auto` never negotiates SMB1. SMB1 is deprecated and insecure: select it only
for isolated legacy devices that support nothing newer. A warning is shown in
the form while SMB1 is selected.
Bookmarks store the selection under the `dialect` key of the SMB table
(`auto`, `smb1`, `smb2` or `smb3`). Bookmarks saved before this option existed
have no `dialect` key and behave as `Auto`. The address syntax below does not
carry a version; connections started from the command line use `Auto`.
Windows address syntax:
@@ -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 选项。
+7 -1
View File
@@ -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
@@ -151,8 +151,22 @@ CLI 连接使用 ADC 和默认端点。如果需要自定义端点或服务账
- 密码
- 端口(仅其他系统;默认 `445`)
- 工作组(仅其他系统)
- SMB 版本(仅其他系统;默认 `Auto`)
在 Windows 上,端口和工作组字段不会被使用。
在 Windows 上,端口、工作组和 SMB 版本字段不会被使用:SMB 协议协商由操作系统管理。
SMB 版本字段限定协商时可用的方言:
| 选项 | 协商的方言 |
| ---- | ------------------ |
| Auto | SMB 2.0.2 至 3.1.1 |
| SMB1 | 仅 NT1(CIFS) |
| SMB2 | SMB 2.0.2 至 2.1 |
| SMB3 | SMB 3.0 至 3.1.1 |
`Auto` 永远不会协商 SMB1。SMB1 已弃用且不安全:仅在无法支持更新协议的隔离旧设备上选择它。选择 SMB1 时,表单中会显示警告。
书签使用 SMB 表中的 `dialect` 键保存所选项(`auto`、`smb1`、`smb2` 或 `smb3`)。在此选项出现之前保存的书签没有 `dialect` 键,其行为等同于 `Auto`。下方的地址语法不包含版本;从命令行发起的连接使用 `Auto`。
Windows 地址语法:
+13
View File
@@ -0,0 +1,13 @@
[group('run')]
run *args:
@set -m; cargo run --bin termscp -- {{ args }}
# Build, sign, and run the macOS CLI binary with a stable development identity
# Job control gives smista its own foreground process group, so Ctrl-C reaches it
# without also interrupting just.
[group('run')]
run_signed *args:
@test "$(uname -s)" = "Darwin" || (echo "run_signed is only supported on macOS." >&2; exit 1)
cargo build --bin termscp
codesign --force --sign "${TERMSCP_CODESIGN_IDENTITY:-Apple Development}" target/debug/termscp
@set -m; target/debug/termscp {{ args }}
+170 -11
View File
@@ -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 {
&params.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, &params.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")
}
}
+35 -5
View File
@@ -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<FileTransferParams, String> {
utils::parser::parse_remote_opt(remote).map_err(|e| format!("Bad address option: {e}"))
fn parse_remote_address(remote: &str) -> Result<utils::parser::ParsedRemote, String> {
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<String>,
}
@@ -151,9 +159,14 @@ impl BookmarkParams {
}
impl HostParams {
pub fn new<S: AsRef<str>>(params: FileTransferParams, password: Option<S>) -> Self {
pub fn new<S: AsRef<str>>(
params: FileTransferParams,
port_explicit: bool,
password: Option<S>,
) -> 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 {
+45 -15
View File
@@ -191,25 +191,25 @@ impl From<Bookmark> for FileTransferParams {
}
#[cfg(posix)]
FileTransferProtocol::Smb => {
let params = TransferSmbParams::new(
bookmark.address.unwrap_or_default(),
bookmark.smb.clone().map(|x| x.share).unwrap_or_default(),
)
.port(bookmark.port.unwrap_or(445))
.username(bookmark.username)
.password(bookmark.password)
.workgroup(bookmark.smb.and_then(|x| x.workgroup));
let smb = bookmark.smb.unwrap_or_default();
let params =
TransferSmbParams::new(bookmark.address.unwrap_or_default(), smb.share)
.port(bookmark.port.unwrap_or(445))
.username(bookmark.username)
.password(bookmark.password)
.workgroup(smb.workgroup)
.dialect(smb.dialect.unwrap_or_default());
Self::new(bookmark.protocol, ProtocolParams::Smb(params))
}
#[cfg(win)]
FileTransferProtocol::Smb => {
let params = TransferSmbParams::new(
bookmark.address.unwrap_or_default(),
bookmark.smb.clone().map(|x| x.share).unwrap_or_default(),
)
.username(bookmark.username)
.password(bookmark.password);
let smb = bookmark.smb.unwrap_or_default();
let params =
TransferSmbParams::new(bookmark.address.unwrap_or_default(), smb.share)
.username(bookmark.username)
.password(bookmark.password)
.dialect(smb.dialect.unwrap_or_default());
Self::new(bookmark.protocol, ProtocolParams::Smb(params))
}
@@ -254,7 +254,7 @@ mod tests {
use pretty_assertions::assert_eq;
use super::*;
use crate::filetransfer::params::DEFAULT_GCS_ENDPOINT;
use crate::filetransfer::params::{DEFAULT_GCS_ENDPOINT, SmbDialect};
#[test]
fn test_bookmarks_default() {
@@ -605,6 +605,7 @@ mod tests {
smb: Some(SmbParams {
share: "test".to_string(),
workgroup: Some("testone".to_string()),
dialect: Some(SmbDialect::Smb2),
}),
};
@@ -625,6 +626,7 @@ mod tests {
assert_eq!(smb_params.password.as_deref().unwrap(), "bar");
assert_eq!(smb_params.username.as_deref().unwrap(), "foo");
assert_eq!(smb_params.workgroup.as_deref().unwrap(), "testone");
assert_eq!(smb_params.dialect, SmbDialect::Smb2);
}
#[test]
@@ -644,6 +646,7 @@ mod tests {
smb: Some(SmbParams {
share: "test".to_string(),
workgroup: None,
dialect: Some(SmbDialect::Smb2),
}),
};
@@ -660,5 +663,32 @@ mod tests {
let smb_params = params.params.smb_params().unwrap();
assert_eq!(smb_params.address.as_str(), "localhost");
assert_eq!(smb_params.share.as_str(), "test");
assert_eq!(smb_params.dialect, SmbDialect::Smb2);
}
#[test]
fn should_default_dialect_when_bookmark_has_none() {
let bookmark: Bookmark = Bookmark {
protocol: FileTransferProtocol::Smb,
address: Some("localhost".to_string()),
port: Some(445),
username: None,
password: None,
remote_path: None,
local_path: None,
kube: None,
s3: None,
gcs: None,
smb: Some(SmbParams {
share: "test".to_string(),
workgroup: None,
dialect: None,
}),
};
let params = FileTransferParams::from(bookmark);
assert_eq!(
params.params.smb_params().unwrap().dialect,
SmbDialect::Auto
);
}
}
+45 -1
View File
@@ -4,7 +4,7 @@
use serde::{Deserialize, Serialize};
use crate::filetransfer::params::SmbParams as TransferSmbParams;
use crate::filetransfer::params::{SmbDialect, SmbParams as TransferSmbParams};
/// Extra Connection parameters for SMB protocol
#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Default)]
@@ -13,6 +13,9 @@ pub struct SmbParams {
pub share: String,
/// Optional SMB workgroup used on POSIX platforms.
pub workgroup: Option<String>,
/// Requested SMB protocol family. `None` (older bookmarks) means `Auto`.
#[serde(default)]
pub dialect: Option<SmbDialect>,
}
#[cfg(posix)]
@@ -21,6 +24,7 @@ impl From<TransferSmbParams> for SmbParams {
Self {
share: params.share,
workgroup: params.workgroup,
dialect: Some(params.dialect),
}
}
}
@@ -31,6 +35,46 @@ impl From<TransferSmbParams> for SmbParams {
Self {
share: params.share,
workgroup: None,
dialect: Some(params.dialect),
}
}
}
#[cfg(test)]
mod test {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn should_deserialize_missing_dialect_as_none() {
let params: SmbParams = toml::from_str("share = \"temp\"").unwrap();
assert_eq!(params.share.as_str(), "temp");
assert_eq!(params.dialect, None);
}
#[test]
fn should_deserialize_dialect() {
let params: SmbParams = toml::from_str("share = \"temp\"\ndialect = \"smb1\"").unwrap();
assert_eq!(params.dialect, Some(SmbDialect::Smb1));
}
#[test]
fn should_round_trip_dialect() {
let params = SmbParams {
share: "temp".to_string(),
workgroup: None,
dialect: Some(SmbDialect::Smb2),
};
let toml_str = toml::to_string(&params).unwrap();
let decoded: SmbParams = toml::from_str(&toml_str).unwrap();
assert_eq!(decoded, params);
}
#[test]
fn should_convert_transfer_params_with_dialect() {
let transfer = TransferSmbParams::new("localhost", "temp").dialect(SmbDialect::Smb3);
let params = SmbParams::from(transfer);
assert_eq!(params.dialect, Some(SmbDialect::Smb3));
}
}
+85 -1
View File
@@ -118,7 +118,8 @@ mod tests {
use crate::config::bookmarks::{Bookmark, KubeParams, S3Params, SmbParams, UserHosts};
use crate::config::params::UserConfig;
use crate::config::themes::Theme;
use crate::filetransfer::FileTransferProtocol;
use crate::filetransfer::params::SmbDialect;
use crate::filetransfer::{FileTransferParams, FileTransferProtocol};
use crate::utils::test_helpers::create_file_ioers;
#[test]
@@ -445,6 +446,7 @@ mod tests {
assert_eq!(smb.share.as_str(), "temp");
#[cfg(posix)]
assert_eq!(smb.workgroup.as_deref().unwrap(), "test");
assert_eq!(smb.dialect, None);
}
#[test]
@@ -491,6 +493,66 @@ mod tests {
assert_eq!(gcs.service_account_key, None);
}
#[test]
fn should_deserialize_legacy_smb_bookmark_without_dialect() {
let toml_file = create_good_toml_bookmarks();
toml_file.as_file().sync_all().unwrap();
toml_file.as_file().rewind().unwrap();
let hosts: UserHosts = deserialize(Box::new(toml_file)).unwrap();
let host = hosts.bookmarks.get("smb").unwrap();
let smb = host.smb.as_ref().unwrap();
assert_eq!(smb.share.as_str(), "temp");
assert_eq!(smb.dialect, None);
// Legacy bookmarks resolve to secure Auto at runtime.
let params = FileTransferParams::from(host.clone());
assert_eq!(
params.params.smb_params().unwrap().dialect,
SmbDialect::Auto
);
}
#[test]
fn should_deserialize_smb_bookmark_with_dialect() {
let toml_file = create_smb_dialect_toml_bookmark();
toml_file.as_file().sync_all().unwrap();
toml_file.as_file().rewind().unwrap();
let hosts: UserHosts = deserialize(Box::new(toml_file)).unwrap();
let host = hosts.bookmarks.get("smb-dialect").unwrap();
let smb = host.smb.as_ref().unwrap();
assert_eq!(smb.dialect, Some(SmbDialect::Smb2));
let params = FileTransferParams::from(host.clone());
assert_eq!(
params.params.smb_params().unwrap().dialect,
SmbDialect::Smb2
);
}
#[test]
fn should_reserialize_legacy_smb_bookmark_and_reload() {
let toml_file = create_good_toml_bookmarks();
toml_file.as_file().sync_all().unwrap();
toml_file.as_file().rewind().unwrap();
let hosts: UserHosts = deserialize(Box::new(toml_file)).unwrap();
let output_file = tempfile::NamedTempFile::new().unwrap();
let output_path = output_file.path().to_path_buf();
serialize(
&hosts,
Box::new(std::fs::File::create(&output_path).unwrap()),
)
.unwrap();
let reloaded: UserHosts =
deserialize(Box::new(std::fs::File::open(&output_path).unwrap())).unwrap();
let smb = reloaded.bookmarks.get("smb").unwrap().smb.as_ref().unwrap();
assert_eq!(smb.share.as_str(), "temp");
assert_eq!(smb.dialect, None);
}
#[test]
fn should_serialize_gcs_bookmark_fields() {
let toml_file = create_good_toml_bookmarks();
@@ -630,6 +692,7 @@ mod tests {
let smb_params: Option<SmbParams> = Some(SmbParams {
share: "test".to_string(),
workgroup: None,
dialect: None,
});
bookmarks.insert(
String::from("smb"),
@@ -789,6 +852,27 @@ mod tests {
tmpfile
}
fn create_smb_dialect_toml_bookmark() -> tempfile::NamedTempFile {
let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
let file_content: &str = r#"
[bookmarks.smb-dialect]
protocol = "SMB"
address = "localhost"
port = 445
username = "test"
password = "test"
[bookmarks.smb-dialect.smb]
share = "temp"
workgroup = "test"
dialect = "smb2"
[recents]
"#;
tmpfile.write_all(file_content.as_bytes()).unwrap();
tmpfile
}
fn create_v14_pod_bookmark() -> tempfile::NamedTempFile {
let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
let file_content: &str = r#"
+1 -1
View File
@@ -13,7 +13,7 @@ use std::path::{Path, PathBuf};
pub use self::aws_s3::AwsS3Params;
pub use self::google_cloud_storage::{DEFAULT_GCS_ENDPOINT, GoogleCloudStorageParams};
pub use self::kube::KubeProtocolParams;
pub use self::smb::SmbParams;
pub use self::smb::{SmbDialect, SmbParams};
pub use self::webdav::WebDAVProtocolParams;
use super::FileTransferProtocol;
+92 -1
View File
@@ -3,6 +3,26 @@
//! Defines the runtime connection parameters used to build SMB remote
//! filesystem clients.
use serde::{Deserialize, Serialize};
/// SMB protocol family requested for a connection.
///
/// Each family maps to inclusive dialect bounds when the Unix client is built.
/// `Auto` negotiates SMB2 or SMB3 and never falls back to SMB1.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SmbDialect {
/// Negotiate SMB 2.0.2 through SMB 3.1.1.
#[default]
Auto,
/// Force the deprecated NT1 (CIFS) dialect.
Smb1,
/// Negotiate SMB 2.0.2 through SMB 2.1.
Smb2,
/// Negotiate SMB 3.0 through SMB 3.1.1.
Smb3,
}
/// Connection parameters for SMB protocol
#[derive(Debug, Clone)]
pub struct SmbParams {
@@ -20,6 +40,8 @@ pub struct SmbParams {
#[cfg(posix)]
/// Optional workgroup used on POSIX platforms.
pub workgroup: Option<String>,
/// Requested SMB protocol family. Enforced on POSIX platforms only.
pub dialect: SmbDialect,
}
// -- SMB params
@@ -36,6 +58,7 @@ impl SmbParams {
password: None,
#[cfg(posix)]
workgroup: None,
dialect: SmbDialect::default(),
}
}
@@ -61,6 +84,12 @@ impl SmbParams {
self
}
/// Sets the SMB protocol family to request.
pub fn dialect(mut self, dialect: SmbDialect) -> Self {
self.dialect = dialect;
self
}
/// Returns whether a password is supposed to be required for this protocol params.
/// The result true is returned ONLY if the supposed secret is MISSING!!!
pub fn password_missing(&self) -> bool {
@@ -82,7 +111,8 @@ mod test {
use pretty_assertions::assert_eq;
use super::*;
use super::SmbParams;
use crate::filetransfer::params::SmbDialect;
#[test]
fn should_init_smb_params() {
@@ -118,6 +148,67 @@ mod test {
assert_eq!(params.workgroup.as_deref().unwrap(), "baz");
}
#[test]
fn should_default_dialect_to_auto() {
assert_eq!(SmbDialect::default(), SmbDialect::Auto);
let params = SmbParams::new("localhost", "temp");
assert_eq!(params.dialect, SmbDialect::Auto);
}
#[test]
fn should_set_dialect() {
let params = SmbParams::new("localhost", "temp").dialect(SmbDialect::Smb1);
assert_eq!(params.dialect, SmbDialect::Smb1);
}
#[test]
fn should_serialize_dialect_lowercase() {
assert_eq!(
toml::to_string(&Wrapper {
dialect: SmbDialect::Auto,
})
.unwrap()
.trim(),
"dialect = \"auto\""
);
assert_eq!(
toml::to_string(&Wrapper {
dialect: SmbDialect::Smb1,
})
.unwrap()
.trim(),
"dialect = \"smb1\""
);
assert_eq!(
toml::to_string(&Wrapper {
dialect: SmbDialect::Smb2,
})
.unwrap()
.trim(),
"dialect = \"smb2\""
);
assert_eq!(
toml::to_string(&Wrapper {
dialect: SmbDialect::Smb3,
})
.unwrap()
.trim(),
"dialect = \"smb3\""
);
}
#[test]
fn should_deserialize_dialect_lowercase() {
let w: Wrapper = toml::from_str("dialect = \"smb3\"").unwrap();
assert_eq!(w.dialect, SmbDialect::Smb3);
assert!(toml::from_str::<Wrapper>("dialect = \"SMB3\"").is_err());
}
#[derive(serde::Serialize, serde::Deserialize)]
struct Wrapper {
dialect: SmbDialect,
}
#[test]
#[cfg(win)]
fn should_init_smb_params_with_optionals() {
+63 -33
View File
@@ -11,16 +11,20 @@ use remotefs_ftp::FtpFs;
use remotefs_gcs::credentials::service_account;
use remotefs_gcs::{GoogleCloudStorageCredentials, GoogleCloudStorageFs};
use remotefs_kube::KubeMultiPodFs as KubeFs;
#[cfg(smb_unix)]
use remotefs_smb::SmbOptions;
#[cfg(smb)]
use remotefs_smb::{SmbCredentials, SmbFs};
use remotefs_smb::{PavaoSmbCredentials as SmbCredentials, PavaoSmbFs as SmbFs};
#[cfg(smb_unix)]
use remotefs_smb::{PavaoSmbOptions as SmbOptions, SmbDialect as RemoteSmbDialect};
#[cfg(smb_windows)]
use remotefs_smb::{WNetSmbCredentials as SmbCredentials, WNetSmbFs as SmbFs};
use remotefs_ssh::{
NoCheckServerKey, RusshSession as SshSession, ScpFs, SftpFs, SshAgentIdentity,
SshConfigParseRule, SshOpts,
};
use remotefs_webdav::WebDAVFs;
#[cfg(smb_unix)]
use super::params::SmbDialect;
#[cfg(not(smb))]
use super::params::{AwsS3Params, GenericProtocolParams, GoogleCloudStorageParams};
#[cfg(smb)]
@@ -29,7 +33,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;
@@ -190,6 +193,17 @@ impl RemoteFsBuilder {
Ok(SftpFs::russh(opts, rt))
}
/// Maps the user-facing SMB family to inclusive remotefs dialect bounds.
#[cfg(smb_unix)]
fn smb_dialect_bounds(dialect: SmbDialect) -> (RemoteSmbDialect, RemoteSmbDialect) {
match dialect {
SmbDialect::Auto => (RemoteSmbDialect::Smb202, RemoteSmbDialect::Smb311),
SmbDialect::Smb1 => (RemoteSmbDialect::Nt1, RemoteSmbDialect::Nt1),
SmbDialect::Smb2 => (RemoteSmbDialect::Smb202, RemoteSmbDialect::Smb210),
SmbDialect::Smb3 => (RemoteSmbDialect::Smb300, RemoteSmbDialect::Smb311),
}
}
#[cfg(smb_unix)]
fn smb_client(params: SmbParams) -> Result<SmbFs, String> {
let mut credentials = SmbCredentials::default()
@@ -206,11 +220,14 @@ impl RemoteFsBuilder {
credentials = credentials.workgroup(workgroup);
}
SmbFs::try_new(
let (min_dialect, max_dialect) = Self::smb_dialect_bounds(params.dialect);
SmbFs::try_new_with_dialect(
credentials,
SmbOptions::default()
.one_share_per_server(true)
.case_sensitive(false),
min_dialect,
max_dialect,
)
.map_err(|e| {
error!("Invalid params for protocol SMB: {e}");
@@ -229,6 +246,7 @@ impl RemoteFsBuilder {
credentials = credentials.password(password);
}
// Dialect is OS-managed on Windows.
Ok(SmbFs::new(credentials))
}
@@ -242,37 +260,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(&params.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.
@@ -313,6 +303,8 @@ mod test {
use std::path::{Path, PathBuf};
#[cfg(smb)]
use serial_test::serial;
use tempfile::TempDir;
use super::*;
@@ -446,12 +438,50 @@ mod test {
#[test]
#[cfg(smb)]
#[serial]
fn should_build_smb_fs() {
let params = ProtocolParams::Smb(SmbParams::new("localhost", "share"));
let config_client = get_config_client();
assert!(RemoteFsBuilder::build(FileTransferProtocol::Smb, params, &config_client).is_ok());
}
#[test]
#[cfg(smb_unix)]
fn should_map_smb_dialect_to_bounds() {
use remotefs_smb::SmbDialect as RemoteSmbDialect;
use crate::filetransfer::params::SmbDialect;
assert_eq!(
RemoteFsBuilder::smb_dialect_bounds(SmbDialect::Auto),
(RemoteSmbDialect::Smb202, RemoteSmbDialect::Smb311)
);
assert_eq!(
RemoteFsBuilder::smb_dialect_bounds(SmbDialect::Smb1),
(RemoteSmbDialect::Nt1, RemoteSmbDialect::Nt1)
);
assert_eq!(
RemoteFsBuilder::smb_dialect_bounds(SmbDialect::Smb2),
(RemoteSmbDialect::Smb202, RemoteSmbDialect::Smb210)
);
assert_eq!(
RemoteFsBuilder::smb_dialect_bounds(SmbDialect::Smb3),
(RemoteSmbDialect::Smb300, RemoteSmbDialect::Smb311)
);
}
#[test]
#[cfg(smb)]
#[serial]
fn should_build_smb_fs_with_dialect() {
use crate::filetransfer::params::SmbDialect;
let params =
ProtocolParams::Smb(SmbParams::new("localhost", "share").dialect(SmbDialect::Smb1));
let config_client = get_config_client();
assert!(RemoteFsBuilder::build(FileTransferProtocol::Smb, params, &config_client).is_ok());
}
#[test]
fn should_not_build_fs() {
let params = ProtocolParams::Generic(
+2
View File
@@ -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,
+97
View File
@@ -101,6 +101,10 @@ pub enum AuthFormId {
SmbShare,
#[cfg(posix)]
SmbWorkgroup,
#[cfg(posix)]
SmbDialect,
#[cfg(posix)]
SmbDialectWarning,
Username,
WebDAVUri,
}
@@ -209,6 +213,10 @@ pub enum UiAuthFormMsg {
SmbWorkgroupDown,
#[cfg(posix)]
SmbWorkgroupUp,
#[cfg(posix)]
SmbDialectBlurDown,
#[cfg(posix)]
SmbDialectBlurUp,
UsernameBlurDown,
UsernameBlurUp,
WebDAVUriBlurDown,
@@ -243,6 +251,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<Id, Msg, NoUserEvent>,
@@ -256,7 +276,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<Context>,
@@ -273,6 +297,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 +343,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 +484,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
));
}
}
+2
View File
@@ -315,6 +315,8 @@ impl AuthActivity {
self.mount_smb_share(form_tab, &params.share);
#[cfg(posix)]
self.mount_smb_workgroup(form_tab, params.workgroup.as_deref().unwrap_or(""));
#[cfg(posix)]
self.mount_smb_dialect(form_tab, params.dialect);
}
fn load_bookmark_webdav_into_gui(&mut self, form_tab: FormTab, params: WebDAVProtocolParams) {
+2 -2
View File
@@ -13,8 +13,6 @@ pub use bookmarks::{
BookmarkName, BookmarkSavePassword, BookmarksList, DeleteBookmarkPopup, DeleteRecentPopup,
RecentsList,
};
#[cfg(posix)]
pub use form::InputSmbWorkgroup;
pub use form::{
HostBridgeProtocolRadio, InputAddress, InputGcsBucket, InputGcsEndpoint,
InputGcsServiceAccountKey, InputKubeClientCert, InputKubeClientKey, InputKubeClusterUrl,
@@ -23,6 +21,8 @@ pub use form::{
InputS3Region, InputS3SecretAccessKey, InputS3SecurityToken, InputS3SessionToken,
InputSmbShare, InputUsername, InputWebDAVUri, RadioS3NewPathStyle, RemoteProtocolRadio,
};
#[cfg(posix)]
pub use form::{InputSmbWorkgroup, RadioSmbDialect, SmbDialectWarning};
pub use popup::{
ErrorPopup, InfoPopup, InstallUpdatePopup, Keybindings, QuitPopup, ReleaseNotes, WaitPopup,
WindowSizeError,
+1 -1
View File
@@ -54,7 +54,7 @@ pub use s3::{
};
pub use smb::InputSmbShare;
#[cfg(posix)]
pub use smb::InputSmbWorkgroup;
pub use smb::{InputSmbWorkgroup, RadioSmbDialect, SmbDialectWarning};
pub use webdav::InputWebDAVUri;
fn handle_input_ev(
@@ -1,7 +1,13 @@
#[cfg(posix)]
use tui_realm_stdlib::components::Span;
use tuirealm::component::{AppComponent, Component};
use tuirealm::event::NoUserEvent;
#[cfg(posix)]
use tuirealm::props::SpanStatic;
use super::*;
#[cfg(posix)]
use crate::filetransfer::params::SmbDialect;
#[derive(Component)]
pub struct InputSmbShare {
@@ -85,3 +91,163 @@ impl AppComponent<Msg, NoUserEvent> for InputSmbWorkgroup {
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
#[cfg(all(test, posix))]
mod test {
use pretty_assertions::assert_eq;
use super::*;
use crate::filetransfer::params::SmbDialect;
#[test]
fn should_map_radio_options_to_dialect() {
assert_eq!(RadioSmbDialect::opt_to_dialect(0), SmbDialect::Auto);
assert_eq!(RadioSmbDialect::opt_to_dialect(1), SmbDialect::Smb1);
assert_eq!(RadioSmbDialect::opt_to_dialect(2), SmbDialect::Smb2);
assert_eq!(RadioSmbDialect::opt_to_dialect(3), SmbDialect::Smb3);
assert_eq!(RadioSmbDialect::opt_to_dialect(99), SmbDialect::Auto);
}
#[test]
fn should_map_dialect_to_radio_options() {
for dialect in [
SmbDialect::Auto,
SmbDialect::Smb1,
SmbDialect::Smb2,
SmbDialect::Smb3,
] {
let opt = RadioSmbDialect::dialect_to_opt(dialect);
assert_eq!(RadioSmbDialect::opt_to_dialect(opt), dialect);
}
}
}
#[cfg(posix)]
const RADIO_SMB_DIALECT_AUTO: usize = 0;
#[cfg(posix)]
const RADIO_SMB_DIALECT_SMB1: usize = 1;
#[cfg(posix)]
const RADIO_SMB_DIALECT_SMB2: usize = 2;
#[cfg(posix)]
const RADIO_SMB_DIALECT_SMB3: usize = 3;
/// Radio to select the SMB protocol family.
#[cfg(posix)]
#[derive(Component)]
pub struct RadioSmbDialect {
component: Radio,
form_tab: FormTab,
}
#[cfg(posix)]
impl RadioSmbDialect {
pub fn new(dialect: SmbDialect, form_tab: FormTab, color: Color) -> Self {
Self {
component: Radio::default()
.highlight_style(
Style::default()
.fg(color)
.add_modifier(TextModifiers::REVERSED),
)
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.choices(["Auto", "SMB1 (insecure)", "SMB2", "SMB3"])
.rewind(true)
.title(Title::from("SMB version").alignment(HorizontalAlignment::Left))
.value(Self::dialect_to_opt(dialect)),
form_tab,
}
}
/// Converts the radio choice index to a dialect. Unknown indexes map to `Auto`.
pub fn opt_to_dialect(opt: usize) -> SmbDialect {
match opt {
RADIO_SMB_DIALECT_SMB1 => SmbDialect::Smb1,
RADIO_SMB_DIALECT_SMB2 => SmbDialect::Smb2,
RADIO_SMB_DIALECT_SMB3 => SmbDialect::Smb3,
_ => SmbDialect::Auto,
}
}
fn dialect_to_opt(dialect: SmbDialect) -> usize {
match dialect {
SmbDialect::Auto => RADIO_SMB_DIALECT_AUTO,
SmbDialect::Smb1 => RADIO_SMB_DIALECT_SMB1,
SmbDialect::Smb2 => RADIO_SMB_DIALECT_SMB2,
SmbDialect::Smb3 => RADIO_SMB_DIALECT_SMB3,
}
}
}
#[cfg(posix)]
impl AppComponent<Msg, NoUserEvent> for RadioSmbDialect {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
match ev {
Event::Keyboard(KeyEvent {
code: Key::Left, ..
}) => {
self.perform(Cmd::Move(Direction::Left));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Right, ..
}) => {
self.perform(Cmd::Move(Direction::Right));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Enter, ..
}) => Some(Msg::Form(FormMsg::Connect)),
Event::Keyboard(KeyEvent {
code: Key::Down, ..
}) => Some(if self.form_tab == FormTab::Remote {
Msg::Ui(UiMsg::Remote(UiAuthFormMsg::SmbDialectBlurDown))
} else {
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::SmbDialectBlurDown))
}),
Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
Some(if self.form_tab == FormTab::Remote {
Msg::Ui(UiMsg::Remote(UiAuthFormMsg::SmbDialectBlurUp))
} else {
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::SmbDialectBlurUp))
})
}
Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
Some(if self.form_tab == FormTab::Remote {
Msg::Ui(UiMsg::Remote(UiAuthFormMsg::ParamsFormBlur))
} else {
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::ParamsFormBlur))
})
}
_ => None,
}
}
}
/// One-line warning shown above the dialect radio while SMB1 is selected.
#[cfg(posix)]
#[derive(Component)]
pub struct SmbDialectWarning {
component: Span,
}
#[cfg(posix)]
impl SmbDialectWarning {
pub fn new(color: Color) -> Self {
Self {
component: Span::default().foreground(color).spans([SpanStatic::from(
"Warning: SMB1 is deprecated and insecure. Use it only for isolated legacy devices.",
)]),
}
}
}
#[cfg(posix)]
impl AppComponent<Msg, NoUserEvent> for SmbDialectWarning {
fn on(&mut self, _ev: &Event<NoUserEvent>) -> Option<Msg> {
None
}
}
+102 -28
View File
@@ -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<Msg>) -> Option<Msg> {
@@ -24,19 +26,26 @@ impl AuthActivity {
fn update_form(&mut self, msg: FormMsg) -> Option<Msg> {
match msg {
FormMsg::Connect => {
let Ok(remote_params) = self.collect_remote_host_params() else {
// mount error
self.mount_error("Invalid remote params parameters");
return None;
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) => {
// mount error
self.mount_error(format!("Invalid remote host parameters: {err}"));
return None;
}
};
let Ok(host_bridge_params) = self.collect_host_bridge_params() else {
// mount error
self.mount_error("Invalid host bridge params parameters");
return None;
};
debug!("Remote params: {:?}", remote_params);
let host_bridge_params = match self.collect_host_bridge_params() {
Ok(host_bridge_params) => host_bridge_params,
Err(err) => {
// mount error
self.mount_error(format!("Invalid host bridge parameters: {err}"));
return None;
}
};
debug!("Host bridge params: {:?}", host_bridge_params);
self.save_recent();
@@ -137,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 => {
@@ -248,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 {
@@ -256,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));
}
@@ -273,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 => {
@@ -333,7 +358,7 @@ impl AuthActivity {
InputMask::Localhost => unreachable!(),
InputMask::Generic => Id::HostBridge(AuthFormId::Password),
#[cfg(posix)]
InputMask::Smb => Id::HostBridge(AuthFormId::SmbWorkgroup),
InputMask::Smb => Id::HostBridge(AuthFormId::SmbDialect),
#[cfg(win)]
InputMask::Smb => Id::HostBridge(AuthFormId::Password),
InputMask::Kube => Id::HostBridge(AuthFormId::KubeClientKey),
@@ -458,12 +483,20 @@ impl AuthActivity {
}
#[cfg(posix)]
UiAuthFormMsg::SmbWorkgroupDown => {
self.activate_component(Id::HostBridge(AuthFormId::RemoteDirectory))
self.activate_component(Id::HostBridge(AuthFormId::SmbDialect))
}
#[cfg(posix)]
UiAuthFormMsg::SmbWorkgroupUp => {
self.activate_component(Id::HostBridge(AuthFormId::Password))
}
#[cfg(posix)]
UiAuthFormMsg::SmbDialectBlurDown => {
self.activate_component(Id::HostBridge(AuthFormId::RemoteDirectory))
}
#[cfg(posix)]
UiAuthFormMsg::SmbDialectBlurUp => {
self.activate_component(Id::HostBridge(AuthFormId::SmbWorkgroup))
}
UiAuthFormMsg::UsernameBlurDown => {
self.activate_component(Id::HostBridge(AuthFormId::Password))
}
@@ -491,6 +524,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 {
@@ -499,9 +533,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));
}
@@ -512,6 +548,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 => {
@@ -572,7 +609,7 @@ impl AuthActivity {
InputMask::Localhost => unreachable!(),
InputMask::Generic => Id::Remote(AuthFormId::Password),
#[cfg(posix)]
InputMask::Smb => Id::Remote(AuthFormId::SmbWorkgroup),
InputMask::Smb => Id::Remote(AuthFormId::SmbDialect),
#[cfg(win)]
InputMask::Smb => Id::Remote(AuthFormId::Password),
InputMask::Kube => Id::Remote(AuthFormId::KubeClientKey),
@@ -697,12 +734,20 @@ impl AuthActivity {
}
#[cfg(posix)]
UiAuthFormMsg::SmbWorkgroupDown => {
self.activate_component(Id::Remote(AuthFormId::RemoteDirectory))
self.activate_component(Id::Remote(AuthFormId::SmbDialect))
}
#[cfg(posix)]
UiAuthFormMsg::SmbWorkgroupUp => {
self.activate_component(Id::Remote(AuthFormId::Password))
}
#[cfg(posix)]
UiAuthFormMsg::SmbDialectBlurDown => {
self.activate_component(Id::Remote(AuthFormId::RemoteDirectory))
}
#[cfg(posix)]
UiAuthFormMsg::SmbDialectBlurUp => {
self.activate_component(Id::Remote(AuthFormId::SmbWorkgroup))
}
UiAuthFormMsg::UsernameBlurDown => {
self.activate_component(Id::Remote(AuthFormId::Password))
}
@@ -727,6 +772,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}");
+156 -58
View File
@@ -3,7 +3,7 @@
//! `auth_activity` is the module which implements the authentication activity
use tuirealm::props::Color;
use tuirealm::ratatui::layout::{Constraint, Direction, Layout};
use tuirealm::ratatui::layout::{Constraint, Direction, Layout, Rect};
use tuirealm::ratatui::widgets::Clear;
use tuirealm::terminal::TerminalAdapter;
@@ -12,6 +12,8 @@ use super::{
InputMask, components,
};
use crate::filetransfer::params::DEFAULT_GCS_ENDPOINT;
#[cfg(posix)]
use crate::filetransfer::params::SmbDialect;
use crate::utils::ui::{Popup, Size};
#[path = "view/mounting.rs"]
@@ -73,6 +75,10 @@ impl AuthActivity {
self.mount_smb_share(FormTab::HostBridge, "");
#[cfg(posix)]
self.mount_smb_workgroup(FormTab::HostBridge, "");
#[cfg(posix)]
self.mount_smb_dialect(FormTab::HostBridge, SmbDialect::default());
#[cfg(posix)]
self.mount_smb_dialect_warning(FormTab::HostBridge);
self.mount_webdav_uri(FormTab::HostBridge, "");
let remote_default_protocol = self.context().config().get_default_protocol();
@@ -107,6 +113,10 @@ impl AuthActivity {
self.mount_smb_share(FormTab::Remote, "");
#[cfg(posix)]
self.mount_smb_workgroup(FormTab::Remote, "");
#[cfg(posix)]
self.mount_smb_dialect(FormTab::Remote, SmbDialect::default());
#[cfg(posix)]
self.mount_smb_dialect_warning(FormTab::Remote);
self.mount_webdav_uri(FormTab::Remote, "");
if let Some(version) = self
@@ -247,8 +257,10 @@ impl AuthActivity {
f: &mut tuirealm::ratatui::Frame<'_>,
area: tuirealm::ratatui::layout::Rect,
) {
let input_mask_size = Self::input_mask_size(self.host_bridge_input_mask());
let input_mask = self.host_bridge_input_mask();
let protocol_and_mask_chunks = Layout::default()
.constraints([Constraint::Length(3), Constraint::Length(12)].as_ref())
.constraints([Constraint::Length(3), Constraint::Length(input_mask_size)].as_ref())
.direction(Direction::Vertical)
.split(area);
@@ -258,36 +270,25 @@ impl AuthActivity {
protocol_and_mask_chunks[0],
);
let input_mask = Layout::default()
.constraints(
[
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
]
.as_ref(),
)
.direction(Direction::Vertical)
.split(protocol_and_mask_chunks[1]);
match self.host_bridge_input_mask() {
InputMask::AwsS3 => self.render_view_ids(f, input_mask, self.get_host_bridge_s3_view()),
InputMask::Gcs => self.render_view_ids(f, input_mask, self.get_host_bridge_gcs_view()),
InputMask::Generic => {
self.render_view_ids(f, input_mask, self.get_host_bridge_generic_params_view())
}
InputMask::Kube => {
self.render_view_ids(f, input_mask, self.get_host_bridge_kube_view())
}
let view_ids = match input_mask {
InputMask::AwsS3 => self.get_host_bridge_s3_view(),
InputMask::Gcs => self.get_host_bridge_gcs_view(),
InputMask::Generic => self.get_host_bridge_generic_params_view(),
InputMask::Kube => self.get_host_bridge_kube_view(),
InputMask::Localhost => {
let view_ids = self.get_host_bridge_localhost_view();
self.app.view(&view_ids[0], f, input_mask[0]);
self.app.view(&view_ids[0], f, protocol_and_mask_chunks[1]);
return;
}
InputMask::Smb => self.render_view_ids(f, input_mask, self.get_host_bridge_smb_view()),
InputMask::WebDAV => {
self.render_view_ids(f, input_mask, self.get_host_bridge_webdav_view())
}
}
InputMask::Smb => self.get_host_bridge_smb_view(),
InputMask::WebDAV => self.get_host_bridge_webdav_view(),
};
self.render_form_rows(
f,
protocol_and_mask_chunks[1],
FormTab::HostBridge,
view_ids,
);
}
fn render_remote_input_mask(
@@ -295,8 +296,10 @@ impl AuthActivity {
f: &mut tuirealm::ratatui::Frame<'_>,
area: tuirealm::ratatui::layout::Rect,
) {
let input_mask_size = Self::input_mask_size(self.remote_input_mask());
let input_mask = self.remote_input_mask();
let protocol_and_mask_chunks = Layout::default()
.constraints([Constraint::Length(3), Constraint::Length(12)].as_ref())
.constraints([Constraint::Length(3), Constraint::Length(input_mask_size)].as_ref())
.direction(Direction::Vertical)
.split(area);
@@ -306,40 +309,135 @@ impl AuthActivity {
protocol_and_mask_chunks[0],
);
let input_mask = Layout::default()
.constraints(
[
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
]
.as_ref(),
)
.direction(Direction::Vertical)
.split(protocol_and_mask_chunks[1]);
match self.remote_input_mask() {
InputMask::AwsS3 => self.render_view_ids(f, input_mask, self.get_remote_s3_view()),
InputMask::Gcs => self.render_view_ids(f, input_mask, self.get_remote_gcs_view()),
InputMask::Generic => {
self.render_view_ids(f, input_mask, self.get_remote_generic_params_view())
}
InputMask::Kube => self.render_view_ids(f, input_mask, self.get_remote_kube_view()),
let view_ids = match input_mask {
InputMask::AwsS3 => self.get_remote_s3_view(),
InputMask::Gcs => self.get_remote_gcs_view(),
InputMask::Generic => self.get_remote_generic_params_view(),
InputMask::Kube => self.get_remote_kube_view(),
InputMask::Localhost => unreachable!(),
InputMask::Smb => self.render_view_ids(f, input_mask, self.get_remote_smb_view()),
InputMask::WebDAV => self.render_view_ids(f, input_mask, self.get_remote_webdav_view()),
}
InputMask::Smb => self.get_remote_smb_view(),
InputMask::WebDAV => self.get_remote_webdav_view(),
};
self.render_form_rows(f, protocol_and_mask_chunks[1], FormTab::Remote, view_ids);
}
fn render_view_ids(
/// Splits `area` into four 3-line form rows. When `warning_row` is
/// `Some(index)`, a 1-line row is inserted directly above row `index` and
/// returned as the second tuple element.
fn split_input_mask(area: Rect, warning_row: Option<usize>) -> ([Rect; 4], Option<Rect>) {
let mut constraints = Vec::with_capacity(6);
for row in 0..4 {
if warning_row == Some(row) {
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Length(3));
}
constraints.push(Constraint::Min(0));
let chunks = Layout::default()
.constraints(constraints)
.direction(Direction::Vertical)
.split(area);
let mut rows = [Rect::default(); 4];
let mut warning = None;
let mut chunk = 0;
for (row, slot) in rows.iter_mut().enumerate() {
if warning_row == Some(row) {
warning = Some(chunks[chunk]);
chunk += 1;
}
*slot = chunks[chunk];
chunk += 1;
}
(rows, warning)
}
/// Returns the visible row index of the SMB dialect radio when the form
/// shows SMB and SMB1 is selected; `None` otherwise.
#[cfg(posix)]
fn smb_dialect_warning_row(&self, form_tab: FormTab, view_ids: &[Id; 4]) -> Option<usize> {
let input_mask = match form_tab {
FormTab::HostBridge => self.host_bridge_input_mask(),
FormTab::Remote => self.remote_input_mask(),
};
if input_mask != InputMask::Smb || self.get_input_smb_dialect(form_tab) != SmbDialect::Smb1
{
return None;
}
let dialect_id = Self::form_tab_id(form_tab, AuthFormId::SmbDialect);
view_ids.iter().position(|id| *id == dialect_id)
}
#[cfg(win)]
fn smb_dialect_warning_row(&self, _form_tab: FormTab, _view_ids: &[Id; 4]) -> Option<usize> {
None
}
fn render_form_rows(
&mut self,
f: &mut tuirealm::ratatui::Frame<'_>,
input_mask: std::rc::Rc<[tuirealm::ratatui::layout::Rect]>,
area: Rect,
form_tab: FormTab,
view_ids: [Id; 4],
) {
self.app.view(&view_ids[0], f, input_mask[0]);
self.app.view(&view_ids[1], f, input_mask[1]);
self.app.view(&view_ids[2], f, input_mask[2]);
self.app.view(&view_ids[3], f, input_mask[3]);
let warning_row = self.smb_dialect_warning_row(form_tab, &view_ids);
let (rows, warning) = Self::split_input_mask(area, warning_row);
#[cfg(posix)]
if let Some(rect) = warning {
let id = Self::form_tab_id(form_tab, AuthFormId::SmbDialectWarning);
self.app.view(&id, f, rect);
}
#[cfg(win)]
let _ = warning;
for (id, rect) in view_ids.iter().zip(rows) {
self.app.view(id, f, rect);
}
}
}
#[cfg(test)]
mod test {
use pretty_assertions::assert_eq;
use tuirealm::ratatui::layout::Rect;
use super::AuthActivity;
#[test]
fn should_split_input_mask_without_warning() {
let area = Rect::new(0, 0, 40, 13);
let (rows, warning) = AuthActivity::split_input_mask(area, None);
assert_eq!(warning, None);
assert_eq!(rows[0], Rect::new(0, 0, 40, 3));
assert_eq!(rows[1], Rect::new(0, 3, 40, 3));
assert_eq!(rows[2], Rect::new(0, 6, 40, 3));
assert_eq!(rows[3], Rect::new(0, 9, 40, 3));
}
#[test]
fn should_split_input_mask_with_middle_warning() {
let area = Rect::new(0, 0, 40, 13);
let (rows, warning) = AuthActivity::split_input_mask(area, Some(2));
assert_eq!(warning, Some(Rect::new(0, 6, 40, 1)));
assert_eq!(rows[0], Rect::new(0, 0, 40, 3));
assert_eq!(rows[1], Rect::new(0, 3, 40, 3));
assert_eq!(rows[2], Rect::new(0, 7, 40, 3));
assert_eq!(rows[3], Rect::new(0, 10, 40, 3));
}
#[test]
fn should_split_input_mask_with_first_row_warning() {
let area = Rect::new(0, 0, 40, 13);
let (rows, warning) = AuthActivity::split_input_mask(area, Some(0));
assert_eq!(warning, Some(Rect::new(0, 0, 40, 1)));
assert_eq!(rows[0], Rect::new(0, 1, 40, 3));
assert_eq!(rows[1], Rect::new(0, 4, 40, 3));
assert_eq!(rows[2], Rect::new(0, 7, 40, 3));
assert_eq!(rows[3], Rect::new(0, 10, 40, 3));
}
}
+33
View File
@@ -1,4 +1,6 @@
use super::*;
#[cfg(posix)]
use crate::filetransfer::params::SmbDialect;
use crate::ui::activities::auth::STORE_KEY_RELEASE_NOTES;
impl AuthActivity {
@@ -320,6 +322,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(
@@ -704,6 +707,36 @@ impl AuthActivity {
}
}
#[cfg(posix)]
pub(in crate::ui::activities::auth) fn mount_smb_dialect(
&mut self,
form_tab: FormTab,
dialect: SmbDialect,
) {
let color = self.theme().auth_protocol;
let id = Self::form_tab_id(form_tab, AuthFormId::SmbDialect);
if let Err(err) = self.app.remount(
id,
Box::new(components::RadioSmbDialect::new(dialect, form_tab, color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
#[cfg(posix)]
pub(in crate::ui::activities::auth) fn mount_smb_dialect_warning(&mut self, form_tab: FormTab) {
let color = self.theme().misc_warn_dialog;
let id = Self::form_tab_id(form_tab, AuthFormId::SmbDialectWarning);
if let Err(err) = self.app.remount(
id,
Box::new(components::SmbDialectWarning::new(color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_webdav_uri(
&mut self,
form_tab: FormTab,
+23 -1
View File
@@ -5,10 +5,14 @@ use tuirealm::state::{State, StateValue};
use super::*;
use crate::filetransfer::FileTransferParams;
#[cfg(posix)]
use crate::filetransfer::params::SmbDialect;
use crate::filetransfer::params::{
AwsS3Params, GenericProtocolParams, GoogleCloudStorageParams, KubeProtocolParams,
ProtocolParams, SmbParams, WebDAVProtocolParams,
};
#[cfg(posix)]
use crate::ui::activities::auth::components::RadioSmbDialect;
impl AuthActivity {
pub(in crate::ui::activities::auth) fn get_generic_params_input(
@@ -82,6 +86,7 @@ impl AuthActivity {
) -> SmbParams {
let share = self.get_input_smb_share(form_tab);
let workgroup = self.get_input_smb_workgroup(form_tab);
let dialect = self.get_input_smb_dialect(form_tab);
let address = self.get_input_addr(form_tab);
let port = self.get_input_port(form_tab);
let username = self.get_input_username(form_tab);
@@ -92,6 +97,7 @@ impl AuthActivity {
.username(username)
.password(password)
.workgroup(workgroup)
.dialect(dialect)
}
#[cfg(win)]
@@ -452,6 +458,20 @@ impl AuthActivity {
}
}
#[cfg(posix)]
pub(in crate::ui::activities::auth) fn get_input_smb_dialect(
&self,
form_tab: FormTab,
) -> SmbDialect {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::SmbDialect))
{
Ok(State::Single(StateValue::Usize(opt))) => RadioSmbDialect::opt_to_dialect(opt),
_ => SmbDialect::default(),
}
}
pub(in crate::ui::activities::auth) fn get_new_bookmark(&self) -> (String, bool) {
let name = match self.app.state(&Id::BookmarkName) {
Ok(State::Single(StateValue::String(name))) => name,
@@ -473,8 +493,10 @@ impl AuthActivity {
+ 3
}
fn input_mask_size(input_mask: InputMask) -> u16 {
pub(in crate::ui::activities::auth) fn input_mask_size(input_mask: InputMask) -> u16 {
match input_mask {
// One extra line for the SMB1 warning above the dialect radio.
InputMask::Smb if cfg!(posix) => 13,
InputMask::AwsS3
| InputMask::Gcs
| InputMask::Generic
+16 -4
View File
@@ -289,15 +289,21 @@ impl AuthActivity {
Id::HostBridge(AuthFormId::Password),
Id::HostBridge(AuthFormId::SmbWorkgroup),
],
Some(&Id::HostBridge(AuthFormId::RemoteDirectory)) => [
Some(&Id::HostBridge(AuthFormId::SmbDialect)) => [
Id::HostBridge(AuthFormId::Username),
Id::HostBridge(AuthFormId::Password),
Id::HostBridge(AuthFormId::SmbWorkgroup),
Id::HostBridge(AuthFormId::SmbDialect),
],
Some(&Id::HostBridge(AuthFormId::RemoteDirectory)) => [
Id::HostBridge(AuthFormId::Password),
Id::HostBridge(AuthFormId::SmbWorkgroup),
Id::HostBridge(AuthFormId::SmbDialect),
Id::HostBridge(AuthFormId::RemoteDirectory),
],
Some(&Id::HostBridge(AuthFormId::LocalDirectory)) => [
Id::HostBridge(AuthFormId::Password),
Id::HostBridge(AuthFormId::SmbWorkgroup),
Id::HostBridge(AuthFormId::SmbDialect),
Id::HostBridge(AuthFormId::RemoteDirectory),
Id::HostBridge(AuthFormId::LocalDirectory),
],
@@ -336,15 +342,21 @@ impl AuthActivity {
Id::Remote(AuthFormId::Password),
Id::Remote(AuthFormId::SmbWorkgroup),
],
Some(&Id::Remote(AuthFormId::RemoteDirectory)) => [
Some(&Id::Remote(AuthFormId::SmbDialect)) => [
Id::Remote(AuthFormId::Username),
Id::Remote(AuthFormId::Password),
Id::Remote(AuthFormId::SmbWorkgroup),
Id::Remote(AuthFormId::SmbDialect),
],
Some(&Id::Remote(AuthFormId::RemoteDirectory)) => [
Id::Remote(AuthFormId::Password),
Id::Remote(AuthFormId::SmbWorkgroup),
Id::Remote(AuthFormId::SmbDialect),
Id::Remote(AuthFormId::RemoteDirectory),
],
Some(&Id::Remote(AuthFormId::LocalDirectory)) => [
Id::Remote(AuthFormId::Password),
Id::Remote(AuthFormId::SmbWorkgroup),
Id::Remote(AuthFormId::SmbDialect),
Id::Remote(AuthFormId::RemoteDirectory),
Id::Remote(AuthFormId::LocalDirectory),
],
+18
View File
@@ -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<HostBridgeParams>,
/// Parameters used to build the remote file transfer client.
remote_params: Option<FileTransferParams>,
/// Client for persistent bookmarks, when initialization succeeded.
bookmarks_client: Option<BookmarksClient>,
/// Client for persisted application configuration.
config_client: ConfigClient,
/// SSH configuration parsed once during application startup.
ssh_config: Option<SshConfig>,
/// 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<String>,
}
@@ -29,6 +40,7 @@ impl Context {
bookmarks_client: Option<BookmarksClient>,
config_client: ConfigClient,
theme_provider: ThemeProvider,
ssh_config: Option<SshConfig>,
error: Option<String>,
) -> 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
}
+36 -1
View File
@@ -64,6 +64,15 @@ static SEMVER_REGEX: Lazy<Regex> = lazy_regex!(r"v?((0|[1-9]\d*)\.(0|[1-9]\d*)\.
*/
static BYTESIZE_REGEX: Lazy<Regex> = 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<Regex> = lazy_regex!(r"(:?([0-9])+)( )*(:?[KMGTP])?B
///
/// `\\<address>\<share>[\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<FileTransferParams, String> {
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<ParsedRemote, String> {
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 =
+16 -6
View File
@@ -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<FileTransferParams, String> {
pub(super) fn parse_remote_opt_with_metadata(s: &str) -> Result<ParsedRemote, String> {
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<FileTransferParams, String> {
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<FileTransferParams, String> {
) -> Result<ParsedRemote, String> {
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<FileTransferParams, String> {
+74 -1
View File
@@ -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<String>,
}
/// Parses an OpenSSH-style config file into an `ssh2_config::SshConfig`.
pub fn parse_ssh2_config(path: &str) -> Result<SshConfig, String> {
use std::fs::File;
@@ -18,10 +30,23 @@ pub fn parse_ssh2_config(path: &str) -> Result<SshConfig, String> {
.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,
}
);
}
}