Skip to content

Commit 7dd9cfc

Browse files
committed
SMB merge fix + move-pipeline logging
Bumps `smb2` to 0.8.0 and wires the new typed error variants into Cmdr: - `map_smb_error`: `STATUS_OBJECT_NAME_COLLISION` → `VolumeError::AlreadyExists` instead of falling through to a generic IoError. This unblocks `copy_directory_streaming`'s merge-into-existing-directory path, which already swallows AlreadyExists. Without this, retrying a move into a partially- populated SMB directory blew up with "Protocol error: STATUS_OBJECT_NAME_COLLISION during Create". - SMB delete fast-path: replaces the substring match against `"FILE_IS_A_DIRECTORY"` with a typed `is_file_is_a_directory(&smb2::Error)` predicate. Same for the `rename(force)` dest-cleanup path, which previously retried delete_directory on ANY delete_file error (could mask a real PermissionDenied as a futile second attempt). - Reworded the misleading "SMB treats already-exists as a merge" comment in `volume_strategy.rs` — that wasn't true until smb2 0.8.0. Notes the version guard so future regressions are easier to spot. - Added WARN logging in `move_between_volumes` for the copy and source-delete steps, plus the operation-level error path. The user's first failed move produced only a generic FE "io_error" with no backend trace; now the file path and raw `VolumeError` show up in `cmdr.log`. - Added WARN logging in `LocalPosixVolume::delete` covering both the stat and the remove call, including the `io::ErrorKind` and raw errno. Same motivation: the move pipeline silently lost context here. Tests: - `map_smb_error_already_exists` and `is_file_is_a_directory_detects_typed_kind` pin the new mappings. - All 1568 existing tests still pass. Note: This commit does NOT yet address the FE error dialog softening (`friendly_error_from_volume_error` integration) or the recursive-delete bug in `move_between_volumes` that's the actual root cause of the original first- attempt failure — see follow-up discussion. # Conflicts: # apps/desktop/src-tauri/src/file_system/volume/smb.rs
1 parent dc5f0b4 commit 7dd9cfc

6 files changed

