Skip to content

Commit 4f030d7

Browse files
committed
SMB: Add write operations to SmbVolume (M2)
- Implement `create_file`, `create_directory`, `delete`, `rename` via smb2 protocol - Implement `export_to_local` and `import_from_local` with recursive directory support - Implement `scan_for_copy` (recursive file/dir/bytes counting) and `scan_for_conflicts` - `delete` stats first to distinguish file vs directory deletion - `rename` with `force` deletes dest before renaming - NFC normalization applied to all paths via `to_smb_path` - Note: export/import load files fully into memory (matches MTP v1 behavior). Streaming is a follow-up.
1 parent baaccc8 commit 4f030d7

2 files changed

Lines changed: 340 additions & 2 deletions

File tree

apps/desktop/src-tauri/src/file_system/volume/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ VolumeManager (registry)
3535
Optional methods default to `Err(VolumeError::NotSupported)` or `false`, so new volume types can be added incrementally. Key capability flags:
3636

3737
- `supports_watching()` — enables the `notify`-based *listing* file watcher in `operations.rs` (separate from the `VolumeWatcher` trait used for drive indexing). `MtpVolume` returns `false` (it has its own USB event loop).
38-
- `supports_export()` — enables copy/move UI. Both local and MTP return `true`.
38+
- `supports_export()` — enables copy/move UI. Local, MTP, and SMB return `true`.
3939
- `supports_streaming()` — enables chunked MTP-to-MTP transfers. Only `MtpVolume` returns `true`.
4040
- `local_path()` — returns `Some` only for local volumes; allows `copyfile(2)` fast-path in copy operations. `SmbVolume` returns `None` so copies go through smb2 instead of the slow OS mount.
4141
- `supports_local_fs_access()` — whether `std::fs` operations (stat, read_dir) work on this volume's paths. Default `true`. `MtpVolume` returns `false`. Used to skip synthetic entry diffs for protocol-only volumes.

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

Lines changed: 339 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
//! but all Cmdr file operations go through smb2's pipelined I/O for better
66
//! performance and fail-fast behavior.
77
8-
use super::{SpaceInfo, Volume, VolumeError};
8+
use super::{CopyScanResult, ScanConflict, SourceItemInfo, SpaceInfo, Volume, VolumeError};
99
use crate::file_system::listing::FileEntry;
1010
use log::{debug, warn};
1111
use smb2::client::tree::Tree;
@@ -199,6 +199,139 @@ impl SmbVolume {
199199
}
200200
}
201201

