Skip to content

Commit 64e41f9

Browse files
committed
Linux: smbclient fallback + SMB login form fixes
- Add smbclient -L fallback for Linux when smb-rs fails (mirrors macOS smbutil fallback, needs samba-client pkg) - Fix login form losing focus on every click (click event bubbled to pane's focus handler) - Allow empty passwords in SMB login (valid for many servers) - Fix credential storage for empty-password connections
1 parent afe2609 commit 64e41f9

9 files changed

Lines changed: 368 additions & 28 deletions

File tree

apps/desktop/src-tauri/src/network/CLAUDE.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ Discover, browse, and mount SMB network shares. Works on macOS and Linux.
99
- `smb_client.rs` — Top-level share-listing entry point; orchestrates guest -> keychain -> prompt auth flow; tries smb-rs first, falls back to smbutil (macOS only)
1010
- `smb_connection.rs` — TCP connection establishment and IPC-level share listing calls
1111
- `smb_cache.rs` — 30-second in-memory cache for share lists, keyed by server address
12-
- `smb_smbutil.rs``smbutil view -G` fallback for older Samba/NAS servers (macOS only; non-macOS stubs return errors)
12+
- `smb_smbutil.rs``smbutil view -G` fallback for older Samba/NAS servers (macOS); on Linux delegates to `smb_smbclient`
13+
- `smb_smbclient.rs``smbclient -L` fallback for Linux (requires `samba-client` package)
1314
- `smb_types.rs` — Shared types (`SmbShare`, `AuthMode`, `SmbError`, etc.)
1415
- `smb_util.rs` — Helpers: hostname derivation, IP resolution, account-name normalization
1516
- **Mounting** (platform-specific via `#[path]` in `mod.rs`):
@@ -26,7 +27,7 @@ Discover, browse, and mount SMB network shares. Works on macOS and Linux.
2627
|-----------|-------|-------|
2728
| mDNS discovery | `mdns-sd` (pure Rust) | `mdns-sd` (same) |
2829
| SMB share listing | `smb` + `smb-rpc` crates | `smb` + `smb-rpc` (same) |
29-
| smbutil fallback | `smbutil view -G` | Not available (returns error, smb-rs handles most cases) |
30+
| smbutil fallback | `smbutil view -G` | `smbclient -L` (from `samba-client` package) |
3031
| Credential storage | `security-framework` (macOS Keychain) | `keyring` (Secret Service) → `cocoon` encrypted file fallback |
3132
| Mounting | `NetFSMountURLSync``/Volumes/` | `gio mount``/run/user/<uid>/gvfs/` |
3233

@@ -43,9 +44,12 @@ smb-rs doesn't resolve `.local` hostnames reliably (std lib DNS doesn't handle m
4344
3. If no stored creds → prompt user
4445
4. Never assume "guest only" — always offer "Sign in for more access" when guest succeeds (can't distinguish guest-only from guest-or-creds at probe time)
4546

46-
### smbutil fallback (macOS only)
47+
### smbutil / smbclient fallback
4748

48-
`smb` crate fails on older Samba servers with RPC incompatibility. Classify error as `ProtocolError`, then try `smbutil view -G` as fallback. On Linux, the non-macOS stubs return `ProtocolError` so the error propagates to the user.
49+
`smb` crate fails on older Samba servers (for example, Raspberry Pi) with RPC incompatibility. Classify error as `ProtocolError`, then try a platform-specific CLI fallback:
50+
- **macOS:** `smbutil view -G` (built-in).
51+
- **Linux:** `smbclient -L` (from `samba-client` package). If `smbclient` is not installed, returns a helpful error message. The `smb_smbutil.rs` Linux stubs delegate to `smb_smbclient.rs`.
52+
- **Other platforms:** stubs return `ProtocolError`.
4953

5054
### No persistent connection pool
5155

apps/desktop/src-tauri/src/network/keychain_linux.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,9 @@ pub fn save_credentials(
285285
cache_put(&account, &creds);
286286
return Ok(());
287287
}
288-
_ => debug!("Secret service save appeared to succeed but read-back failed (keyring likely locked), trying file backend"),
288+
_ => debug!(
289+
"Secret service save appeared to succeed but read-back failed (keyring likely locked), trying file backend"
290+
),
289291
},
290292
Err(e) => debug!("Secret service save failed, trying file backend: {}", e),
291293
}