Lines changed: 113 additions & 51 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ mdns-sd = { version = "0.19", features = ["logging"] }
163163
# SMB2/3 protocol client for share enumeration (pure Rust, pipelined I/O).
164164
# When bumping: also re-vendor the test containers per
165165
# apps/desktop/test/smb-servers/.compose/VENDORED.md
166-
smb2 = "0.7.2"
166+
smb2 = "0.8.0"
167167
# NFD normalization for APFS collation and SMB path normalization
168168
unicode-normalization = "0.1"
169169

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

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -294,12 +294,34 @@ impl Volume for LocalPosixVolume {
294294
}
295295
Box::pin(async move {
296296
spawn_blocking(move || {
297-
let metadata = std::fs::symlink_metadata(&abs_path)?;
298-
if metadata.is_dir() {
299-
std::fs::remove_dir(&abs_path)?;
297+
let metadata = std::fs::symlink_metadata(&abs_path).map_err(|e| {
298+
log::warn!(
299+
target: "local_posix",
300+
"delete: stat failed for {}: {} (kind={:?}, errno={:?})",
301+
abs_path.display(),
302+
e,
303+
e.kind(),
304+
e.raw_os_error()
305+
);
306+
e
307+
})?;
308+
let result = if metadata.is_dir() {
309+
std::fs::remove_dir(&abs_path)
300310
} else {
301-
std::fs::remove_file(&abs_path)?;
302-
}
311+
std::fs::remove_file(&abs_path)
312+
};
313+
result.map_err(|e| {
314+
log::warn!(
315+
target: "local_posix",
316+
"delete: {} {} failed: {} (kind={:?}, errno={:?})",
317+
if metadata.is_dir() { "remove_dir" } else { "remove_file" },
318+
abs_path.display(),
319+
e,
320+
e.kind(),
321+
e.raw_os_error()
322+
);
323+
e
324+
})?;
303325
Ok(())
304326
})
305327
.await

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

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -129,22 +129,13 @@ fn fs_info_to_space_info(info: &smb2::client::tree::FsInfo) -> SpaceInfo {
129129
}
130130
}
131131

132-
/// Returns true if an `smb2::Error`'s display string indicates STATUS_FILE_IS_A_DIRECTORY.
133-
///
134-
/// smb2 0.7.x doesn't expose NT_STATUS as a typed enum yet — string match is the only option.
135-
fn is_file_is_a_directory_error(err: &smb2::Error) -> bool {
136-
// allowed-error-string-match: smb2 0.7.x doesn't expose NT_STATUS as a typed enum yet
137-
err.to_string().contains("FILE_IS_A_DIRECTORY")
138-
}
139-
140132
/// Converts an `smb2::Error` to `VolumeError`.
141133
fn map_smb_error(err: smb2::Error) -> VolumeError {
142134
use smb2::ErrorKind;
143-
if is_file_is_a_directory_error(&err) {
144-
return VolumeError::IsADirectory(err.to_string());
145-
}
146135
match err.kind() {
147136
ErrorKind::NotFound => VolumeError::NotFound(err.to_string()),
137+
ErrorKind::AlreadyExists => VolumeError::AlreadyExists(err.to_string()),
138+
ErrorKind::IsADirectory => VolumeError::IsADirectory(err.to_string()),
148139
ErrorKind::AccessDenied | ErrorKind::AuthRequired | ErrorKind::SigningRequired => {
149140
VolumeError::PermissionDenied(err.to_string())
150141
}
@@ -1291,7 +1282,7 @@ impl Volume for SmbVolume {
12911282
match file_result {
12921283
Ok(()) => {} // File deleted successfully
12931284
Err(VolumeError::IsADirectory(_)) => {
1294-
// It's a directory — try delete_directory
1285+
// Expected fall-through: path is a directory, retry with delete_directory.
12951286
let (tree, mut conn) = self.clone_session().await?;
12961287
let r = tree.delete_directory(&mut conn, &smb_path).await;
12971288
self.handle_smb_result("delete_directory", r)?;
@@ -1335,16 +1326,26 @@ impl Volume for SmbVolume {
13351326
};
13361327

13371328
if dest_exists {
1338-
// Try file delete first; if that fails (it's a dir), try directory delete
1329+
// Try file delete first; if it fails specifically because the path is a
1330+
// directory, try directory delete. Any other error (PermissionDenied,
1331+
// SharingViolation, …) propagates immediately instead of being masked
1332+
// by a second futile delete.
13391333
let file_result = {
13401334
let (tree, mut conn) = self.clone_session().await?;
13411335
let r = tree.delete_file(&mut conn, &smb_to).await;
13421336
self.handle_smb_result("rename(delete_dest_file)", r)
13431337
};
1344-
if file_result.is_err() {
1345-
let (tree, mut conn) = self.clone_session().await?;
1346-
let r = tree.delete_directory(&mut conn, &smb_to).await;
1347-
self.handle_smb_result("rename(delete_dest_dir)", r)?;
1338+
match file_result {
1339+
Ok(()) => {}
1340+
Err(VolumeError::IsADirectory(_)) => {
1341+
// Expected fall-through: dest is a directory, retry with delete_directory.
1342+
// Any other error (PermissionDenied, SharingViolation, …) propagates immediately
1343+
// instead of being masked by a second futile delete.
1344+
let (tree, mut conn) = self.clone_session().await?;
1345+
let r = tree.delete_directory(&mut conn, &smb_to).await;
1346+
self.handle_smb_result("rename(delete_dest_dir)", r)?;
1347+
}
1348+
Err(e) => return Err(e),
13481349
}
13491350
}
13501351
} else {
@@ -2040,29 +2041,42 @@ mod tests {
20402041
assert!(matches!(ve, VolumeError::IoError { .. }));
20412042
}
20422043

2044+
#[test]
2045+
fn map_smb_error_already_exists() {
2046+
// STATUS_OBJECT_NAME_COLLISION (returned by Create when the name exists) must
2047+
// surface as AlreadyExists so the volume_strategy merge-directory path can
2048+
// swallow it instead of bubbling a generic IO error to the user.
2049+
let err = smb2::Error::Protocol {
2050+
status: smb2::types::status::NtStatus::OBJECT_NAME_COLLISION,
2051+
command: smb2::types::Command::Create,
2052+
};
2053+
let ve = map_smb_error(err);
2054+
assert!(matches!(ve, VolumeError::AlreadyExists(_)));
2055+
}
2056+
20432057
#[test]
20442058
fn map_smb_error_file_is_a_directory() {
2045-
// STATUS_FILE_IS_A_DIRECTORY is returned by the server when delete_file is called on a dir.
2046-
// smb2 0.7.x surfaces this in the Display string, not as a typed ErrorKind variant.
2059+
// STATUS_FILE_IS_A_DIRECTORY is returned when delete_file is called on a dir.
2060+
// smb2 0.8.0 exposes this as the typed `ErrorKind::IsADirectory` variant, so
2061+
// `map_smb_error` surfaces it as `VolumeError::IsADirectory` — the delete
2062+
// fast-path matches on that to decide whether to retry with delete_directory.
20472063
let err = smb2::Error::Protocol {
20482064
status: smb2::types::status::NtStatus::FILE_IS_A_DIRECTORY,
20492065
command: smb2::types::Command::Create,
20502066
};
2051-
// Verify the helper correctly identifies the error.
2052-
assert!(is_file_is_a_directory_error(&err));
2053-
// Verify map_smb_error returns the typed variant.
20542067
let ve = map_smb_error(err);
20552068
assert!(matches!(ve, VolumeError::IsADirectory(_)));
20562069
}
20572070

20582071
#[test]
2059-
fn is_file_is_a_directory_error_negative() {
2060-
// Non-directory errors must not be misclassified.
2072+
fn map_smb_error_access_denied_is_not_misclassified() {
2073+
// Non-directory errors must not be classified as IsADirectory.
20612074
let err = smb2::Error::Protocol {
20622075
status: smb2::types::status::NtStatus::ACCESS_DENIED,
20632076
command: smb2::types::Command::Create,
20642077
};
2065-
assert!(!is_file_is_a_directory_error(&err));
2078+
let ve = map_smb_error(err);
2079+
assert!(matches!(ve, VolumeError::PermissionDenied(_)));
20662080
}
20672081

20682082
// ── Connection state tests ──────────────────────────────────────

apps/desktop/src-tauri/src/file_system/write_operations/volume_move.rs

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -197,13 +197,29 @@ pub async fn move_between_volumes(
197197
&|| {},
198198
)
199199
.await
200-
.map_err(|e| map_volume_error(&source_path.display().to_string(), e))?;
200+
.map_err(|e| {
201+
log::warn!(
202+
target: "move",
203+
"move_between_volumes: copy phase failed for {} -> {}: {}",
204+
source_path.display(),
205+
dest_item.display(),
206+
e
207+
);
208+
map_volume_error(&source_path.display().to_string(), e)
209+
})?;
201210

202-
// Delete source
203-
source_volume
204-
.delete(source_path)
205-
.await
206-
.map_err(|e| map_volume_error(&source_path.display().to_string(), e))?;
211+
// Delete source. Failures here leave a partial-move state (data at dest,
212+
// sources still at origin) — log loudly so the cause is visible in the
213+
// file log; without this the FE only sees a generic "io_error".
214+
source_volume.delete(source_path).await.map_err(|e| {
215+
log::warn!(
216+
target: "move",
217+
"move_between_volumes: source delete failed for {} after successful copy: {}",
218+
source_path.display(),
219+
e
220+
);
221+
map_volume_error(&source_path.display().to_string(), e)
222+
})?;
207223

208224
files_done += 1;
209225
bytes_done += bytes;
@@ -262,6 +278,12 @@ pub async fn move_between_volumes(
262278
log::info!("move_between_volumes: operation {} cancelled", operation_id_for_cleanup);
263279
}
264280
Err(e) => {
281+
log::warn!(
282+
target: "move",
283+
"move operation {} failed: {:?}",
284+
operation_id_for_cleanup,
285+
e
286+
);
265287
let _ = app_for_error.emit(
266288
"write-error",
267289
WriteErrorEvent {
@@ -462,6 +484,12 @@ async fn move_within_same_volume(
462484
log::info!("move_between_volumes: operation {} cancelled", operation_id_for_cleanup);
463485
}
464486
Err(e) => {
487+
log::warn!(
488+
target: "move",
489+
"move operation {} failed: {:?}",
490+
operation_id_for_cleanup,
491+
e
492+
);
465493
let _ = app_for_error.emit(
466494
"write-error",
467495
WriteErrorEvent {

apps/desktop/src-tauri/src/file_system/write_operations/volume_strategy.rs

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -106,22 +106,20 @@ async fn copy_directory_streaming(
106106
on_file_progress: &(dyn Fn(u64, u64) -> ControlFlow<()> + Sync),
107107
on_file_complete: &(dyn Fn() + Sync),
108108
) -> Result<u64, VolumeError> {
109-
// Ensure the destination directory exists. The create_directory call is
110-
// idempotent for in-memory/SMB/MTP (they treat "already exists" as a
111-
// merge), but we catch `AlreadyExists` just in case for LocalPosix.
109+
// Ensure the destination directory exists. Every backend is expected to
110+
// surface "already exists" as `VolumeError::AlreadyExists`; we swallow it
111+
// because that's the merge-into-existing-directory signal, not a failure.
112+
// (SMB needed smb2 ≥ 0.8.0 to typed-classify STATUS_OBJECT_NAME_COLLISION;
113+
// older versions leaked it as IoError and the merge path blew up.)
112114
match dest_volume.create_directory(dest_path).await {
113115
Ok(()) => {}
114116
Err(VolumeError::AlreadyExists(_)) => {}
115-
Err(e) => {
116-
// LocalPosix uses std::fs::create_dir which fails if the parent
117-
// doesn't exist; retry with create_dir_all semantics via the
118-
// default trait — but most backends implement this fine. Treat
119-
// NotSupported as a signal to skip the explicit mkdir and hope
120-
// write_from_stream creates parents as needed.
121-
if !matches!(e, VolumeError::NotSupported) {
122-
return Err(e);
123-
}
117+
Err(VolumeError::NotSupported) => {
118+
// Backend can't create directories at all; assume `write_from_stream`
119+
// will materialize parents on demand (LocalPosix does this via the
120+
// default `create_dir_all` semantics).
124121
}
122+
Err(e) => return Err(e),
125123
}
126124

127125
let entries = source_volume.list_directory(source_path, None).await?;

0 commit comments

Comments
 (0)