202+
// ── Recursive helpers for export/import/scan ──────────────────────
203+
204+
/// Exports a single file from SMB to a local path. Returns bytes written.
205+
fn export_single_file(&self, smb_path: &str, local_dest: &Path) -> Result<u64, VolumeError> {
206+
let handle = self.runtime_handle.clone();
207+
let sp = smb_path.to_string();
208+
209+
let data = self.with_smb("export_to_local(read)", |client, tree| {
210+
handle.block_on(client.read_file_pipelined(tree, &sp))
211+
})?;
212+
213+
let len = data.len() as u64;
214+
std::fs::write(local_dest, &data).map_err(|e| VolumeError::IoError(e.to_string()))?;
215+
Ok(len)
216+
}
217+
218+
/// Recursively exports a directory from SMB to a local path. Returns total bytes.
219+
fn export_directory_recursive(&self, smb_path: &str, local_dest: &Path) -> Result<u64, VolumeError> {
220+
std::fs::create_dir_all(local_dest).map_err(|e| VolumeError::IoError(e.to_string()))?;
221+
222+
let display_path = self.to_display_path(smb_path);
223+
let entries = self.list_directory(Path::new(&display_path))?;
224+
let mut total_bytes = 0u64;
225+
226+
for entry in &entries {
227+
let child_smb = if smb_path.is_empty() {
228+
entry.name.clone()
229+
} else {
230+
format!("{}/{}", smb_path, entry.name)
231+
};
232+
let child_local = local_dest.join(&entry.name);
233+
234+
if entry.is_directory {
235+
total_bytes += self.export_directory_recursive(&child_smb, &child_local)?;
236+
} else {
237+
total_bytes += self.export_single_file(&child_smb, &child_local)?;
238+
}
239+
}
240+
241+
Ok(total_bytes)
242+
}
243+
244+
/// Imports a single local file to SMB. Returns bytes written.
245+
fn import_single_file(&self, local_source: &Path, smb_path: &str) -> Result<u64, VolumeError> {
246+
let data = std::fs::read(local_source).map_err(|e| VolumeError::IoError(e.to_string()))?;
247+
let len = data.len() as u64;
248+
let handle = self.runtime_handle.clone();
249+
let sp = smb_path.to_string();
250+
251+
self.with_smb("import_from_local(write)", |client, tree| {
252+
handle.block_on(client.write_file_pipelined(tree, &sp, &data))
253+
})?;
254+
255+
Ok(len)
256+
}
257+
258+
/// Recursively imports a local directory to SMB. Returns total bytes.
259+
fn import_directory_recursive(&self, local_source: &Path, smb_path: &str) -> Result<u64, VolumeError> {
260+
let handle = self.runtime_handle.clone();
261+
let sp = smb_path.to_string();
262+
263+
self.with_smb("import_from_local(mkdir)", |client, tree| {
264+
handle.block_on(client.create_directory(tree, &sp))
265+
})?;
266+
267+
let read_dir = std::fs::read_dir(local_source).map_err(|e| VolumeError::IoError(e.to_string()))?;
268+
let mut total_bytes = 0u64;
269+
270+
for dir_entry in read_dir {
271+
let dir_entry = dir_entry.map_err(|e| VolumeError::IoError(e.to_string()))?;
272+
let child_local = dir_entry.path();
273+
let child_name = dir_entry.file_name().to_string_lossy().to_string();
274+
let child_smb = if smb_path.is_empty() {
275+
child_name
276+
} else {
277+
format!("{}/{}", smb_path, child_name)
278+
};
279+
280+
if child_local.is_dir() {
281+
total_bytes += self.import_directory_recursive(&child_local, &child_smb)?;
282+
} else {
283+
total_bytes += self.import_single_file(&child_local, &child_smb)?;
284+
}
285+
}
286+
287+
Ok(total_bytes)
288+
}
289+
290+
/// Recursively scans an SMB path, accumulating file/dir counts and total bytes.
291+
fn scan_recursive(&self, smb_path: &str, result: &mut CopyScanResult) -> Result<(), VolumeError> {
292+
let handle = self.runtime_handle.clone();
293+
let sp = smb_path.to_string();
294+
295+
// Stat to determine if this is a file or directory
296+
if smb_path.is_empty() {
297+
// Root is always a directory, scan its contents
298+
} else {
299+
let info = self.with_smb("scan_for_copy(stat)", |client, tree| {
300+
handle.block_on(client.stat(tree, &sp))
301+
})?;
302+
303+
if !info.is_directory {
304+
result.file_count += 1;
305+
result.total_bytes += info.size;
306+
return Ok(());
307+
}
308+
}
309+
310+
// It's a directory — list and recurse
311+
result.dir_count += 1;
312+
let display_path = self.to_display_path(smb_path);
313+
let entries = self.list_directory(Path::new(&display_path))?;
314+
315+
for entry in &entries {
316+
let child_smb = if smb_path.is_empty() {
317+
entry.name.clone()
318+
} else {
319+
format!("{}/{}", smb_path, entry.name)
320+
};
321+
322+
if entry.is_directory {
323+
self.scan_recursive(&child_smb, result)?;
324+
} else {
325+
result.file_count += 1;
326+
result.total_bytes += entry.size.unwrap_or(0);
327+
}
328+
}
329+
330+
Ok(())
331+
}
332+
333+
// ── Connection helpers ──────────────────────────────────────────────
334+
202335
/// Runs an smb2 operation, handling connection state transitions.
203336
///
204337
/// On disconnection errors, transitions state to `Disconnected` (for now;
@@ -379,6 +512,205 @@ impl Volume for SmbVolume {
379512
Ok(fs_info_to_space_info(&info))
380513
}
381514

