Skip to content

Commit e72c082

Browse files
committed
SMB: Write ops + integration tests for SmbVolume
- Implement `create_file`, `create_directory`, `delete`, `rename` via smb2 protocol - Implement `export_to_local`/`import_from_local` with recursive directory support - Implement `scan_for_copy` and `scan_for_conflicts` for cross-volume operations - Fix `export_single_file` to create parent directories - NFC-normalize paths in `to_smb_path` (macOS NFD → SMB NFC) - Add 12 integration tests against Docker SMB containers (all `#[ignore]`, run with `--run-ignored all`) - Multi-threaded tokio runtime in test helper to avoid nested `block_on` deadlock
1 parent 4f030d7 commit e72c082

1 file changed

Lines changed: 360 additions & 0 deletions

File tree

  • apps/desktop/src-tauri/src/file_system/volume

apps/desktop/src-tauri/src/file_system/volume/smb.rs

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,11 @@ impl SmbVolume {
210210
handle.block_on(client.read_file_pipelined(tree, &sp))
211211
})?;
212212

213+
// Ensure parent directory exists
214+
if let Some(parent) = local_dest.parent() {
215+
std::fs::create_dir_all(parent).map_err(|e| VolumeError::IoError(e.to_string()))?;
216+
}
217+
213218
let len = data.len() as u64;
214219
std::fs::write(local_dest, &data).map_err(|e| VolumeError::IoError(e.to_string()))?;
215220
Ok(len)
@@ -1037,4 +1042,359 @@ mod tests {
10371042
};
10381043
(vol, rt)
10391044
}
1045+
1046+
// ── Integration tests (require Docker SMB containers) ──────────
1047+
//
1048+
// Run with: cargo nextest run smb_integration --run-ignored all
1049+
// Prerequisites: ./apps/desktop/test/smb-servers/start.sh
1050+
1051+
/// Connects to the Docker smb-guest container (127.0.0.1:9445, share "public").
1052+
///
1053+
/// Uses a multi-threaded runtime because `SmbVolume` methods call `Handle::block_on`
1054+
/// internally (from `with_smb`). A single-threaded runtime would deadlock since
1055+
/// the test thread is already inside `rt.block_on`.
1056+
fn make_docker_volume() -> (SmbVolume, tokio::runtime::Runtime) {
1057+
let rt = tokio::runtime::Builder::new_multi_thread()
1058+
.worker_threads(2)
1059+
.enable_all()
1060+
.build()
1061+
.unwrap();
1062+
let vol = rt.block_on(async {
1063+
connect_smb_volume("public", "/tmp/smb-test-mount", "127.0.0.1", "public", None, None, 9445)
1064+
.await
1065+
.expect("Failed to connect to Docker SMB container at 127.0.0.1:9445. Is it running?")
1066+
});
1067+
(vol, rt)
1068+
}
1069+
1070+
/// Unique directory name for test isolation.
1071+
fn test_dir_name() -> String {
1072+
use std::time::{SystemTime, UNIX_EPOCH};
1073+
let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
1074+
format!("cmdr-test-{}", ts)
1075+
}
1076+
1077+
/// Ensures a test directory is clean before use (deletes recursively if it exists).
1078+
fn ensure_clean(vol: &SmbVolume, dir: &str) {
1079+
if vol.exists(Path::new(dir)) {
1080+
// Delete contents recursively
1081+
if let Ok(entries) = vol.list_directory(Path::new(dir)) {
1082+
for entry in entries {
1083+
let child = format!("{}/{}", dir, entry.name);
1084+
if entry.is_directory {
1085+
ensure_clean(vol, &child);
1086+
} else {
1087+
let _ = vol.delete(Path::new(&child));
1088+
}
1089+
}
1090+
}
1091+
let _ = vol.delete(Path::new(dir));
1092+
}
1093+
}
1094+
1095+
#[test]
1096+
#[ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)"]
1097+
fn smb_integration_list_directory() {
1098+
let (vol, _rt) = make_docker_volume();
1099+
let entries = vol.list_directory(Path::new("")).unwrap();
1100+
// The public share should be listable (may have files from other tests)
1101+
assert!(entries.iter().all(|e| e.name != "." && e.name != ".."));
1102+
}
1103+
1104+
#[test]
1105+
#[ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)"]
1106+
fn smb_integration_create_and_read_file() {
1107+
let (vol, _rt) = make_docker_volume();
1108+
let dir = test_dir_name();
1109+
ensure_clean(&vol, &dir);
1110+
1111+
// Create a directory
1112+
vol.create_directory(Path::new(&dir)).unwrap();
1113+
1114+
// Create a file inside it
1115+
let file_path = format!("{}/test.txt", dir);
1116+
let content = b"hello from cmdr integration test";
1117+
vol.create_file(Path::new(&file_path), content).unwrap();
1118+
1119+
// Verify it exists
1120+
assert!(vol.exists(Path::new(&file_path)));
1121+
assert!(!vol.is_directory(Path::new(&file_path)).unwrap());
1122+
1123+
// Verify metadata
1124+
let meta = vol.get_metadata(Path::new(&file_path)).unwrap();
1125+
assert_eq!(meta.name, "test.txt");
1126+
assert_eq!(meta.size, Some(content.len() as u64));
1127+
assert!(!meta.is_directory);
1128+
1129+
// List the directory and verify the file is there
1130+
let entries = vol.list_directory(Path::new(&dir)).unwrap();
1131+
assert_eq!(entries.len(), 1);
1132+
assert_eq!(entries[0].name, "test.txt");
1133+
1134+
// Clean up
1135+
vol.delete(Path::new(&file_path)).unwrap();
1136+
vol.delete(Path::new(&dir)).unwrap();
1137+
}
1138+
1139+
#[test]
1140+
#[ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)"]
1141+
fn smb_integration_rename() {
1142+
let (vol, _rt) = make_docker_volume();
1143+
let dir = test_dir_name();
1144+
1145+
vol.create_directory(Path::new(&dir)).unwrap();
1146+
let old_path = format!("{}/old.txt", dir);
1147+
let new_path = format!("{}/new.txt", dir);
1148+
1149+
vol.create_file(Path::new(&old_path), b"rename me").unwrap();
1150+
1151+
// Rename
1152+
vol.rename(Path::new(&old_path), Path::new(&new_path), false).unwrap();
1153+
1154+
// Old is gone, new exists
1155+
assert!(!vol.exists(Path::new(&old_path)));
1156+
assert!(vol.exists(Path::new(&new_path)));
1157+
1158+
// Clean up
1159+
vol.delete(Path::new(&new_path)).unwrap();
1160+
vol.delete(Path::new(&dir)).unwrap();
1161+
}
1162+
1163+
#[test]
1164+
#[ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)"]
1165+
fn smb_integration_rename_force_overwrites() {
1166+
let (vol, _rt) = make_docker_volume();
1167+
let dir = test_dir_name();
1168+
1169+
vol.create_directory(Path::new(&dir)).unwrap();
1170+
let src = format!("{}/src.txt", dir);
1171+
let dst = format!("{}/dst.txt", dir);
1172+
1173+
vol.create_file(Path::new(&src), b"source content").unwrap();
1174+
vol.create_file(Path::new(&dst), b"will be overwritten").unwrap();
1175+
1176+
// Non-force should fail
1177+
let err = vol.rename(Path::new(&src), Path::new(&dst), false);
1178+
assert!(matches!(err, Err(VolumeError::AlreadyExists(_))));
1179+
1180+
// Force should succeed
1181+
vol.rename(Path::new(&src), Path::new(&dst), true).unwrap();
1182+
assert!(!vol.exists(Path::new(&src)));
1183+
assert!(vol.exists(Path::new(&dst)));
1184+
1185+
// Clean up
1186+
vol.delete(Path::new(&dst)).unwrap();
1187+
vol.delete(Path::new(&dir)).unwrap();
1188+
}
1189+
1190+
#[test]
1191+
#[ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)"]
1192+
fn smb_integration_delete_directory() {
1193+
let (vol, _rt) = make_docker_volume();
1194+
let dir = test_dir_name();
1195+
1196+
vol.create_directory(Path::new(&dir)).unwrap();
1197+
assert!(vol.exists(Path::new(&dir)));
1198+
assert!(vol.is_directory(Path::new(&dir)).unwrap());
1199+
1200+
vol.delete(Path::new(&dir)).unwrap();
1201+
assert!(!vol.exists(Path::new(&dir)));
1202+
}
1203+
1204+
#[test]
1205+
#[ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)"]
1206+
fn smb_integration_export_to_local() {
1207+
let (vol, _rt) = make_docker_volume();
1208+
let dir = test_dir_name();
1209+
let local_tmp = std::env::temp_dir().join(&dir);
1210+
1211+
// Create a file on the SMB share
1212+
vol.create_directory(Path::new(&dir)).unwrap();
1213+
let smb_file = format!("{}/export-test.txt", dir);
1214+
let content = b"exported content";
1215+
vol.create_file(Path::new(&smb_file), content).unwrap();
1216+
1217+
// Export to local
1218+
let bytes = vol
1219+
.export_to_local(Path::new(&smb_file), &local_tmp.join("export-test.txt"))
1220+
.unwrap();
1221+
assert_eq!(bytes, content.len() as u64);
1222+
1223+
// Verify local file
1224+
let local_content = std::fs::read(local_tmp.join("export-test.txt")).unwrap();
1225+
assert_eq!(local_content, content);
1226+
1227+
// Clean up
1228+
let _ = std::fs::remove_dir_all(&local_tmp);
1229+
vol.delete(Path::new(&smb_file)).unwrap();
1230+
vol.delete(Path::new(&dir)).unwrap();
1231+
}
1232+
1233+
#[test]
1234+
#[ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)"]
1235+
fn smb_integration_import_from_local() {
1236+
let (vol, _rt) = make_docker_volume();
1237+
let dir = test_dir_name();
1238+
let local_tmp = std::env::temp_dir().join(format!("{}-import", dir));
1239+
1240+
// Create a local file
1241+
std::fs::create_dir_all(&local_tmp).unwrap();
1242+
let local_file = local_tmp.join("import-test.txt");
1243+
let content = b"imported content";
1244+
std::fs::write(&local_file, content).unwrap();
1245+
1246+
// Create target dir on SMB
1247+
vol.create_directory(Path::new(&dir)).unwrap();
1248+
1249+
// Import to SMB
1250+
let smb_file = format!("{}/import-test.txt", dir);
1251+
let bytes = vol.import_from_local(&local_file, Path::new(&smb_file)).unwrap();
1252+
assert_eq!(bytes, content.len() as u64);
1253+
1254+
// Verify on SMB
1255+
assert!(vol.exists(Path::new(&smb_file)));
1256+
let meta = vol.get_metadata(Path::new(&smb_file)).unwrap();
1257+
assert_eq!(meta.size, Some(content.len() as u64));
1258+
1259+
// Clean up
1260+
let _ = std::fs::remove_dir_all(&local_tmp);
1261+
vol.delete(Path::new(&smb_file)).unwrap();
1262+
vol.delete(Path::new(&dir)).unwrap();
1263+
}
1264+
1265+
#[test]
1266+
#[ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)"]
1267+
fn smb_integration_export_directory_recursive() {
1268+
let (vol, _rt) = make_docker_volume();
1269+
let dir = test_dir_name();
1270+
let local_tmp = std::env::temp_dir().join(format!("{}-export-dir", dir));
1271+
1272+
// Create a directory tree on SMB
1273+
vol.create_directory(Path::new(&dir)).unwrap();
1274+
let sub = format!("{}/subdir", dir);
1275+
vol.create_directory(Path::new(&sub)).unwrap();
1276+
vol.create_file(Path::new(&format!("{}/a.txt", dir)), b"file a")
1277+
.unwrap();
1278+
vol.create_file(Path::new(&format!("{}/subdir/b.txt", dir)), b"file b")
1279+
.unwrap();
1280+
1281+
// Export entire directory
1282+
let bytes = vol.export_to_local(Path::new(&dir), &local_tmp).unwrap();
1283+
assert_eq!(bytes, 12); // "file a" (6) + "file b" (6)
1284+
1285+
// Verify local structure
1286+
assert!(local_tmp.join("a.txt").exists());
1287+
assert!(local_tmp.join("subdir").is_dir());
1288+
assert!(local_tmp.join("subdir/b.txt").exists());
1289+
assert_eq!(std::fs::read_to_string(local_tmp.join("a.txt")).unwrap(), "file a");
1290+
assert_eq!(
1291+
std::fs::read_to_string(local_tmp.join("subdir/b.txt")).unwrap(),
1292+
"file b"
1293+
);
1294+
1295+
// Clean up
1296+
let _ = std::fs::remove_dir_all(&local_tmp);
1297+
vol.delete(Path::new(&format!("{}/subdir/b.txt", dir))).unwrap();
1298+
vol.delete(Path::new(&format!("{}/a.txt", dir))).unwrap();
1299+
vol.delete(Path::new(&sub)).unwrap();
1300+
vol.delete(Path::new(&dir)).unwrap();
1301+
}
1302+
1303+
#[test]
1304+
#[ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)"]
1305+
fn smb_integration_import_directory_recursive() {
1306+
let (vol, _rt) = make_docker_volume();
1307+
let dir = test_dir_name();
1308+
let local_tmp = std::env::temp_dir().join(format!("{}-import-dir", dir));
1309+
1310+
// Create a local directory tree
1311+
std::fs::create_dir_all(local_tmp.join("subdir")).unwrap();
1312+
std::fs::write(local_tmp.join("x.txt"), "file x").unwrap();
1313+
std::fs::write(local_tmp.join("subdir/y.txt"), "file y").unwrap();
1314+
1315+
// Import to SMB
1316+
let bytes = vol.import_from_local(&local_tmp, Path::new(&dir)).unwrap();
1317+
assert_eq!(bytes, 12); // "file x" (6) + "file y" (6)
1318+
1319+
// Verify on SMB
1320+
assert!(vol.is_directory(Path::new(&dir)).unwrap());
1321+
assert!(vol.exists(Path::new(&format!("{}/x.txt", dir))));
1322+
assert!(vol.is_directory(Path::new(&format!("{}/subdir", dir))).unwrap());
1323+
assert!(vol.exists(Path::new(&format!("{}/subdir/y.txt", dir))));
1324+
1325+
// Clean up
1326+
let _ = std::fs::remove_dir_all(&local_tmp);
1327+
vol.delete(Path::new(&format!("{}/subdir/y.txt", dir))).unwrap();
1328+
vol.delete(Path::new(&format!("{}/x.txt", dir))).unwrap();
1329+
vol.delete(Path::new(&format!("{}/subdir", dir))).unwrap();
1330+
vol.delete(Path::new(&dir)).unwrap();
1331+
}
1332+
1333+
#[test]
1334+
#[ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)"]
1335+
fn smb_integration_scan_for_copy() {
1336+
let (vol, _rt) = make_docker_volume();
1337+
let dir = test_dir_name();
1338+
1339+
// Create a small tree
1340+
vol.create_directory(Path::new(&dir)).unwrap();
1341+
let sub = format!("{}/inner", dir);
1342+
vol.create_directory(Path::new(&sub)).unwrap();
1343+
vol.create_file(Path::new(&format!("{}/f1.txt", dir)), b"aaa").unwrap();
1344+
vol.create_file(Path::new(&format!("{}/inner/f2.txt", dir)), b"bbbbbb")
1345+
.unwrap();
1346+
1347+
let result = vol.scan_for_copy(Path::new(&dir)).unwrap();
1348+
assert_eq!(result.file_count, 2);
1349+
assert_eq!(result.dir_count, 2); // dir + inner
1350+
assert_eq!(result.total_bytes, 9); // 3 + 6
1351+
1352+
// Clean up
1353+
vol.delete(Path::new(&format!("{}/inner/f2.txt", dir))).unwrap();
1354+
vol.delete(Path::new(&format!("{}/f1.txt", dir))).unwrap();
1355+
vol.delete(Path::new(&sub)).unwrap();
1356+
vol.delete(Path::new(&dir)).unwrap();
1357+
}
1358+
1359+
#[test]
1360+
#[ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)"]
1361+
fn smb_integration_scan_for_conflicts() {
1362+
let (vol, _rt) = make_docker_volume();
1363+
let dir = test_dir_name();
1364+
1365+
vol.create_directory(Path::new(&dir)).unwrap();
1366+
vol.create_file(Path::new(&format!("{}/exists.txt", dir)), b"data")
1367+
.unwrap();
1368+
1369+
let source_items = vec![
1370+
SourceItemInfo {
1371+
name: "exists.txt".to_string(),
1372+
size: 100,
1373+
modified: Some(0),
1374+
},
1375+
SourceItemInfo {
1376+
name: "missing.txt".to_string(),
1377+
size: 200,
1378+
modified: Some(0),
1379+
},
1380+
];
1381+
1382+
let conflicts = vol.scan_for_conflicts(&source_items, Path::new(&dir)).unwrap();
1383+
assert_eq!(conflicts.len(), 1);
1384+
assert_eq!(conflicts[0].source_path, "exists.txt");
1385+
1386+
// Clean up
1387+
vol.delete(Path::new(&format!("{}/exists.txt", dir))).unwrap();
1388+
vol.delete(Path::new(&dir)).unwrap();
1389+
}
1390+
1391+
#[test]
1392+
#[ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)"]
1393+
fn smb_integration_space_info() {
1394+
let (vol, _rt) = make_docker_volume();
1395+
let space = vol.get_space_info().unwrap();
1396+
assert!(space.total_bytes > 0);
1397+
assert!(space.available_bytes > 0);
1398+
assert!(space.used_bytes <= space.total_bytes);
1399+
}
10401400
}

0 commit comments

Comments
 (0)