apps/desktop/src-tauri/src/network/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ pub mod smb_client;
3131
// SMB submodules - these are implementation details of smb_client
3232
mod smb_cache;
3333
mod smb_connection;
34+
#[cfg(target_os = "linux")]
35+
mod smb_smbclient;
3436
mod smb_smbutil;
3537
mod smb_types;
3638
mod smb_util;
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
//! Linux smbclient wrapper for SMB share listing.
2+
//!
3+
//! Provides fallback share listing using the `smbclient` command (from the `samba-client`
4+
//! package) when the pure Rust smb-rs implementation fails on Linux. This is the Linux
5+
//! equivalent of the macOS `smbutil` fallback in `smb_smbutil.rs`.
6+
7+
use crate::network::smb_types::{ShareInfo, ShareListError};
8+
use log::debug;
9+
use std::process::Command;
10+
11+
/// Lists shares using `smbclient -L` and returns parsed disk shares.
12+
///
13+
/// Guest mode: pass `credentials: None` → uses `-N` (no password).
14+
/// Authenticated: pass `credentials: Some((user, pass))` → uses `-U user%pass`.
15+
pub async fn run_smbclient_list(
16+
host: &str,
17+
port: u16,
18+
credentials: Option<(&str, &str)>,
19+
) -> Result<Vec<ShareInfo>, ShareListError> {
20+
let server = format!("//{}", host);
21+
let port_str = port.to_string();
22+
let creds_owned = credentials.map(|(u, p)| (u.to_string(), p.to_string()));
23+
24+
let output = tokio::task::spawn_blocking(move || {
25+
let mut cmd = Command::new("smbclient");
26+
cmd.arg("-L").arg(&server);
27+
if port_str != "445" {
28+
cmd.arg("-p").arg(&port_str);
29+
}
30+
match &creds_owned {
31+
Some((username, password)) => {
32+
cmd.arg("-U").arg(format!("{}%{}", username, password));
33+
}
34+
None => {
35+
cmd.arg("-N");
36+
}
37+
}
38+
cmd.output()
39+
})
40+
.await
41+
.map_err(|e| ShareListError::ProtocolError(format!("Failed to spawn smbclient task: {}", e)))?
42+
.map_err(|e| {
43+
if e.kind() == std::io::ErrorKind::NotFound {
44+
ShareListError::ProtocolError(
45+
"smbclient not found. Install the samba-client package to connect to this server.".to_string(),
46+
)
47+
} else {
48+
ShareListError::ProtocolError(format!("Failed to run smbclient: {}", e))
49+
}
50+
})?;
51+
52+
let stdout = String::from_utf8_lossy(&output.stdout);
53+
let stderr = String::from_utf8_lossy(&output.stderr);
54+
debug!(
55+
"smbclient exit={:?}, stdout_len={}, stderr_len={}",
56+
output.status.code(),
57+
stdout.len(),
58+
stderr.len()
59+
);
60+
61+
if !output.status.success() {
62+
debug!("smbclient stderr: {}", stderr);
63+
return Err(classify_smbclient_error(&stdout, &stderr, host, credentials.is_some()));
64+
}
65+
66+
Ok(parse_smbclient_output(&stdout))
67+
}
68+
69+
/// Classifies smbclient error output into a typed error.
70+
fn classify_smbclient_error(stdout: &str, stderr: &str, host: &str, has_creds: bool) -> ShareListError {
71+
let combined = format!("{} {}", stderr, stdout);
72+
73+
if combined.contains("NT_STATUS_ACCESS_DENIED")
74+
|| combined.contains("NT_STATUS_LOGON_FAILURE")
75+
|| combined.contains("NT_STATUS_WRONG_PASSWORD")
76+
{
77+
return if has_creds {
78+
ShareListError::AuthFailed("Invalid username or password".to_string())
79+
} else {
80+
ShareListError::AuthRequired("This server requires authentication".to_string())
81+
};
82+
}
83+
84+
if combined.contains("NT_STATUS_HOST_UNREACHABLE")
85+
|| combined.contains("NT_STATUS_CONNECTION_REFUSED")
86+
|| (combined.contains("Connection to") && combined.contains("failed"))
87+
{
88+
return ShareListError::HostUnreachable(format!("Cannot reach {}", host));
89+
}
90+
91+
if combined.contains("NT_STATUS_IO_TIMEOUT") {
92+
return ShareListError::Timeout(format!("Connection to {} timed out", host));
93+
}
94+
95+
ShareListError::ProtocolError(format!("smbclient failed: {}", stderr.trim()))
96+
}
97+
98+
/// Parses `smbclient -L` output to extract share information.
99+
///
100+
/// Example output:
101+
/// ```text
102+
/// Sharename Type Comment
103+
/// --------- ---- -------
104+
/// Public Disk System default share
105+
/// Documents Disk
106+
/// IPC$ IPC IPC Service (NAS Server)
107+
///
108+
/// SMB1 disabled -- no workgroup available
109+
/// ```
110+
pub fn parse_smbclient_output(output: &str) -> Vec<ShareInfo> {
111+
let mut shares = Vec::new();
112+
let mut in_shares_section = false;
113+
114+
for line in output.lines() {
115+
let trimmed = line.trim();
116+
117+
if trimmed.starts_with("Sharename") && trimmed.contains("Type") {
118+
in_shares_section = true;
119+
continue;
120+
}
121+
122+
if trimmed.starts_with("---") {
123+
continue;
124+
}
125+
126+
if !in_shares_section || trimmed.is_empty() {
127+
continue;
128+
}
129+
130+
let parts: Vec<&str> = trimmed.split_whitespace().collect();
131+
if parts.len() < 2 {
132+
break;
133+
}
134+
135+
let name = parts[0].to_string();
136+
let share_type = parts[1].to_lowercase();
137+
138+
// If not a known share type, we've left the share section
139+
if share_type != "disk" && share_type != "ipc" && share_type != "printer" {
140+
break;
141+
}
142+
143+
// Skip hidden shares (ending with $) and non-disk shares
144+
if name.ends_with('$') || share_type != "disk" {
145+
continue;
146+
}
147+
148+
let comment = if parts.len() > 2 {
149+
Some(parts[2..].join(" "))
150+
} else {
151+
None
152+
};
153+
154+
shares.push(ShareInfo {
155+
name,
156+
is_disk: true,
157+
comment,
158+
});
159+
}
160+
161+
shares
162+
}
163+
164+
#[cfg(test)]
165+
mod tests {
166+
use super::*;
167+
168+
#[test]
169+
fn test_parse_basic() {
170+
let output = "\tSharename Type Comment\n\
171+
\t--------- ---- -------\n\
172+
\tPublic Disk System default share\n\
173+
\tWeb Disk\n\
174+
\tMultimedia Disk System default share\n\
175+
\tIPC$ IPC IPC Service (NAS Server)\n\
176+
\thome Disk Home\n\
177+
\tADMIN$ Disk Admin share\n\
178+
\n\
179+
SMB1 disabled -- no workgroup available\n";
180+
181+
let shares = parse_smbclient_output(output);
182+
183+
assert_eq!(shares.len(), 4);
184+
let names: Vec<&str> = shares.iter().map(|s| s.name.as_str()).collect();
185+
assert!(names.contains(&"Public"));
186+
assert!(names.contains(&"Web"));
187+
assert!(names.contains(&"Multimedia"));
188+
assert!(names.contains(&"home"));
189+
assert!(!names.contains(&"IPC$"));
190+
assert!(!names.contains(&"ADMIN$"));
191+
assert!(shares.iter().all(|s| s.is_disk));
192+
193+
let public = shares.iter().find(|s| s.name == "Public").unwrap();
194+
assert_eq!(public.comment.as_deref(), Some("System default share"));
195+
196+
let web = shares.iter().find(|s| s.name == "Web").unwrap();
197+
assert!(web.comment.is_none());
198+
}
199+
200+
#[test]
201+
fn test_parse_empty() {
202+
let output = "\tSharename Type Comment\n\
203+
\t--------- ---- -------\n\
204+
\n\
205+
SMB1 disabled -- no workgroup available\n";
206+
207+
let shares = parse_smbclient_output(output);
208+
assert!(shares.is_empty());
209+
}
210+
211+
#[test]
212+
fn test_parse_with_printer() {
213+
let output = "\tSharename Type Comment\n\
214+
\t--------- ---- -------\n\
215+
\tDocuments Disk My docs\n\
216+
\tprint$ Printer Printer Drivers\n\
217+
\tIPC$ IPC IPC Service\n";
218+
219+
let shares = parse_smbclient_output(output);
220+
assert_eq!(shares.len(), 1);
221+
assert_eq!(shares[0].name, "Documents");
222+
}
223+
224+
#[test]
225+
fn test_parse_raspberry_pi() {
226+
let output = "\tSharename Type Comment\n\
227+
\t--------- ---- -------\n\
228+
\tpi Disk Pi shared folder\n\
229+
\tIPC$ IPC IPC Service (Samba 4.13.13-Debian)\n";
230+
231+
let shares = parse_smbclient_output(output);
232+
assert_eq!(shares.len(), 1);
233+
assert_eq!(shares[0].name, "pi");
234+
assert_eq!(shares[0].comment.as_deref(), Some("Pi shared folder"));
235+
}
236+
237+
#[test]
238+
fn test_classify_auth_errors() {
239+
let err = classify_smbclient_error("", "NT_STATUS_ACCESS_DENIED", "host", false);
240+
assert!(matches!(err, ShareListError::AuthRequired(_)));
241+
242+
let err = classify_smbclient_error("", "NT_STATUS_LOGON_FAILURE", "host", true);
243+
assert!(matches!(err, ShareListError::AuthFailed(_)));
244+
}
245+
246+
#[test]
247+
fn test_classify_network_errors() {
248+
let err = classify_smbclient_error("", "NT_STATUS_HOST_UNREACHABLE", "host", false);
249+
assert!(matches!(err, ShareListError::HostUnreachable(_)));
250+
251+
let err = classify_smbclient_error("", "Connection to host failed", "host", false);
252+
assert!(matches!(err, ShareListError::HostUnreachable(_)));
253+
254+
let err = classify_smbclient_error("", "NT_STATUS_IO_TIMEOUT", "host", false);
255+
assert!(matches!(err, ShareListError::Timeout(_)));
256+
}
257+
}

0 commit comments

Comments
 (0)