515+
fn create_file(&self, path: &Path, content: &[u8]) -> Result<(), VolumeError> {
516+
let smb_path = self.to_smb_path(path);
517+
let handle = self.runtime_handle.clone();
518+
let data = content.to_vec();
519+
520+
debug!("SmbVolume::create_file: share={}, path={:?}", self.share_name, smb_path);
521+
522+
self.with_smb("create_file", |client, tree| {
523+
handle.block_on(client.write_file(tree, &smb_path, &data))
524+
})?;
525+
Ok(())
526+
}
527+
528+
fn create_directory(&self, path: &Path) -> Result<(), VolumeError> {
529+
let smb_path = self.to_smb_path(path);
530+
let handle = self.runtime_handle.clone();
531+
532+
debug!(
533+
"SmbVolume::create_directory: share={}, path={:?}",
534+
self.share_name, smb_path
535+
);
536+
537+
self.with_smb("create_directory", |client, tree| {
538+
handle.block_on(client.create_directory(tree, &smb_path))
539+
})
540+
}
541+
542+
fn delete(&self, path: &Path) -> Result<(), VolumeError> {
543+
let smb_path = self.to_smb_path(path);
544+
let handle = self.runtime_handle.clone();
545+
546+
debug!("SmbVolume::delete: share={}, path={:?}", self.share_name, smb_path);
547+
548+
// Stat first to determine file vs directory
549+
let is_dir = {
550+
let h = handle.clone();
551+
let sp = smb_path.clone();
552+
self.with_smb("delete(stat)", |client, tree| h.block_on(client.stat(tree, &sp)))?
553+
.is_directory
554+
};
555+
556+
if is_dir {
557+
self.with_smb("delete_directory", |client, tree| {
558+
handle.block_on(client.delete_directory(tree, &smb_path))
559+
})
560+
} else {
561+
self.with_smb("delete_file", |client, tree| {
562+
handle.block_on(client.delete_file(tree, &smb_path))
563+
})
564+
}
565+
}
566+
567+
fn rename(&self, from: &Path, to: &Path, force: bool) -> Result<(), VolumeError> {
568+
let smb_from = self.to_smb_path(from);
569+
let smb_to = self.to_smb_path(to);
570+
let handle = self.runtime_handle.clone();
571+
572+
debug!(
573+
"SmbVolume::rename: share={}, from={:?}, to={:?}, force={}",
574+
self.share_name, smb_from, smb_to, force
575+
);
576+
577+
if force {
578+
// Check if dest exists and delete it first
579+
let h = handle.clone();
580+
let dest = smb_to.clone();
581+
let dest_exists = self
582+
.with_smb("rename(stat_dest)", |client, tree| h.block_on(client.stat(tree, &dest)))
583+
.is_ok();
584+
585+
if dest_exists {
586+
let h = handle.clone();
587+
let dest = smb_to.clone();
588+
// Try file delete first; if that fails (it's a dir), try directory delete
589+
let file_result = self.with_smb("rename(delete_dest_file)", |client, tree| {
590+
h.block_on(client.delete_file(tree, &dest))
591+
});
592+
if file_result.is_err() {
593+
let h = handle.clone();
594+
let dest = smb_to.clone();
595+
self.with_smb("rename(delete_dest_dir)", |client, tree| {
596+
h.block_on(client.delete_directory(tree, &dest))
597+
})?;
598+
}
599+
}
600+
} else {
601+
// Check if dest exists and return AlreadyExists if so
602+
let h = handle.clone();
603+
let dest = smb_to.clone();
604+
if self
605+
.with_smb("rename(check_dest)", |client, tree| {
606+
h.block_on(client.stat(tree, &dest))
607+
})
608+
.is_ok()
609+
{
610+
return Err(VolumeError::AlreadyExists(to.display().to_string()));
611+
}
612+
}
613+
614+
self.with_smb("rename", |client, tree| {
615+
handle.block_on(client.rename(tree, &smb_from, &smb_to))
616+
})
617+
}
618+
619+
fn supports_export(&self) -> bool {
620+
true
621+
}
622+
623+
fn export_to_local(&self, source: &Path, local_dest: &Path) -> Result<u64, VolumeError> {
624+
let smb_path = self.to_smb_path(source);
625+
let handle = self.runtime_handle.clone();
626+
627+
debug!(
628+
"SmbVolume::export_to_local: share={}, source={:?}, dest={}",
629+
self.share_name,
630+
smb_path,
631+
local_dest.display()
632+
);
633+
634+
// Check if source is a directory or file
635+
let is_dir = if smb_path.is_empty() {
636+
true
637+
} else {
638+
let h = handle.clone();
639+
let sp = smb_path.clone();
640+
self.with_smb("export_to_local(stat)", |client, tree| {
641+
h.block_on(client.stat(tree, &sp))
642+
})?
643+
.is_directory
644+
};
645+
646+
if is_dir {
647+
self.export_directory_recursive(&smb_path, local_dest)
648+
} else {
649+
self.export_single_file(&smb_path, local_dest)
650+
}
651+
}
652+
653+
fn import_from_local(&self, local_source: &Path, dest: &Path) -> Result<u64, VolumeError> {
654+
let smb_path = self.to_smb_path(dest);
655+
656+
debug!(
657+
"SmbVolume::import_from_local: share={}, source={}, dest={:?}",
658+
self.share_name,
659+
local_source.display(),
660+
smb_path
661+
);
662+
663+
if local_source.is_dir() {
664+
self.import_directory_recursive(local_source, &smb_path)
665+
} else {
666+
self.import_single_file(local_source, &smb_path)
667+
}
668+
}
669+
670+
fn scan_for_copy(&self, path: &Path) -> Result<CopyScanResult, VolumeError> {
671+
let smb_path = self.to_smb_path(path);
672+
673+
debug!(
674+
"SmbVolume::scan_for_copy: share={}, path={:?}",
675+
self.share_name, smb_path
676+
);
677+
678+
let mut result = CopyScanResult {
679+
file_count: 0,
680+
dir_count: 0,
681+
total_bytes: 0,
682+
};
683+
684+
self.scan_recursive(&smb_path, &mut result)?;
685+
Ok(result)
686+
}
687+
688+
fn scan_for_conflicts(
689+
&self,
690+
source_items: &[SourceItemInfo],
691+
dest_path: &Path,
692+
) -> Result<Vec<ScanConflict>, VolumeError> {
693+
// List destination directory to check for conflicts
694+
let entries = self.list_directory(dest_path)?;
695+
let mut conflicts = Vec::new();
696+
697+
for item in source_items {
698+
if let Some(existing) = entries.iter().find(|e| e.name == item.name) {
699+
let dest_modified = existing.modified_at.map(|ms| (ms / 1000) as i64);
700+
conflicts.push(ScanConflict {
701+
source_path: item.name.clone(),
702+
dest_path: existing.path.clone(),
703+
source_size: item.size,
704+
dest_size: existing.size.unwrap_or(0),
705+
source_modified: item.modified,
706+
dest_modified,
707+
});
708+
}
709+
}
710+
711+
Ok(conflicts)
712+
}
713+
382714
fn smb_connection_state(&self) -> Option<crate::volumes::SmbConnectionState> {
383715
match self.connection_state() {
384716
ConnectionState::Direct => Some(crate::volumes::SmbConnectionState::Direct),
@@ -679,6 +1011,12 @@ mod tests {
6791011
assert!(vol.local_path().is_none());
6801012
}
6811013

1014+
#[test]
1015+
fn supports_export_returns_true() {
1016+
let (vol, _rt) = make_test_volume();
1017+
assert!(vol.supports_export());
1018+
}
1019+
6821020
/// Creates a test SmbVolume in disconnected state (no real connection).
6831021
///
6841022
/// Uses a dedicated single-threaded runtime since tests don't run

0 commit comments

Comments
 (0)