Skip to content

Commit 017b704

Browse files
committed
SMB: Use smb2 consumer test harness, fix port handling
Monster commit - Replace Cmdr's 11 local Docker container definitions with smb2's consumer test harness (14 containers, extracted on first run via `cargo run --example smb_compose`) - Delete `test/smb-servers/containers/`, old `docker-compose.yml`, Pi deploy files - `smb-e2e` Cargo feature now activates `smb2/testing`; examples gated with `required-features` - `virtual_smb_hosts.rs` injects all 14 virtual hosts using `smb2::testing::*_port()` functions - Add E2E tests for 50-share enumeration and unicode share rendering - Update `smb-fixtures.ts`, `e2e-linux.sh`, and all docs for new container names/ports Port handling fixes (pre-existing bugs): - Add `port: u16` to `SmbMountInfo` (macOS + Linux) — extract port from `statfs` mount source instead of discarding it - Thread port through `mount_share` → `mount_share_sync` → SMB URL (`smb://server:port/share`) - Fix `upgrade_to_smb_volume`, `upgrade_existing_smb_mounts`, `try_upgrade_smb_mount` — use `info.port` instead of hardcoded 445 - Fix `NetworkMountView.svelte` — pass port separately instead of embedding in server string (caused `localhost:10480:10480` double-port) Mount path disambiguation (partial): - On EEXIST, read `NetFSMountURLSync` `mountpoints` array instead of hardcoding `/Volumes/{share}` - Add `find_mount_path_for_share` fallback that scans `/Volumes/` via `statfs` to match server+share - Full same-name-share collision handling (explicit mount points, volume switcher display) tracked separately Dev experience fixes: - Fix `effect_orphan`: move `initAiToastSync()` to synchronous `onMount` (before async IIFE) - Fix SvelteKit HMR crash (sveltejs/kit#15287): `import.meta.hot.accept` + `invalidate()` in root layout forces clean reload instead of broken route-tree rebuild - Fix UnoCSS HMR spam: restrict `content.filesystem` in `uno.config.ts` to the 3 files that use icons
1 parent 4cecfb9 commit 017b704

61 files changed

Lines changed: 616 additions & 1742 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ target/
4747
# Auto-generated capability file for playwright-e2e feature (created by build.rs)
4848
apps/desktop/src-tauri/capabilities/playwright.json
4949

50+
# Extracted smb2 consumer compose files (generated by start.sh / smb_compose example)
51+
apps/desktop/test/smb-servers/.compose/
52+
5053
# Downloaded llama-server binaries (fetched at build time, not committed)
5154
apps/desktop/src-tauri/resources/ai/
5255

apps/desktop/knip.json

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,6 @@
77
"@tauri-apps/cli",
88
"@testing-library/svelte",
99
"prettier-plugin-svelte",
10-
"@wdio/globals",
11-
"@wdio/local-runner",
12-
"@wdio/mocha-framework",
13-
"@wdio/spec-reporter",
14-
"@wdio/types",
15-
"webdriverio",
16-
"@crabnebula/tauri-driver",
17-
"@crabnebula/test-runner-backend",
1810
"axe-core",
1911
"@iconify-json/lucide"
2012
],

apps/desktop/scripts/e2e-linux.sh

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -250,18 +250,19 @@ DOCKER_TAURI_BINARY="/target/$LINUX_TARGET/release/Cmdr"
250250

251251
# ── SMB container management ────────────────────────────────────────────────
252252
# Start Docker SMB containers for network E2E tests. The E2E test container
253-
# joins the smb-servers_default network so it can reach smb-guest:445 and
254-
# smb-auth:445 by container name (no host port mapping needed).
253+
# joins the smb-consumer_default network so it can reach smb-consumer-guest:445
254+
# and smb-consumer-auth:445 by container name (no host port mapping needed).
255+
# Containers come from smb2's consumer test harness.
255256

256-
SMB_COMPOSE_DIR="$DESKTOP_DIR/test/smb-servers"
257-
SMB_NETWORK="smb-servers_default"
257+
SMB_SERVERS_DIR="$DESKTOP_DIR/test/smb-servers"
258+
SMB_NETWORK="smb-consumer_default"
258259

