|
5 | 5 | //! but all Cmdr file operations go through smb2's pipelined I/O for better |
6 | 6 | //! performance and fail-fast behavior. |
7 | 7 |
|
8 | | -use super::{SpaceInfo, Volume, VolumeError}; |
| 8 | +use super::{CopyScanResult, ScanConflict, SourceItemInfo, SpaceInfo, Volume, VolumeError}; |
9 | 9 | use crate::file_system::listing::FileEntry; |
10 | 10 | use log::{debug, warn}; |
11 | 11 | use smb2::client::tree::Tree; |
@@ -199,6 +199,139 @@ impl SmbVolume { |
199 | 199 | } |
200 | 200 | } |
201 | 201 |
|
| 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 | + |
202 | 335 | /// Runs an smb2 operation, handling connection state transitions. |
203 | 336 | /// |
204 | 337 | /// On disconnection errors, transitions state to `Disconnected` (for now; |
@@ -379,6 +512,205 @@ impl Volume for SmbVolume { |
379 | 512 | Ok(fs_info_to_space_info(&info)) |
380 | 513 | } |
381 | 514 |
|
| 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 | + |
382 | 714 | fn smb_connection_state(&self) -> Option<crate::volumes::SmbConnectionState> { |
383 | 715 | match self.connection_state() { |
384 | 716 | ConnectionState::Direct => Some(crate::volumes::SmbConnectionState::Direct), |
@@ -679,6 +1011,12 @@ mod tests { |
679 | 1011 | assert!(vol.local_path().is_none()); |
680 | 1012 | } |
681 | 1013 |
|
| 1014 | + #[test] |
| 1015 | + fn supports_export_returns_true() { |
| 1016 | + let (vol, _rt) = make_test_volume(); |
| 1017 | + assert!(vol.supports_export()); |
| 1018 | + } |
| 1019 | + |
682 | 1020 | /// Creates a test SmbVolume in disconnected state (no real connection). |
683 | 1021 | /// |
684 | 1022 | /// Uses a dedicated single-threaded runtime since tests don't run |
|
0 commit comments