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
This commit is contained in:
Christian Visintin
2026-09-02 14:14:23 +02:00
committed by GitHub
parent e966a83220
commit 751f68f6d4
20 changed files with 802 additions and 121 deletions
Generated
+11 -26
View File
@@ -2121,9 +2121,9 @@ dependencies = [
[[package]] [[package]]
name = "git2" name = "git2"
version = "0.20.4" version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"libc", "libc",
@@ -3405,7 +3405,6 @@ checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
"libssh2-sys",
"libz-sys", "libz-sys",
"openssl-sys", "openssl-sys",
"pkg-config", "pkg-config",
@@ -3426,20 +3425,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "libssh2-sys"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c04141a07bb0c0bc461cb657808764de571702a59bc5c726c400ac9a7625e3ab"
dependencies = [
"cc",
"libc",
"libz-sys",
"openssl-sys",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "libz-sys" name = "libz-sys"
version = "1.1.29" version = "1.1.29"
@@ -4135,9 +4120,9 @@ checksum = "1e91099d4268b0e11973f036e885d652fb0b21fedcf69738c627f94db6a44f42"
[[package]] [[package]]
name = "pavao" name = "pavao"
version = "0.2.16" version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cca8302d5f1b5dc7a8dfb04e5ea7185b43571db5811e084746bf0aeb0d7b18f1" checksum = "6bf4bff78f032402eb6b89a72801f8d4b5ef57e79d33139f6e4fb214e823a854"
dependencies = [ dependencies = [
"cfg_aliases", "cfg_aliases",
"lazy_static", "lazy_static",
@@ -4149,9 +4134,9 @@ dependencies = [
[[package]] [[package]]
name = "pavao-src" name = "pavao-src"
version = "4.22.0-4" version = "4.22.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30b8c63418f1e7dfbb786268ae0927537b87d90b1211216c1ba50ce25d8d9495" checksum = "48289686720a0215553faa63f3211f0faa57c698287fb1ac41d3fbd423ed1f12"
dependencies = [ dependencies = [
"cc", "cc",
"git2", "git2",
@@ -4160,9 +4145,9 @@ dependencies = [
[[package]] [[package]]
name = "pavao-sys" name = "pavao-sys"
version = "0.2.16" version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07b390d8c7372e94c7e0f7d92e36c98404634c554a52b1cb9ae030b4e7888cde" checksum = "8c6a44c550ef244c0a19f0a2201687af1853b0b9d1cfaa989a9312942c676adf"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
@@ -4923,16 +4908,16 @@ dependencies = [
[[package]] [[package]]
name = "remotefs-smb" name = "remotefs-smb"
version = "0.3.1" version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c077c88aecf906b0267def951277dc4f6fd1f368edb73d333771a24c0b47970a" checksum = "c5e5b1fc4afd54e729393b3b44b537583c8e815d0c8e41483799001084e5fe96"
dependencies = [ dependencies = [
"filetime", "filetime",
"libc", "libc",
"log", "log",
"pavao", "pavao",
"remotefs", "remotefs",
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
+1 -1
View File
@@ -62,7 +62,7 @@ remotefs = "0.3"
remotefs-aws-s3 = "0.4" remotefs-aws-s3 = "0.4"
remotefs-gcs = "0.1" remotefs-gcs = "0.1"
remotefs-kube = "0.4" remotefs-kube = "0.4"
remotefs-smb = { version = "0.3", optional = true } remotefs-smb = { version = "0.4", optional = true }
remotefs-ssh = { version = "0.9", default-features = false, features = ["russh"] } remotefs-ssh = { version = "0.9", default-features = false, features = ["russh"] }
remotefs-webdav = "0.2" remotefs-webdav = "0.2"
rpassword = "7" rpassword = "7"
@@ -169,8 +169,28 @@ Authentication-form fields:
- Password - Password
- Port (other systems only; default `445`) - Port (other systems only; default `445`)
- Workgroup (other systems only) - 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: Windows address syntax:
@@ -151,8 +151,22 @@ CLI 连接使用 ADC 和默认端点。如果需要自定义端点或服务账
- 密码 - 密码
- 端口(仅其他系统;默认 `445`) - 端口(仅其他系统;默认 `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 地址语法: Windows 地址语法:
+41 -11
View File
@@ -191,25 +191,25 @@ impl From<Bookmark> for FileTransferParams {
} }
#[cfg(posix)] #[cfg(posix)]
FileTransferProtocol::Smb => { FileTransferProtocol::Smb => {
let params = TransferSmbParams::new( let smb = bookmark.smb.unwrap_or_default();
bookmark.address.unwrap_or_default(), let params =
bookmark.smb.clone().map(|x| x.share).unwrap_or_default(), TransferSmbParams::new(bookmark.address.unwrap_or_default(), smb.share)
)
.port(bookmark.port.unwrap_or(445)) .port(bookmark.port.unwrap_or(445))
.username(bookmark.username) .username(bookmark.username)
.password(bookmark.password) .password(bookmark.password)
.workgroup(bookmark.smb.and_then(|x| x.workgroup)); .workgroup(smb.workgroup)
.dialect(smb.dialect.unwrap_or_default());
Self::new(bookmark.protocol, ProtocolParams::Smb(params)) Self::new(bookmark.protocol, ProtocolParams::Smb(params))
} }
#[cfg(win)] #[cfg(win)]
FileTransferProtocol::Smb => { FileTransferProtocol::Smb => {
let params = TransferSmbParams::new( let smb = bookmark.smb.unwrap_or_default();
bookmark.address.unwrap_or_default(), let params =
bookmark.smb.clone().map(|x| x.share).unwrap_or_default(), TransferSmbParams::new(bookmark.address.unwrap_or_default(), smb.share)
)
.username(bookmark.username) .username(bookmark.username)
.password(bookmark.password); .password(bookmark.password)
.dialect(smb.dialect.unwrap_or_default());
Self::new(bookmark.protocol, ProtocolParams::Smb(params)) Self::new(bookmark.protocol, ProtocolParams::Smb(params))
} }
@@ -254,7 +254,7 @@ mod tests {
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use super::*; use super::*;
use crate::filetransfer::params::DEFAULT_GCS_ENDPOINT; use crate::filetransfer::params::{DEFAULT_GCS_ENDPOINT, SmbDialect};
#[test] #[test]
fn test_bookmarks_default() { fn test_bookmarks_default() {
@@ -605,6 +605,7 @@ mod tests {
smb: Some(SmbParams { smb: Some(SmbParams {
share: "test".to_string(), share: "test".to_string(),
workgroup: Some("testone".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.password.as_deref().unwrap(), "bar");
assert_eq!(smb_params.username.as_deref().unwrap(), "foo"); assert_eq!(smb_params.username.as_deref().unwrap(), "foo");
assert_eq!(smb_params.workgroup.as_deref().unwrap(), "testone"); assert_eq!(smb_params.workgroup.as_deref().unwrap(), "testone");
assert_eq!(smb_params.dialect, SmbDialect::Smb2);
} }
#[test] #[test]
@@ -644,6 +646,7 @@ mod tests {
smb: Some(SmbParams { smb: Some(SmbParams {
share: "test".to_string(), share: "test".to_string(),
workgroup: None, workgroup: None,
dialect: Some(SmbDialect::Smb2),
}), }),
}; };
@@ -660,5 +663,32 @@ mod tests {
let smb_params = params.params.smb_params().unwrap(); let smb_params = params.params.smb_params().unwrap();
assert_eq!(smb_params.address.as_str(), "localhost"); assert_eq!(smb_params.address.as_str(), "localhost");
assert_eq!(smb_params.share.as_str(), "test"); 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 serde::{Deserialize, Serialize};
use crate::filetransfer::params::SmbParams as TransferSmbParams; use crate::filetransfer::params::{SmbDialect, SmbParams as TransferSmbParams};
/// Extra Connection parameters for SMB protocol /// Extra Connection parameters for SMB protocol
#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Default)] #[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Default)]
@@ -13,6 +13,9 @@ pub struct SmbParams {
pub share: String, pub share: String,
/// Optional SMB workgroup used on POSIX platforms. /// Optional SMB workgroup used on POSIX platforms.
pub workgroup: Option<String>, pub workgroup: Option<String>,
/// Requested SMB protocol family. `None` (older bookmarks) means `Auto`.
#[serde(default)]
pub dialect: Option<SmbDialect>,
} }
#[cfg(posix)] #[cfg(posix)]
@@ -21,6 +24,7 @@ impl From<TransferSmbParams> for SmbParams {
Self { Self {
share: params.share, share: params.share,
workgroup: params.workgroup, workgroup: params.workgroup,
dialect: Some(params.dialect),
} }
} }
} }
@@ -31,6 +35,46 @@ impl From<TransferSmbParams> for SmbParams {
Self { Self {
share: params.share, share: params.share,
workgroup: None, 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::bookmarks::{Bookmark, KubeParams, S3Params, SmbParams, UserHosts};
use crate::config::params::UserConfig; use crate::config::params::UserConfig;
use crate::config::themes::Theme; 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; use crate::utils::test_helpers::create_file_ioers;
#[test] #[test]
@@ -445,6 +446,7 @@ mod tests {
assert_eq!(smb.share.as_str(), "temp"); assert_eq!(smb.share.as_str(), "temp");
#[cfg(posix)] #[cfg(posix)]
assert_eq!(smb.workgroup.as_deref().unwrap(), "test"); assert_eq!(smb.workgroup.as_deref().unwrap(), "test");
assert_eq!(smb.dialect, None);
} }
#[test] #[test]
@@ -491,6 +493,66 @@ mod tests {
assert_eq!(gcs.service_account_key, None); 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] #[test]
fn should_serialize_gcs_bookmark_fields() { fn should_serialize_gcs_bookmark_fields() {
let toml_file = create_good_toml_bookmarks(); let toml_file = create_good_toml_bookmarks();
@@ -630,6 +692,7 @@ mod tests {
let smb_params: Option<SmbParams> = Some(SmbParams { let smb_params: Option<SmbParams> = Some(SmbParams {
share: "test".to_string(), share: "test".to_string(),
workgroup: None, workgroup: None,
dialect: None,
}); });
bookmarks.insert( bookmarks.insert(
String::from("smb"), String::from("smb"),
@@ -789,6 +852,27 @@ mod tests {
tmpfile 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 { fn create_v14_pod_bookmark() -> tempfile::NamedTempFile {
let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap(); let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
let file_content: &str = r#" 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::aws_s3::AwsS3Params;
pub use self::google_cloud_storage::{DEFAULT_GCS_ENDPOINT, GoogleCloudStorageParams}; pub use self::google_cloud_storage::{DEFAULT_GCS_ENDPOINT, GoogleCloudStorageParams};
pub use self::kube::KubeProtocolParams; pub use self::kube::KubeProtocolParams;
pub use self::smb::SmbParams; pub use self::smb::{SmbDialect, SmbParams};
pub use self::webdav::WebDAVProtocolParams; pub use self::webdav::WebDAVProtocolParams;
use super::FileTransferProtocol; use super::FileTransferProtocol;
+92 -1
View File
@@ -3,6 +3,26 @@
//! Defines the runtime connection parameters used to build SMB remote //! Defines the runtime connection parameters used to build SMB remote
//! filesystem clients. //! 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 /// Connection parameters for SMB protocol
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SmbParams { pub struct SmbParams {
@@ -20,6 +40,8 @@ pub struct SmbParams {
#[cfg(posix)] #[cfg(posix)]
/// Optional workgroup used on POSIX platforms. /// Optional workgroup used on POSIX platforms.
pub workgroup: Option<String>, pub workgroup: Option<String>,
/// Requested SMB protocol family. Enforced on POSIX platforms only.
pub dialect: SmbDialect,
} }
// -- SMB params // -- SMB params
@@ -36,6 +58,7 @@ impl SmbParams {
password: None, password: None,
#[cfg(posix)] #[cfg(posix)]
workgroup: None, workgroup: None,
dialect: SmbDialect::default(),
} }
} }
@@ -61,6 +84,12 @@ impl SmbParams {
self 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. /// 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!!! /// The result true is returned ONLY if the supposed secret is MISSING!!!
pub fn password_missing(&self) -> bool { pub fn password_missing(&self) -> bool {
@@ -82,7 +111,8 @@ mod test {
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use super::*; use super::SmbParams;
use crate::filetransfer::params::SmbDialect;
#[test] #[test]
fn should_init_smb_params() { fn should_init_smb_params() {
@@ -118,6 +148,67 @@ mod test {
assert_eq!(params.workgroup.as_deref().unwrap(), "baz"); 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] #[test]
#[cfg(win)] #[cfg(win)]
fn should_init_smb_params_with_optionals() { fn should_init_smb_params_with_optionals() {
+60 -3
View File
@@ -11,16 +11,18 @@ use remotefs_ftp::FtpFs;
use remotefs_gcs::credentials::service_account; use remotefs_gcs::credentials::service_account;
use remotefs_gcs::{GoogleCloudStorageCredentials, GoogleCloudStorageFs}; use remotefs_gcs::{GoogleCloudStorageCredentials, GoogleCloudStorageFs};
use remotefs_kube::KubeMultiPodFs as KubeFs; use remotefs_kube::KubeMultiPodFs as KubeFs;
#[cfg(smb_unix)]
use remotefs_smb::SmbOptions;
#[cfg(smb)] #[cfg(smb)]
use remotefs_smb::{SmbCredentials, SmbFs}; use remotefs_smb::{SmbCredentials, SmbFs};
#[cfg(smb_unix)]
use remotefs_smb::{SmbDialect as RemoteSmbDialect, SmbOptions};
use remotefs_ssh::{ use remotefs_ssh::{
NoCheckServerKey, RusshSession as SshSession, ScpFs, SftpFs, SshAgentIdentity, NoCheckServerKey, RusshSession as SshSession, ScpFs, SftpFs, SshAgentIdentity,
SshConfigParseRule, SshOpts, SshConfigParseRule, SshOpts,
}; };
use remotefs_webdav::WebDAVFs; use remotefs_webdav::WebDAVFs;
#[cfg(smb_unix)]
use super::params::SmbDialect;
#[cfg(not(smb))] #[cfg(not(smb))]
use super::params::{AwsS3Params, GenericProtocolParams, GoogleCloudStorageParams}; use super::params::{AwsS3Params, GenericProtocolParams, GoogleCloudStorageParams};
#[cfg(smb)] #[cfg(smb)]
@@ -189,6 +191,17 @@ impl RemoteFsBuilder {
Ok(SftpFs::russh(opts, rt)) 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)] #[cfg(smb_unix)]
fn smb_client(params: SmbParams) -> Result<SmbFs, String> { fn smb_client(params: SmbParams) -> Result<SmbFs, String> {
let mut credentials = SmbCredentials::default() let mut credentials = SmbCredentials::default()
@@ -205,11 +218,14 @@ impl RemoteFsBuilder {
credentials = credentials.workgroup(workgroup); credentials = credentials.workgroup(workgroup);
} }
SmbFs::try_new( let (min_dialect, max_dialect) = Self::smb_dialect_bounds(params.dialect);
SmbFs::try_new_with_dialect(
credentials, credentials,
SmbOptions::default() SmbOptions::default()
.one_share_per_server(true) .one_share_per_server(true)
.case_sensitive(false), .case_sensitive(false),
min_dialect,
max_dialect,
) )
.map_err(|e| { .map_err(|e| {
error!("Invalid params for protocol SMB: {e}"); error!("Invalid params for protocol SMB: {e}");
@@ -228,6 +244,7 @@ impl RemoteFsBuilder {
credentials = credentials.password(password); credentials = credentials.password(password);
} }
// Dialect is OS-managed on Windows.
Ok(SmbFs::new(credentials)) Ok(SmbFs::new(credentials))
} }
@@ -284,6 +301,8 @@ mod test {
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
#[cfg(smb)]
use serial_test::serial;
use tempfile::TempDir; use tempfile::TempDir;
use super::*; use super::*;
@@ -417,12 +436,50 @@ mod test {
#[test] #[test]
#[cfg(smb)] #[cfg(smb)]
#[serial]
fn should_build_smb_fs() { fn should_build_smb_fs() {
let params = ProtocolParams::Smb(SmbParams::new("localhost", "share")); let params = ProtocolParams::Smb(SmbParams::new("localhost", "share"));
let config_client = get_config_client(); let config_client = get_config_client();
assert!(RemoteFsBuilder::build(FileTransferProtocol::Smb, params, &config_client).is_ok()); 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] #[test]
fn should_not_build_fs() { fn should_not_build_fs() {
let params = ProtocolParams::Generic( let params = ProtocolParams::Generic(
+8
View File
@@ -101,6 +101,10 @@ pub enum AuthFormId {
SmbShare, SmbShare,
#[cfg(posix)] #[cfg(posix)]
SmbWorkgroup, SmbWorkgroup,
#[cfg(posix)]
SmbDialect,
#[cfg(posix)]
SmbDialectWarning,
Username, Username,
WebDAVUri, WebDAVUri,
} }
@@ -209,6 +213,10 @@ pub enum UiAuthFormMsg {
SmbWorkgroupDown, SmbWorkgroupDown,
#[cfg(posix)] #[cfg(posix)]
SmbWorkgroupUp, SmbWorkgroupUp,
#[cfg(posix)]
SmbDialectBlurDown,
#[cfg(posix)]
SmbDialectBlurUp,
UsernameBlurDown, UsernameBlurDown,
UsernameBlurUp, UsernameBlurUp,
WebDAVUriBlurDown, WebDAVUriBlurDown,
+2
View File
@@ -315,6 +315,8 @@ impl AuthActivity {
self.mount_smb_share(form_tab, &params.share); self.mount_smb_share(form_tab, &params.share);
#[cfg(posix)] #[cfg(posix)]
self.mount_smb_workgroup(form_tab, params.workgroup.as_deref().unwrap_or("")); 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) { 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, BookmarkName, BookmarkSavePassword, BookmarksList, DeleteBookmarkPopup, DeleteRecentPopup,
RecentsList, RecentsList,
}; };
#[cfg(posix)]
pub use form::InputSmbWorkgroup;
pub use form::{ pub use form::{
HostBridgeProtocolRadio, InputAddress, InputGcsBucket, InputGcsEndpoint, HostBridgeProtocolRadio, InputAddress, InputGcsBucket, InputGcsEndpoint,
InputGcsServiceAccountKey, InputKubeClientCert, InputKubeClientKey, InputKubeClusterUrl, InputGcsServiceAccountKey, InputKubeClientCert, InputKubeClientKey, InputKubeClusterUrl,
@@ -23,6 +21,8 @@ pub use form::{
InputS3Region, InputS3SecretAccessKey, InputS3SecurityToken, InputS3SessionToken, InputS3Region, InputS3SecretAccessKey, InputS3SecurityToken, InputS3SessionToken,
InputSmbShare, InputUsername, InputWebDAVUri, RadioS3NewPathStyle, RemoteProtocolRadio, InputSmbShare, InputUsername, InputWebDAVUri, RadioS3NewPathStyle, RemoteProtocolRadio,
}; };
#[cfg(posix)]
pub use form::{InputSmbWorkgroup, RadioSmbDialect, SmbDialectWarning};
pub use popup::{ pub use popup::{
ErrorPopup, InfoPopup, InstallUpdatePopup, Keybindings, QuitPopup, ReleaseNotes, WaitPopup, ErrorPopup, InfoPopup, InstallUpdatePopup, Keybindings, QuitPopup, ReleaseNotes, WaitPopup,
WindowSizeError, WindowSizeError,
+1 -1
View File
@@ -54,7 +54,7 @@ pub use s3::{
}; };
pub use smb::InputSmbShare; pub use smb::InputSmbShare;
#[cfg(posix)] #[cfg(posix)]
pub use smb::InputSmbWorkgroup; pub use smb::{InputSmbWorkgroup, RadioSmbDialect, SmbDialectWarning};
pub use webdav::InputWebDAVUri; pub use webdav::InputWebDAVUri;
fn handle_input_ev( fn handle_input_ev(
@@ -1,7 +1,13 @@
#[cfg(posix)]
use tui_realm_stdlib::components::Span;
use tuirealm::component::{AppComponent, Component}; use tuirealm::component::{AppComponent, Component};
use tuirealm::event::NoUserEvent; use tuirealm::event::NoUserEvent;
#[cfg(posix)]
use tuirealm::props::SpanStatic;
use super::*; use super::*;
#[cfg(posix)]
use crate::filetransfer::params::SmbDialect;
#[derive(Component)] #[derive(Component)]
pub struct InputSmbShare { 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) 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
}
}
+20 -4
View File
@@ -358,7 +358,7 @@ impl AuthActivity {
InputMask::Localhost => unreachable!(), InputMask::Localhost => unreachable!(),
InputMask::Generic => Id::HostBridge(AuthFormId::Password), InputMask::Generic => Id::HostBridge(AuthFormId::Password),
#[cfg(posix)] #[cfg(posix)]
InputMask::Smb => Id::HostBridge(AuthFormId::SmbWorkgroup), InputMask::Smb => Id::HostBridge(AuthFormId::SmbDialect),
#[cfg(win)] #[cfg(win)]
InputMask::Smb => Id::HostBridge(AuthFormId::Password), InputMask::Smb => Id::HostBridge(AuthFormId::Password),
InputMask::Kube => Id::HostBridge(AuthFormId::KubeClientKey), InputMask::Kube => Id::HostBridge(AuthFormId::KubeClientKey),
@@ -483,12 +483,20 @@ impl AuthActivity {
} }
#[cfg(posix)] #[cfg(posix)]
UiAuthFormMsg::SmbWorkgroupDown => { UiAuthFormMsg::SmbWorkgroupDown => {
self.activate_component(Id::HostBridge(AuthFormId::RemoteDirectory)) self.activate_component(Id::HostBridge(AuthFormId::SmbDialect))
} }
#[cfg(posix)] #[cfg(posix)]
UiAuthFormMsg::SmbWorkgroupUp => { UiAuthFormMsg::SmbWorkgroupUp => {
self.activate_component(Id::HostBridge(AuthFormId::Password)) 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 => { UiAuthFormMsg::UsernameBlurDown => {
self.activate_component(Id::HostBridge(AuthFormId::Password)) self.activate_component(Id::HostBridge(AuthFormId::Password))
} }
@@ -601,7 +609,7 @@ impl AuthActivity {
InputMask::Localhost => unreachable!(), InputMask::Localhost => unreachable!(),
InputMask::Generic => Id::Remote(AuthFormId::Password), InputMask::Generic => Id::Remote(AuthFormId::Password),
#[cfg(posix)] #[cfg(posix)]
InputMask::Smb => Id::Remote(AuthFormId::SmbWorkgroup), InputMask::Smb => Id::Remote(AuthFormId::SmbDialect),
#[cfg(win)] #[cfg(win)]
InputMask::Smb => Id::Remote(AuthFormId::Password), InputMask::Smb => Id::Remote(AuthFormId::Password),
InputMask::Kube => Id::Remote(AuthFormId::KubeClientKey), InputMask::Kube => Id::Remote(AuthFormId::KubeClientKey),
@@ -726,12 +734,20 @@ impl AuthActivity {
} }
#[cfg(posix)] #[cfg(posix)]
UiAuthFormMsg::SmbWorkgroupDown => { UiAuthFormMsg::SmbWorkgroupDown => {
self.activate_component(Id::Remote(AuthFormId::RemoteDirectory)) self.activate_component(Id::Remote(AuthFormId::SmbDialect))
} }
#[cfg(posix)] #[cfg(posix)]
UiAuthFormMsg::SmbWorkgroupUp => { UiAuthFormMsg::SmbWorkgroupUp => {
self.activate_component(Id::Remote(AuthFormId::Password)) 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 => { UiAuthFormMsg::UsernameBlurDown => {
self.activate_component(Id::Remote(AuthFormId::Password)) self.activate_component(Id::Remote(AuthFormId::Password))
} }
+159 -61
View File
@@ -3,7 +3,7 @@
//! `auth_activity` is the module which implements the authentication activity //! `auth_activity` is the module which implements the authentication activity
use tuirealm::props::Color; 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::ratatui::widgets::Clear;
use tuirealm::terminal::TerminalAdapter; use tuirealm::terminal::TerminalAdapter;
@@ -12,6 +12,8 @@ use super::{
InputMask, components, InputMask, components,
}; };
use crate::filetransfer::params::DEFAULT_GCS_ENDPOINT; use crate::filetransfer::params::DEFAULT_GCS_ENDPOINT;
#[cfg(posix)]
use crate::filetransfer::params::SmbDialect;
use crate::utils::ui::{Popup, Size}; use crate::utils::ui::{Popup, Size};
#[path = "view/mounting.rs"] #[path = "view/mounting.rs"]
@@ -73,6 +75,10 @@ impl AuthActivity {
self.mount_smb_share(FormTab::HostBridge, ""); self.mount_smb_share(FormTab::HostBridge, "");
#[cfg(posix)] #[cfg(posix)]
self.mount_smb_workgroup(FormTab::HostBridge, ""); 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, ""); self.mount_webdav_uri(FormTab::HostBridge, "");
let remote_default_protocol = self.context().config().get_default_protocol(); let remote_default_protocol = self.context().config().get_default_protocol();
@@ -107,6 +113,10 @@ impl AuthActivity {
self.mount_smb_share(FormTab::Remote, ""); self.mount_smb_share(FormTab::Remote, "");
#[cfg(posix)] #[cfg(posix)]
self.mount_smb_workgroup(FormTab::Remote, ""); 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, ""); self.mount_webdav_uri(FormTab::Remote, "");
if let Some(version) = self if let Some(version) = self
@@ -247,8 +257,10 @@ impl AuthActivity {
f: &mut tuirealm::ratatui::Frame<'_>, f: &mut tuirealm::ratatui::Frame<'_>,
area: tuirealm::ratatui::layout::Rect, 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() 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) .direction(Direction::Vertical)
.split(area); .split(area);
@@ -258,36 +270,25 @@ impl AuthActivity {
protocol_and_mask_chunks[0], protocol_and_mask_chunks[0],
); );
let input_mask = Layout::default() let view_ids = match input_mask {
.constraints( InputMask::AwsS3 => self.get_host_bridge_s3_view(),
[ InputMask::Gcs => self.get_host_bridge_gcs_view(),
Constraint::Length(3), InputMask::Generic => self.get_host_bridge_generic_params_view(),
Constraint::Length(3), InputMask::Kube => self.get_host_bridge_kube_view(),
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())
}
InputMask::Localhost => { InputMask::Localhost => {
let view_ids = self.get_host_bridge_localhost_view(); 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( fn render_remote_input_mask(
@@ -295,8 +296,10 @@ impl AuthActivity {
f: &mut tuirealm::ratatui::Frame<'_>, f: &mut tuirealm::ratatui::Frame<'_>,
area: tuirealm::ratatui::layout::Rect, 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() 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) .direction(Direction::Vertical)
.split(area); .split(area);
@@ -306,40 +309,135 @@ impl AuthActivity {
protocol_and_mask_chunks[0], protocol_and_mask_chunks[0],
); );
let input_mask = Layout::default() let view_ids = match input_mask {
.constraints( InputMask::AwsS3 => self.get_remote_s3_view(),
[ InputMask::Gcs => self.get_remote_gcs_view(),
Constraint::Length(3), InputMask::Generic => self.get_remote_generic_params_view(),
Constraint::Length(3), InputMask::Kube => self.get_remote_kube_view(),
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()),
InputMask::Localhost => unreachable!(), InputMask::Localhost => unreachable!(),
InputMask::Smb => self.render_view_ids(f, input_mask, self.get_remote_smb_view()), InputMask::Smb => self.get_remote_smb_view(),
InputMask::WebDAV => self.render_view_ids(f, input_mask, self.get_remote_webdav_view()), InputMask::WebDAV => self.get_remote_webdav_view(),
};
self.render_form_rows(f, protocol_and_mask_chunks[1], FormTab::Remote, 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<'_>,
area: Rect,
form_tab: FormTab,
view_ids: [Id; 4],
) {
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);
}
} }
} }
fn render_view_ids( #[cfg(test)]
&mut self, mod test {
f: &mut tuirealm::ratatui::Frame<'_>, use pretty_assertions::assert_eq;
input_mask: std::rc::Rc<[tuirealm::ratatui::layout::Rect]>, use tuirealm::ratatui::layout::Rect;
view_ids: [Id; 4],
) { use super::AuthActivity;
self.app.view(&view_ids[0], f, input_mask[0]);
self.app.view(&view_ids[1], f, input_mask[1]); #[test]
self.app.view(&view_ids[2], f, input_mask[2]); fn should_split_input_mask_without_warning() {
self.app.view(&view_ids[3], f, input_mask[3]); 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));
} }
} }
+32
View File
@@ -1,4 +1,6 @@
use super::*; use super::*;
#[cfg(posix)]
use crate::filetransfer::params::SmbDialect;
use crate::ui::activities::auth::STORE_KEY_RELEASE_NOTES; use crate::ui::activities::auth::STORE_KEY_RELEASE_NOTES;
impl AuthActivity { impl AuthActivity {
@@ -705,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( pub(in crate::ui::activities::auth) fn mount_webdav_uri(
&mut self, &mut self,
form_tab: FormTab, form_tab: FormTab,
+23 -1
View File
@@ -5,10 +5,14 @@ use tuirealm::state::{State, StateValue};
use super::*; use super::*;
use crate::filetransfer::FileTransferParams; use crate::filetransfer::FileTransferParams;
#[cfg(posix)]
use crate::filetransfer::params::SmbDialect;
use crate::filetransfer::params::{ use crate::filetransfer::params::{
AwsS3Params, GenericProtocolParams, GoogleCloudStorageParams, KubeProtocolParams, AwsS3Params, GenericProtocolParams, GoogleCloudStorageParams, KubeProtocolParams,
ProtocolParams, SmbParams, WebDAVProtocolParams, ProtocolParams, SmbParams, WebDAVProtocolParams,
}; };
#[cfg(posix)]
use crate::ui::activities::auth::components::RadioSmbDialect;
impl AuthActivity { impl AuthActivity {
pub(in crate::ui::activities::auth) fn get_generic_params_input( pub(in crate::ui::activities::auth) fn get_generic_params_input(
@@ -82,6 +86,7 @@ impl AuthActivity {
) -> SmbParams { ) -> SmbParams {
let share = self.get_input_smb_share(form_tab); let share = self.get_input_smb_share(form_tab);
let workgroup = self.get_input_smb_workgroup(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 address = self.get_input_addr(form_tab);
let port = self.get_input_port(form_tab); let port = self.get_input_port(form_tab);
let username = self.get_input_username(form_tab); let username = self.get_input_username(form_tab);
@@ -92,6 +97,7 @@ impl AuthActivity {
.username(username) .username(username)
.password(password) .password(password)
.workgroup(workgroup) .workgroup(workgroup)
.dialect(dialect)
} }
#[cfg(win)] #[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) { pub(in crate::ui::activities::auth) fn get_new_bookmark(&self) -> (String, bool) {
let name = match self.app.state(&Id::BookmarkName) { let name = match self.app.state(&Id::BookmarkName) {
Ok(State::Single(StateValue::String(name))) => name, Ok(State::Single(StateValue::String(name))) => name,
@@ -473,8 +493,10 @@ impl AuthActivity {
+ 3 + 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 { match input_mask {
// One extra line for the SMB1 warning above the dialect radio.
InputMask::Smb if cfg!(posix) => 13,
InputMask::AwsS3 InputMask::AwsS3
| InputMask::Gcs | InputMask::Gcs
| InputMask::Generic | InputMask::Generic
+16 -4
View File
@@ -289,15 +289,21 @@ impl AuthActivity {
Id::HostBridge(AuthFormId::Password), Id::HostBridge(AuthFormId::Password),
Id::HostBridge(AuthFormId::SmbWorkgroup), Id::HostBridge(AuthFormId::SmbWorkgroup),
], ],
Some(&Id::HostBridge(AuthFormId::RemoteDirectory)) => [ Some(&Id::HostBridge(AuthFormId::SmbDialect)) => [
Id::HostBridge(AuthFormId::Username), Id::HostBridge(AuthFormId::Username),
Id::HostBridge(AuthFormId::Password), Id::HostBridge(AuthFormId::Password),
Id::HostBridge(AuthFormId::SmbWorkgroup), 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), Id::HostBridge(AuthFormId::RemoteDirectory),
], ],
Some(&Id::HostBridge(AuthFormId::LocalDirectory)) => [ Some(&Id::HostBridge(AuthFormId::LocalDirectory)) => [
Id::HostBridge(AuthFormId::Password),
Id::HostBridge(AuthFormId::SmbWorkgroup), Id::HostBridge(AuthFormId::SmbWorkgroup),
Id::HostBridge(AuthFormId::SmbDialect),
Id::HostBridge(AuthFormId::RemoteDirectory), Id::HostBridge(AuthFormId::RemoteDirectory),
Id::HostBridge(AuthFormId::LocalDirectory), Id::HostBridge(AuthFormId::LocalDirectory),
], ],
@@ -336,15 +342,21 @@ impl AuthActivity {
Id::Remote(AuthFormId::Password), Id::Remote(AuthFormId::Password),
Id::Remote(AuthFormId::SmbWorkgroup), Id::Remote(AuthFormId::SmbWorkgroup),
], ],
Some(&Id::Remote(AuthFormId::RemoteDirectory)) => [ Some(&Id::Remote(AuthFormId::SmbDialect)) => [
Id::Remote(AuthFormId::Username), Id::Remote(AuthFormId::Username),
Id::Remote(AuthFormId::Password), Id::Remote(AuthFormId::Password),
Id::Remote(AuthFormId::SmbWorkgroup), 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), Id::Remote(AuthFormId::RemoteDirectory),
], ],
Some(&Id::Remote(AuthFormId::LocalDirectory)) => [ Some(&Id::Remote(AuthFormId::LocalDirectory)) => [
Id::Remote(AuthFormId::Password),
Id::Remote(AuthFormId::SmbWorkgroup), Id::Remote(AuthFormId::SmbWorkgroup),
Id::Remote(AuthFormId::SmbDialect),
Id::Remote(AuthFormId::RemoteDirectory), Id::Remote(AuthFormId::RemoteDirectory),
Id::Remote(AuthFormId::LocalDirectory), Id::Remote(AuthFormId::LocalDirectory),
], ],