259260
start_smb_containers() {
260-
if docker compose -f "$SMB_COMPOSE_DIR/docker-compose.yml" ps --format json 2>/dev/null | grep -q '"smb-guest"'; then
261+
if docker compose -p smb-consumer ps --format json 2>/dev/null | grep -q '"smb-consumer-guest"'; then
261262
log_info "SMB containers already running"
262263
else
263264
log_info "Starting SMB containers (minimal)..."
264-
"$SMB_COMPOSE_DIR/start.sh" minimal
265+
"$SMB_SERVERS_DIR/start.sh" minimal
265266
fi
266267

267268
# Wait for the network to exist (docker compose creates it)
@@ -281,7 +282,7 @@ start_smb_containers
281282
# CMDR_MCP_ENABLED: release builds disable MCP by default — tests need it
282283
# --privileged: needed for mount -t cifs inside the container (SYS_ADMIN alone is
283284
# blocked by Docker's default seccomp profile which denies the mount syscall)
284-
SMB_ENV_ARGS="-e SMB_E2E_GUEST_HOST=smb-guest -e SMB_E2E_GUEST_PORT=445 -e SMB_E2E_AUTH_HOST=smb-auth -e SMB_E2E_AUTH_PORT=445 -e CMDR_MCP_ENABLED=true"
285+
SMB_ENV_ARGS="-e SMB_E2E_GUEST_HOST=smb-consumer-guest -e SMB_E2E_GUEST_PORT=445 -e SMB_E2E_AUTH_HOST=smb-consumer-auth -e SMB_E2E_AUTH_PORT=445 -e CMDR_MCP_ENABLED=true"
285286
SMB_DOCKER_ARGS="--privileged"
286287

287288
if $INTERACTIVE; then

apps/desktop/src-tauri/Cargo.toml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ playwright-e2e = ["dep:tauri-plugin-playwright"]
1414
# Virtual MTP device for E2E testing (registers a fake Android device). Never enable in production builds.
1515
virtual-mtp = ["mtp-rs/virtual-device"]
1616
# Virtual SMB hosts for E2E testing (injects synthetic network hosts). Never enable in production builds.
17-
smb-e2e = []
17+
smb-e2e = ["smb2/testing"]
1818

1919
[[bin]]
2020
name = "Cmdr"
@@ -151,6 +151,14 @@ criterion = { version = "0.8.1", features = ["html_reports"] }
151151
rand_core = { version = "0.6", features = ["getrandom"] }
152152
tempfile = "3"
153153

154+
[[example]]
155+
name = "smb_compose"
156+
required-features = ["smb-e2e"]
157+
158+
[[example]]
159+
name = "docker_smb_test"
160+
required-features = ["smb-e2e"]
161+
154162
[[bench]]
155163
name = "icon_benchmarks"
156164
harness = false

apps/desktop/src-tauri/examples/docker_smb_test.rs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Quick test for Docker SMB servers with custom ports.
22
//!
33
//! Run with:
4-
//! cargo run --example docker_smb_test
4+
//! cargo run --example docker_smb_test --features smb-e2e
55
//!
66
//! NOTE: This example only works on macOS/Linux (requires the `smb2` crate).
77
@@ -10,15 +10,14 @@ mod inner {
1010
use smb2::{ClientConfig, SmbClient};
1111
use std::time::Duration;
1212

13-
const TEST_PORT: u16 = 9445; // smb-guest Docker container
14-
const TEST_IP: &str = "127.0.0.1";
15-
1613
#[tokio::main]
1714
pub async fn main() {
18-
println!("Testing Docker SMB container at {}:{}", TEST_IP, TEST_PORT);
15+
let port = smb2::testing::guest_port();
16+
let ip = "127.0.0.1";
17+
println!("Testing Docker SMB container at {}:{}", ip, port);
1918

2019
let config = ClientConfig {
21-
addr: format!("{}:{}", TEST_IP, TEST_PORT),
20+
addr: format!("{}:{}", ip, port),
2221
timeout: Duration::from_secs(5),
2322
username: "Guest".to_string(),
2423
password: String::new(),
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//! Extracts smb2's consumer Docker Compose files to a directory.
2+
//!
3+
//! Used by `test/smb-servers/start.sh` to set up the SMB test containers.
4+
//!
5+
//! Run with:
6+
//! cargo run --example smb_compose --features smb-e2e -- <output_dir>
7+
8+
fn main() {
9+
let dir = std::env::args()
10+
.nth(1)
11+
.unwrap_or_else(|| "test/smb-servers/.compose".to_string());
12+
let path = std::path::Path::new(&dir);
13+
smb2::testing::write_compose_files(path).expect("Failed to write compose files");
14+
println!("{}", path.display());
15+
}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,11 +295,13 @@ pub async fn mount_network_share(
295295
port: Option<u16>,
296296
timeout_ms: Option<u64>,
297297
) -> Result<MountResult, MountError> {
298+
let actual_port = port.unwrap_or(445);
298299
let result = mount::mount_share(
299300
server.clone(),
300301
share.clone(),
301302
username.clone(),
302303
password.clone(),
304+
actual_port,
303305
timeout_ms,
304306
)
305307
.await?;
@@ -313,7 +315,7 @@ pub async fn mount_network_share(
313315
&result.mount_path,
314316
username.as_deref(),
315317
password.as_deref(),
316-
port.unwrap_or(445),
318+
actual_port,
317319
)
318320
.await;
319321

@@ -419,7 +421,7 @@ pub async fn upgrade_to_smb_volume(volume_id: String) -> Result<String, String>
419421
None => (None, None),
420422
};
421423

422-
register_smb_volume(&info.server, &info.share, &mount_path, username, password, 445).await;
424+
register_smb_volume(&info.server, &info.share, &mount_path, username, password, info.port).await;
423425

424426
// Check if it worked
425427
if let Some(vol) = manager.get(&volume_id)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ pub fn upgrade_existing_smb_mounts() {
234234
&mount_path,
235235
username,
236236
password,
237-
445,
237+
info.port,
238238
)
239239
.await;
240240
any_upgraded = true;

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Discover, browse, and mount SMB network shares. Works on macOS and Linux.
66

77
- **Discovery**: `mdns_discovery.rs` — Pure Rust mDNS using `mdns-sd` crate. Cross-platform.
88
- **Manual servers**: `manual_servers.rs` — User-added servers via "Connect to server..." dialog. Parses addresses, checks TCP reachability, persists to `manual-servers.json`, and injects synthetic `NetworkHost` entries with `source: Manual` into `DISCOVERY_STATE`. Loaded at startup.
9-
- **E2E testing**: `virtual_smb_hosts.rs` — Injects synthetic `NetworkHost` entries for Docker SMB containers. Gated behind `smb-e2e` Cargo feature. Never enabled in production.
9+
- **E2E testing**: `virtual_smb_hosts.rs` — Injects 14 synthetic `NetworkHost` entries for smb2's consumer Docker containers. Ports come from `smb2::testing::*_port()` functions (configurable via `SMB_CONSUMER_*_PORT` env vars, default 10480+). Hosts configurable via `SMB_E2E_*_HOST` env vars (default `localhost`). Gated behind `smb-e2e` Cargo feature. Never enabled in production.
1010
- **Share listing**: Split across multiple files:
1111
- `smb_client.rs` — Top-level share-listing entry point; orchestrates guest -> keychain -> prompt auth flow; tries smb2 first, falls back to smbutil (macOS only)
1212
- `smb_connection.rs` — TCP connection establishment and share listing via `smb2::SmbClient`
@@ -104,6 +104,13 @@ When the user mounts an SMB share, we establish a parallel smb2 connection along
104104

105105
Manual server IDs use the format `manual-{address}-{port}` with dots/colons replaced by dashes. This is deterministic (same address+port always produces the same ID), preventing duplicates. The `manual-` prefix avoids collision with mDNS-derived IDs.
106106

107+
### Mount path disambiguation for same-name shares
108+
109+
When two servers have a share with the same name (for example, two NAS devices both sharing `public`), macOS creates
110+
disambiguated mount paths (`/Volumes/public`, `/Volumes/public-1`). The mount code reads the actual path from
111+
`NetFSMountURLSync`'s `mountpoints` array on both success and EEXIST. If the array is empty (some macOS versions don't
112+
populate it on EEXIST), `find_mount_path_for_share` scans `/Volumes/` and uses `statfs` to match the server+share.
113+
107114
## Gotchas
108115

109116
- **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.
@@ -113,7 +120,7 @@ Manual server IDs use the format `manual-{address}-{port}` with dots/colons repl
113120
- **Account name is lowercase**: `make_account_name` lowercases server name for consistency. Prevents duplicate entries for "SERVER" vs "server".
114121
- **Linux `gio mount` requires GVFS**: The `gvfs-smb` package must be installed. Standard on Ubuntu/Fedora GNOME desktops. KDE desktops may need it explicitly.
115122
- **`ShareListError` uses internally tagged serde format** (`#[serde(tag = "type")]`) with struct variants. This keeps a flat JSON shape (`{ "type": "protocol_error", "message": "..." }`). The `MissingDependency` variant adds an optional `installCommand` field. When adding new variants, use struct syntax (not tuple).
116-
- **macOS smbutil and NetFSMountURLSync fail with loopback IP + non-standard port**: `//127.0.0.1:9445` gives "Broken pipe", but `//localhost:9445` works. `build_smbutil_url` and `NetworkMountView.svelte` both fall back to hostname when IP is `127.0.0.1` or `::1`. This matters for E2E testing against Docker containers on localhost.
117-
- **Mount URL must include port when non-standard**: `NetworkMountView.svelte` appends `:PORT` to the server string when `port !== 445`. Without this, `NetFSMountURLSync` defaults to port 445 and can't reach Docker containers on custom ports.
123+
- **macOS smbutil and NetFSMountURLSync fail with loopback IP + non-standard port**: `//127.0.0.1:10480` gives "Broken pipe", but `//localhost:10480` works. `build_smbutil_url` and `NetworkMountView.svelte` both fall back to hostname when IP is `127.0.0.1` or `::1`. This matters for E2E testing against Docker containers on localhost.
124+
- **Mount URL must include port when non-standard**: `mount_share_sync` builds `smb://server:port/share` for non-445 ports. The port is passed as a separate parameter through `mount_share``mount_share_sync`, not embedded in the server string (embedding it would cause `build_smb_addr` to double the port: `localhost:10480:10480`). `SmbMountInfo.port` extracts the port from `statfs` mount source for upgrade paths.
118125
- **Strip `.local` from addr for smb2**: `smb2::Connection::connect()` extracts `server_name` from the addr string and uses it in UNC paths. Passing `"foo.local:445"` creates `\\foo.local\IPC$` which some servers reject. The `build_addr` helper in `smb_connection.rs` handles this.
119126
- **Manual hosts always set `hostname`**: The share listing pipeline guards on `host.hostname` being truthy. `create_network_host` always sets `hostname` (to the address, even for IPs) so manual hosts flow through the pipeline correctly.

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -821,15 +821,16 @@ mod tests {
821821
mod integration_tests {
822822
use super::*;
823823

824-
/// Verifies TCP reachability against a Docker SMB container on port 9445.
824+
/// Verifies TCP reachability against a Docker SMB container.
825825
///
826826
/// Requires: `./test/smb-servers/start.sh minimal`
827827
#[tokio::test]
828828
async fn reachability_docker_smb_guest() {
829-
let result = check_reachability("localhost", 9445).await;
829+
let port = smb2::testing::guest_port();
830+
let result = check_reachability("localhost", port).await;
830831
assert!(
831832
result.is_ok(),
832-
"Docker SMB container should be reachable on port 9445. Start it with: ./test/smb-servers/start.sh minimal"
833+
"Docker SMB container should be reachable on port {port}. Start it with: ./test/smb-servers/start.sh minimal"
833834
);
834835
}
835836

0 commit comments

Comments
 (0)