diff --git a/Cargo.lock b/Cargo.lock index d82bcaf..33f1a65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2121,9 +2121,9 @@ dependencies = [ [[package]] name = "git2" -version = "0.20.4" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" dependencies = [ "bitflags 2.13.1", "libc", @@ -3405,7 +3405,6 @@ checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50" dependencies = [ "cc", "libc", - "libssh2-sys", "libz-sys", "openssl-sys", "pkg-config", @@ -3426,20 +3425,6 @@ dependencies = [ "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]] name = "libz-sys" version = "1.1.29" @@ -4135,9 +4120,9 @@ checksum = "1e91099d4268b0e11973f036e885d652fb0b21fedcf69738c627f94db6a44f42" [[package]] name = "pavao" -version = "0.2.16" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cca8302d5f1b5dc7a8dfb04e5ea7185b43571db5811e084746bf0aeb0d7b18f1" +checksum = "6bf4bff78f032402eb6b89a72801f8d4b5ef57e79d33139f6e4fb214e823a854" dependencies = [ "cfg_aliases", "lazy_static", @@ -4149,9 +4134,9 @@ dependencies = [ [[package]] name = "pavao-src" -version = "4.22.0-4" +version = "4.22.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b8c63418f1e7dfbb786268ae0927537b87d90b1211216c1ba50ce25d8d9495" +checksum = "48289686720a0215553faa63f3211f0faa57c698287fb1ac41d3fbd423ed1f12" dependencies = [ "cc", "git2", @@ -4160,9 +4145,9 @@ dependencies = [ [[package]] name = "pavao-sys" -version = "0.2.16" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07b390d8c7372e94c7e0f7d92e36c98404634c554a52b1cb9ae030b4e7888cde" +checksum = "8c6a44c550ef244c0a19f0a2201687af1853b0b9d1cfaa989a9312942c676adf" dependencies = [ "cc", "libc", @@ -4923,16 +4908,16 @@ dependencies = [ [[package]] name = "remotefs-smb" -version = "0.3.1" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c077c88aecf906b0267def951277dc4f6fd1f368edb73d333771a24c0b47970a" +checksum = "c5e5b1fc4afd54e729393b3b44b537583c8e815d0c8e41483799001084e5fe96" dependencies = [ "filetime", "libc", "log", "pavao", "remotefs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b715b90..d266d4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,7 +62,7 @@ remotefs = "0.3" remotefs-aws-s3 = "0.4" remotefs-gcs = "0.1" 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-webdav = "0.2" rpassword = "7" diff --git a/docs/en-US/getting-started/connection-parameters.md b/docs/en-US/getting-started/connection-parameters.md index 0dfe8c5..dcce9a1 100644 --- a/docs/en-US/getting-started/connection-parameters.md +++ b/docs/en-US/getting-started/connection-parameters.md @@ -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: diff --git a/docs/zh-CN/getting-started/connection-parameters.md b/docs/zh-CN/getting-started/connection-parameters.md index b2d451e..d6717ab 100644 --- a/docs/zh-CN/getting-started/connection-parameters.md +++ b/docs/zh-CN/getting-started/connection-parameters.md @@ -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 地址语法: diff --git a/src/config/bookmarks.rs b/src/config/bookmarks.rs index b42696d..d483187 100644 --- a/src/config/bookmarks.rs +++ b/src/config/bookmarks.rs @@ -191,25 +191,25 @@ impl From 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 + ); } } diff --git a/src/config/bookmarks/smb.rs b/src/config/bookmarks/smb.rs index 2c79fa6..1aaf2ed 100644 --- a/src/config/bookmarks/smb.rs +++ b/src/config/bookmarks/smb.rs @@ -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, + /// Requested SMB protocol family. `None` (older bookmarks) means `Auto`. + #[serde(default)] + pub dialect: Option, } #[cfg(posix)] @@ -21,6 +24,7 @@ impl From for SmbParams { Self { share: params.share, workgroup: params.workgroup, + dialect: Some(params.dialect), } } } @@ -31,6 +35,46 @@ impl From 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(¶ms).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)); + } +} diff --git a/src/config/serialization.rs b/src/config/serialization.rs index b8e8e6f..7336454 100644 --- a/src/config/serialization.rs +++ b/src/config/serialization.rs @@ -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 = 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#" diff --git a/src/filetransfer/params.rs b/src/filetransfer/params.rs index 88ec871..816f94b 100644 --- a/src/filetransfer/params.rs +++ b/src/filetransfer/params.rs @@ -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; diff --git a/src/filetransfer/params/smb.rs b/src/filetransfer/params/smb.rs index 234ae21..2bad045 100644 --- a/src/filetransfer/params/smb.rs +++ b/src/filetransfer/params/smb.rs @@ -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, + /// 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::("dialect = \"SMB3\"").is_err()); + } + + #[derive(serde::Serialize, serde::Deserialize)] + struct Wrapper { + dialect: SmbDialect, + } + #[test] #[cfg(win)] fn should_init_smb_params_with_optionals() { diff --git a/src/filetransfer/remotefs_builder.rs b/src/filetransfer/remotefs_builder.rs index b564add..f102d81 100644 --- a/src/filetransfer/remotefs_builder.rs +++ b/src/filetransfer/remotefs_builder.rs @@ -11,16 +11,18 @@ 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}; +#[cfg(smb_unix)] +use remotefs_smb::{SmbDialect as RemoteSmbDialect, SmbOptions}; 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)] @@ -189,6 +191,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 { let mut credentials = SmbCredentials::default() @@ -205,11 +218,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}"); @@ -228,6 +244,7 @@ impl RemoteFsBuilder { credentials = credentials.password(password); } + // Dialect is OS-managed on Windows. Ok(SmbFs::new(credentials)) } @@ -284,6 +301,8 @@ mod test { use std::path::{Path, PathBuf}; + #[cfg(smb)] + use serial_test::serial; use tempfile::TempDir; use super::*; @@ -417,12 +436,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( diff --git a/src/ui/activities/auth.rs b/src/ui/activities/auth.rs index 0f04cfa..9ae2109 100644 --- a/src/ui/activities/auth.rs +++ b/src/ui/activities/auth.rs @@ -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, diff --git a/src/ui/activities/auth/bookmarks.rs b/src/ui/activities/auth/bookmarks.rs index ba1d634..7d5c851 100644 --- a/src/ui/activities/auth/bookmarks.rs +++ b/src/ui/activities/auth/bookmarks.rs @@ -315,6 +315,8 @@ impl AuthActivity { self.mount_smb_share(form_tab, ¶ms.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) { diff --git a/src/ui/activities/auth/components.rs b/src/ui/activities/auth/components.rs index bd9ced4..051bdc1 100644 --- a/src/ui/activities/auth/components.rs +++ b/src/ui/activities/auth/components.rs @@ -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, diff --git a/src/ui/activities/auth/components/form.rs b/src/ui/activities/auth/components/form.rs index f84b31d..e7a10ab 100644 --- a/src/ui/activities/auth/components/form.rs +++ b/src/ui/activities/auth/components/form.rs @@ -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( diff --git a/src/ui/activities/auth/components/form/smb.rs b/src/ui/activities/auth/components/form/smb.rs index 191d416..4a3f5fe 100644 --- a/src/ui/activities/auth/components/form/smb.rs +++ b/src/ui/activities/auth/components/form/smb.rs @@ -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 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 for RadioSmbDialect { + fn on(&mut self, ev: &Event) -> Option { + 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 for SmbDialectWarning { + fn on(&mut self, _ev: &Event) -> Option { + None + } +} diff --git a/src/ui/activities/auth/update.rs b/src/ui/activities/auth/update.rs index c2ba759..4b811e1 100644 --- a/src/ui/activities/auth/update.rs +++ b/src/ui/activities/auth/update.rs @@ -358,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), @@ -483,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)) } @@ -601,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), @@ -726,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)) } diff --git a/src/ui/activities/auth/view.rs b/src/ui/activities/auth/view.rs index 3ca1ed7..c37431f 100644 --- a/src/ui/activities/auth/view.rs +++ b/src/ui/activities/auth/view.rs @@ -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) -> ([Rect; 4], Option) { + 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 { + 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 { + 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)); } } diff --git a/src/ui/activities/auth/view/mounting.rs b/src/ui/activities/auth/view/mounting.rs index 2a888cb..a914f60 100644 --- a/src/ui/activities/auth/view/mounting.rs +++ b/src/ui/activities/auth/view/mounting.rs @@ -1,4 +1,6 @@ use super::*; +#[cfg(posix)] +use crate::filetransfer::params::SmbDialect; use crate::ui::activities::auth::STORE_KEY_RELEASE_NOTES; 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( &mut self, form_tab: FormTab, diff --git a/src/ui/activities/auth/view/query.rs b/src/ui/activities/auth/view/query.rs index b03b620..866f462 100644 --- a/src/ui/activities/auth/view/query.rs +++ b/src/ui/activities/auth/view/query.rs @@ -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 diff --git a/src/ui/activities/auth/view/visible.rs b/src/ui/activities/auth/view/visible.rs index 0e9d776..fdb1c6a 100644 --- a/src/ui/activities/auth/view/visible.rs +++ b/src/ui/activities/auth/view/visible.rs @@ -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), ],