Skip to content

Commit 0e1bc77

Browse files
committed
SMB: Fix dead-end login flow when mounting shares
- Always set NetFS `UIOption=NoUI` so macOS never shows its own auth dialogs; auth failures come back as typed errors instead. Map the NetAuth codes (`-6600` → auth failed, `-6004` → auth required, `-6003` → share not found). - Auth-class mount failures now render the login form (error inline, username pre-filled) instead of the dead-end error pane; submitting retries the mount with the entered creds and saves them to the Keychain on success. - Activating a share when creds are required and none are held opens the login form first instead of firing a doomed guest mount (happens when the share listing succeeded via the system Keychain through `smbutil`, which Cmdr can't reuse). - Compare servers by identity (mDNS service name ↔ `.local` hostname ↔ IP, via the new `network/server_identity.rs`) in mount-path disambiguation, and return `already_mounted` early when the same server+share+port is already mounted, so one NAS can't be treated as two and forced into a second doomed session. - New tests pin all of the above; incident analysis and the P1/P2 redesign proposal live in `docs/notes/smb-auth-flow-redesign.md`.
1 parent 8019606 commit 0e1bc77

11 files changed

Lines changed: 1136 additions & 61 deletions

File tree

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Frontend counterpart: [`apps/desktop/src/lib/file-explorer/network/CLAUDE.md`](.
2222
- **Mounting** (platform-specific via `#[path]` in `mod.rs`):
2323
- `mount.rs`: macOS `NetFSMountURLSync` for native `/Volumes/` mounts; also `unmount_smb_shares_from_host` (iterates `/Volumes/`, matches via `statfs`, unmounts via `diskutil`)
2424
- `mount_linux.rs`: Linux `gio mount` for GVFS-based user-space mounts
25+
- **Server identity**: `server_identity.rs`: `same_server` / `same_server_live` equivalence over the names a server goes by (mDNS service name, `.local` hostname, IP), enriched from the discovery state. Used by the mount-path disambiguation and the already-mounted short-circuit so string-shape differences can't split one server into two.
2526
- **Auth** (platform-agnostic):
2627
- `keychain.rs`: SMB credential management. Delegates storage to `crate::secrets::store()` (see `secrets/CLAUDE.md` for backend details)
2728
- **State**: `known_shares.rs`: Connection history in `known-shares.json` (usernames, last auth mode, timestamps).
@@ -81,6 +82,13 @@ guest/credentials toggle. `keychain.rs` delegates to `crate::secrets::store()` f
8182
(macOS Keychain, Linux Secret Service, encrypted file fallback). Passwords never stored in our settings file.
8283
`CMDR_SECRET_STORE=file` forces the plain file backend in dev mode (set by `tauri-wrapper.js`).
8384

85+
To make this hold, every NetFS mount sets `UIOption = NoUI` (`open_option_entries` in `mount.rs`). Without it, NetFS
86+
hands auth *failures* to NetAuthAgent even when we pass explicit credentials: the agent pops a system dialog ("You
87+
entered an invalid username or password...") on top of Cmdr, blocks the mount call while open, and returns
88+
`kNetAuthErrorInternal` (-6600) when dismissed. With `NoUI`, the same failure returns immediately as a typed code
89+
(`error_from_code` maps -6600 → `AuthFailed`, -6004 `kNetAuthErrorGuestNotSupported``AuthRequired`) and the frontend
90+
renders its own login form.
91+
8492
### `smb2` for SMB share enumeration (not `pavao`/libsmbclient, `smb-rs`, or `smbutil`)
8593

8694
MIT license (compatible with BSL, allows dual-licensing for enterprise), pure Rust (no C dependencies), async-native
@@ -197,6 +205,12 @@ already taken by a different server (via `statfs`), and if so picks `/Volumes/{s
197205
convention) and passes it as an explicit mount point to `NetFSMountURLSync`. The volume switcher shows
198206
`{share} on {server}` for SMB mounts so the user knows which server each volume belongs to.
199207

208+
"Different server" is an identity comparison (`server_identity::same_server_live`), never a string compare: `statfs`
209+
may report the existing mount as `Naspolya._smb._tcp.local` while we mount by `192.168.1.111`, and a string mismatch
210+
would treat one NAS as two, force a second mount with `ForceNewSession`, and break session reuse. For the same reason,
211+
`mount_share_sync` returns early with `already_mounted: true` when `find_mount_path_for_share` finds the same
212+
server+share+port already mounted, skipping NetFS entirely.
213+
200214
## Gotchas
201215

202216
- **Don't hold mutex during DNS resolution**: `get_host_for_resolution` / `update_host_resolution` extract host info and release the mutex before blocking DNS, then re-acquire to update. Holding the mutex across network calls risks deadlock.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ pub mod mount;
2222
#[path = "mount_linux.rs"]
2323
pub mod mount;
2424

25+
pub mod server_identity;
2526
pub mod smb_client;
2627

2728
// SMB submodules - these are implementation details of smb_client

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

Lines changed: 178 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@
2323
//! The constant `kNetFSUseGuestKey` is a `#define` in `<NetFS/NetFS.h>` (not an
2424
//! exported symbol), so we recreate the CFString from the literal `"Guest"` at the
2525
//! call site rather than linking to an `extern "C"` static.
26+
//!
27+
//! On top of that, every mount sets `UIOption = NoUI` (`kNAUIOptionKey = kNAUIOptionNoUI`):
28+
//! even with explicit credentials, NetFS hands auth *failures* to NetAuthAgent, which
29+
//! shows a system dialog and returns `kNetAuthErrorInternal` (-6600) when dismissed.
30+
//! With `NoUI`, failures come back immediately as typed error codes and Cmdr renders
31+
//! its own login flow. See `open_option_entries`.
2632
2733
use core_foundation::base::TCFType;
2834
use core_foundation::string::CFString;
@@ -102,6 +108,45 @@ const ETIMEDOUT: i32 = 60;
102108
const ECONNREFUSED: i32 = 61;
103109
const EHOSTUNREACH: i32 = 65;
104110
const EAUTH: i32 = 80;
111+
/// NetAuth error codes (NetAuthAgent), documented in the comment block of `<NetFS/NetFS.h>`.
112+
/// `kNetAuthErrorInternal` is what `NetFSMountURLSync` returns when authentication fails,
113+
/// for example a guest mount against a creds-required server.
114+
const KNETAUTH_ERROR_INTERNAL: i32 = -6600;
115+
const KNETAUTH_ERROR_MOUNT_FAILED: i32 = -6602;
116+
const KNETAUTH_ERROR_NO_SHARES_AVAILABLE: i32 = -6003;
117+
const KNETAUTH_ERROR_GUEST_NOT_SUPPORTED: i32 = -6004;
118+
119+
/// Value of a NetFS `openOptions` entry.
120+
#[derive(Debug, PartialEq)]
121+
enum OpenOptionValue {
122+
/// `kCFBooleanTrue`
123+
True,
124+
/// A CFString value.
125+
Str(&'static str),
126+
}
127+
128+
/// Decides which entries go into the NetFS `openOptions` dictionary.
129+
///
130+
/// `UIOption = NoUI` (`kNAUIOptionKey = kNAUIOptionNoUI`) is ALWAYS set: Cmdr owns all
131+
/// auth UI. Without it, NetFS hands auth failures to NetAuthAgent, which shows a system
132+
/// dialog ("You entered an invalid username or password...") on top of Cmdr, blocks the
133+
/// mount call while it's open, and then returns `kNetAuthErrorInternal` (-6600). With
134+
/// `NoUI`, the same failure comes back immediately as a typed error code that we map in
135+
/// `error_from_code` and render in our own login flow.
136+
///
137+
/// All three keys (`UIOption`, `Guest`, `ForceNewSession`) are `#define`s in
138+
/// `<NetFS/NetFS.h>`, not exported symbols, so the caller recreates CFStrings from these
139+
/// literals rather than linking `extern "C"` statics.
140+
fn open_option_entries(want_guest: bool, want_force_new_session: bool) -> Vec<(&'static str, OpenOptionValue)> {
141+
let mut entries = vec![("UIOption", OpenOptionValue::Str("NoUI"))];
142+
if want_guest {
143+
entries.push(("Guest", OpenOptionValue::True));
144+
}
145+
if want_force_new_session {
146+
entries.push(("ForceNewSession", OpenOptionValue::True));
147+
}
148+
entries
149+
}
105150

106151
/// Map NetFS/POSIX error codes to user-friendly MountError.
107152
/// Note: EEXIST (17) is handled specially in mount_share_sync, not here.
@@ -116,12 +161,21 @@ fn error_from_code(code: i32, share_name: &str, server_name: &str) -> MountError
116161
ENETFSNOSHARESAVAIL => MountError::ShareNotFound {
117162
message: format!("No shares available on \"{}\"", server_name),
118163
},
119-
EACCES | EAUTH => MountError::AuthFailed {
164+
EACCES | EAUTH | KNETAUTH_ERROR_INTERNAL => MountError::AuthFailed {
120165
message: "Invalid username or password".to_string(),
121166
},
122167
ENETFSNOAUTHMECHSUPP => MountError::AuthRequired {
123168
message: "Authentication required".to_string(),
124169
},
170+
KNETAUTH_ERROR_GUEST_NOT_SUPPORTED => MountError::AuthRequired {
171+
message: format!("\"{}\" doesn't allow guest access. Sign in to connect.", server_name),
172+
},
173+
KNETAUTH_ERROR_NO_SHARES_AVAILABLE => MountError::ShareNotFound {
174+
message: format!("No shares available on \"{}\"", server_name),
175+
},
176+
KNETAUTH_ERROR_MOUNT_FAILED => MountError::ProtocolError {
177+
message: format!("\"{}\" refused to mount \"{}\"", server_name, share_name),
178+
},
125179
ETIMEDOUT => MountError::Timeout {
126180
message: format!("Connection to \"{}\" timed out", server_name),
127181
},
@@ -159,6 +213,18 @@ pub fn mount_share_sync(
159213
password: Option<&str>,
160214
port: u16,
161215
) -> Result<MountResult, MountError> {
216+
// If this exact share (same server identity + port) is already mounted, return it
217+
// directly instead of going through NetFS. The identity check matters: the existing
218+
// mount may be keyed by a different name for the same server (mDNS service name vs
219+
// IP), in which case a second NetFS call would "disambiguate" into mounting a
220+
// doomed second copy with a fresh session instead of reusing this one.
221+
if let Some(existing) = find_mount_path_for_share(server, share, port) {
222+
return Ok(MountResult {
223+
mount_path: existing,
224+
already_mounted: true,
225+
});
226+
}
227+
162228
// Build SMB URL: smb://server/share (with port for non-standard)
163229
let url_string = if port != 445 {
164230
format!("smb://{}:{}/{}", server, port, share)
@@ -187,46 +253,45 @@ pub fn mount_share_sync(
187253
// If so, pick a disambiguated path (public-1, public-2, ...) like Finder does.
188254
let explicit_mount_path = disambiguated_mount_path(server, share, port);
189255

190-
// Build openOptions. Two reasons we may need a dict:
191-
// 1. Guest mount (no credentials): set `kNetFSUseGuestKey = true` so NetFS doesn't consult the
192-
// Keychain and pop a credential dialog.
193-
// 2. Disambiguating against an existing same-hostname mount: set `ForceNewSession = true` so
194-
// macOS opens a fresh SMB session instead of reusing the existing one (different port =
195-
// different server).
196-
// If neither applies, pass NULL (NetFS uses default behavior).
256+
// Build openOptions. `open_option_entries` decides the content:
257+
// - `UIOption = NoUI`, always: Cmdr owns all auth UI; NetAuthAgent must never pop
258+
// a system dialog (see the helper's doc comment).
259+
// - `Guest = true` for guest mounts (no credentials): NetFS authenticates as guest
260+
// without consulting the Keychain.
261+
// - `ForceNewSession = true` when disambiguating against an existing same-name
262+
// mount: macOS opens a fresh SMB session instead of reusing the existing one
263+
// (different server, so the existing session would be wrong).
197264
let want_guest = cf_user.is_none() && cf_pass.is_none();
198265
let want_force_new_session = explicit_mount_path.is_some();
199-
let open_options = if want_guest || want_force_new_session {
200-
unsafe {
201-
let dict = core_foundation::dictionary::CFDictionaryCreateMutable(
202-
ptr::null(),
203-
2,
204-
&core_foundation::dictionary::kCFTypeDictionaryKeyCallBacks,
205-
&core_foundation::dictionary::kCFTypeDictionaryValueCallBacks,
206-
);
207-
let true_value = core_foundation::boolean::kCFBooleanTrue;
208-
if want_guest {
209-
// `kNetFSUseGuestKey` is a `#define` in <NetFS/NetFS.h>, not an
210-
// exported symbol. Build the CFString from its literal value.
211-
let guest_key = CFString::new("Guest");
212-
core_foundation::dictionary::CFDictionarySetValue(
213-
dict,
214-
guest_key.as_concrete_TypeRef() as *const c_void,
215-
true_value as *const c_void,
216-
);
217-
}
218-
if want_force_new_session {
219-
let force_key = CFString::new("ForceNewSession");
220-
core_foundation::dictionary::CFDictionarySetValue(
266+
let entries = open_option_entries(want_guest, want_force_new_session);
267+
let open_options = unsafe {
268+
let dict = core_foundation::dictionary::CFDictionaryCreateMutable(
269+
ptr::null(),
270+
0, // no capacity limit
271+
&core_foundation::dictionary::kCFTypeDictionaryKeyCallBacks,
272+
&core_foundation::dictionary::kCFTypeDictionaryValueCallBacks,
273+
);
274+
for (key, value) in &entries {
275+
// The dictionary retains keys and values (kCFTypeDictionary*CallBacks), so
276+
// dropping the temporary CFStrings after SetValue is fine.
277+
let cf_key = CFString::new(key);
278+
match value {
279+
OpenOptionValue::True => core_foundation::dictionary::CFDictionarySetValue(
221280
dict,
222-
force_key.as_concrete_TypeRef() as *const c_void,
223-
true_value as *const c_void,
224-
);
281+
cf_key.as_concrete_TypeRef() as *const c_void,
282+
core_foundation::boolean::kCFBooleanTrue as *const c_void,
283+
),
284+
OpenOptionValue::Str(s) => {
285+
let cf_value = CFString::new(s);
286+
core_foundation::dictionary::CFDictionarySetValue(
287+
dict,
288+
cf_key.as_concrete_TypeRef() as *const c_void,
289+
cf_value.as_concrete_TypeRef() as *const c_void,
290+
);
291+
}
225292
}
226-
dict as *const c_void
227293
}
228-
} else {
229-
ptr::null()
294+
dict as *const c_void
230295
};
231296

232297
// Prepare output array for mount points
@@ -275,7 +340,7 @@ pub fn mount_share_sync(
275340
// The explicit path is most reliable because we already validated it.
276341
let mount_path = explicit_mount_path
277342
.or_else(|| extract_mount_path(mountpoints))
278-
.or_else(|| find_mount_path_for_share(server, share))
343+
.or_else(|| find_mount_path_for_share(server, share, port))
279344
.unwrap_or_else(|| format!("/Volumes/{}", share));
280345

281346
Ok(MountResult {
@@ -353,9 +418,11 @@ fn disambiguated_mount_path(server: &str, share: &str, port: u16) -> Option<Stri
353418
return None; // Default path is free
354419
}
355420

356-
// Check if the existing mount is from the same server+port
421+
// Check if the existing mount is from the same server+port. Identity-aware: the
422+
// mount source may name the server differently than we do (mDNS service name vs
423+
// IP), and a string mismatch here would force a second mount of the same share.
357424
if let Some(info) = get_smb_mount_info(&default_path)
358-
&& info.server.to_lowercase() == server.to_lowercase()
425+
&& crate::network::server_identity::same_server_live(&info.server, server)
359426
&& info.share == share
360427
&& info.port == port
361428
{
@@ -375,7 +442,7 @@ fn disambiguated_mount_path(server: &str, share: &str, port: u16) -> Option<Stri
375442
}
376443
// If this suffixed path exists and belongs to this server, reuse it
377444
if let Some(info) = get_smb_mount_info(&candidate)
378-
&& info.server.to_lowercase() == server.to_lowercase()
445+
&& crate::network::server_identity::same_server_live(&info.server, server)
379446
&& info.share == share
380447
&& info.port == port
381448
{
@@ -386,16 +453,17 @@ fn disambiguated_mount_path(server: &str, share: &str, port: u16) -> Option<Stri
386453
None // Give up after 100 attempts, let NetFS handle it
387454
}
388455

389-
/// Finds the mount path for a server+share by scanning `/Volumes/` with `statfs`.
456+
/// Finds the mount path for a server+share+port by scanning `/Volumes/` with `statfs`.
390457
///
391458
/// Handles disambiguated paths: if `server` has share `public` but `/Volumes/public`
392459
/// belongs to a different server, macOS may have mounted it at `/Volumes/public-1`.
393-
/// This function finds the right one by checking each mount's source via `statfs`.
394-
fn find_mount_path_for_share(server: &str, share: &str) -> Option<String> {
460+
/// This function finds the right one by checking each mount's source via `statfs`,
461+
/// comparing servers by identity (mDNS name ↔ IP), not by string. The port check keeps
462+
/// same-named shares on different ports apart (Docker test containers on `localhost`).
463+
fn find_mount_path_for_share(server: &str, share: &str, port: u16) -> Option<String> {
395464
use crate::volumes::get_smb_mount_info;
396465

397466
let entries = std::fs::read_dir("/Volumes").ok()?;
398-
let server_lower = server.to_lowercase();
399467

400468
for entry in entries.flatten() {
401469
let path = entry.path().to_string_lossy().to_string();
@@ -405,8 +473,9 @@ fn find_mount_path_for_share(server: &str, share: &str) -> Option<String> {
405473
continue;
406474
}
407475
if let Some(info) = get_smb_mount_info(&path)
408-
&& info.server.to_lowercase() == server_lower
476+
&& crate::network::server_identity::same_server_live(&info.server, server)
409477
&& info.share == share
478+
&& info.port == port
410479
{
411480
return Some(path);
412481
}
@@ -504,6 +573,70 @@ mod tests {
504573
}
505574
}
506575

576+
/// NetAuth error codes (NetAuthAgent, documented in `<NetFS/NetFS.h>`) must map to
577+
/// typed errors, not the opaque `ProtocolError` catch-all. -6600 is what
578+
/// `NetFSMountURLSync` returns when authentication fails (observed in the wild with
579+
/// a guest mount against a creds-required NAS); routing it to `AuthFailed` is what
580+
/// lets the frontend offer the login form instead of a dead-end error pane.
581+
#[test]
582+
fn test_netauth_error_codes() {
583+
let err = error_from_code(-6600, "naspi", "naspolya");
584+
assert!(
585+
matches!(err, MountError::AuthFailed { .. }),
586+
"kNetAuthErrorInternal (-6600) should be AuthFailed, got {:?}",
587+
err
588+
);
589+
590+
let err = error_from_code(-6004, "naspi", "naspolya");
591+
assert!(
592+
matches!(err, MountError::AuthRequired { .. }),
593+
"kNetAuthErrorGuestNotSupported (-6004) should be AuthRequired, got {:?}",
594+
err
595+
);
596+
597+
let err = error_from_code(-6003, "naspi", "naspolya");
598+
assert!(
599+
matches!(err, MountError::ShareNotFound { .. }),
600+
"kNetAuthErrorNoSharesAvailable (-6003) should be ShareNotFound, got {:?}",
601+
err
602+
);
603+
604+
// kNetAuthErrorMountFailed means auth SUCCEEDED but the mount step failed, so it
605+
// must NOT map to an auth-class error (that would loop the user into a pointless
606+
// login form). It stays a ProtocolError, just with a readable message.
607+
let err = error_from_code(-6602, "naspi", "naspolya");
608+
assert!(
609+
matches!(err, MountError::ProtocolError { .. }),
610+
"kNetAuthErrorMountFailed (-6602) should stay ProtocolError, got {:?}",
611+
err
612+
);
613+
}
614+
615+
/// `UIOption = NoUI` must be set on EVERY mount, regardless of guest/credentialed
616+
/// mode. Without it, NetFS hands auth failures to NetAuthAgent, which pops a system
617+
/// dialog ("You entered an invalid username or password...") on top of Cmdr and then
618+
/// returns `kNetAuthErrorInternal`. Cmdr owns all auth UI.
619+
#[test]
620+
fn test_open_options_always_suppress_system_ui() {
621+
for (guest, force_new_session) in [(false, false), (true, false), (false, true), (true, true)] {
622+
let entries = open_option_entries(guest, force_new_session);
623+
assert!(
624+
entries.contains(&("UIOption", OpenOptionValue::Str("NoUI"))),
625+
"UIOption=NoUI missing for guest={guest}, force_new_session={force_new_session}: {entries:?}"
626+
);
627+
assert_eq!(
628+
entries.iter().any(|(key, _)| *key == "Guest"),
629+
guest,
630+
"Guest key presence should match guest={guest}"
631+
);
632+
assert_eq!(
633+
entries.iter().any(|(key, _)| *key == "ForceNewSession"),
634+
force_new_session,
635+
"ForceNewSession key presence should match force_new_session={force_new_session}"
636+
);
637+
}
638+
}
639+
507640
#[test]
508641
fn test_timeout_constant() {
509642
// Verify default timeout is reasonable (10-60 seconds)

0 commit comments

Comments
 (0)