Skip to content

fix(ssh): map mkdir-on-existing-dir to AlreadyExists so dir-merge copy works - #172

Merged
zoza1982 merged 2 commits into
mainfrom
fix/ssh-create-dir-already-exists
Jul 9, 2026
Merged

fix(ssh): map mkdir-on-existing-dir to AlreadyExists so dir-merge copy works#172
zoza1982 merged 2 commits into
mainfrom
fix/ssh-create-dir-already-exists

Conversation

@zoza1982

@zoza1982 zoza1982 commented Jul 9, 2026

Copy link
Copy Markdown
Owner

What

Copying a directory onto a remote SFTP directory that already exists failed with Copy failed: …
(the reporter saw Copy failed: not found). This fixes it so a directory-merge copy proceeds.

Root cause

OpenSSH's sftp-server reports mkdir on an existing path as a generic SSH_FX_FAILURE — the
SFTP protocol has no dedicated "already exists" status — and different server builds phrase it
differently ("Failure" on the OpenSSH box I reproduced against, "not found" on the reporter's
DietPi host). The SSH backend surfaced this as an opaque VfsError::Backend, but the transfer
engine's copy_tree only tolerates VfsError::AlreadyExists:

match dst.create_dir(&t).await {
    Ok(()) => outcome.dirs += 1,
    Err(VfsError::AlreadyExists(_)) => {}   // ← only this is tolerated
    Err(e) => return Err(e.into()),         // ← everything else aborts the whole copy
}

Since the destination folder almost always already exists (you're copying into it), every such
copy aborted.

Fix

SftpVfs::create_dir now disambiguates server-agnostically: on a failed mkdir, it stats the
path — if it's already a directory, it returns AlreadyExists (matching every other backend, so the
merge proceeds); otherwise it surfaces the original error unchanged. The extra stat happens only on
the failure path
, not the happy path. A collision with an existing file still surfaces the real
error (not masked as AlreadyExists).

Reproduction & testing

  • Integration test (tests/copy_repro.rs, CAIRN_IT_SFTP=1): drives the real OpenSSH
    sftp-server as the transfer destination and runs the actual cairn_transfer::run_transfer
    local→SFTP. Before the fix: Err(Vfs(Backend { msg: "Failure: Failure" })). After:
    Ok(TransferOutcome { files: 2, dirs: 1, bytes: 10 }).
  • Hermetic unit test: MockSftp made faithful (rejects mkdir on an existing path like the real
    server); create_dir on an existing dir → AlreadyExists, on an existing file → the real
    Backend error.
  • Full crate suite green; fmt + clippy --all-targets --all-features -D warnings clean.

Adds dev-deps cairn-transfer + cairn-backend-local to the SSH crate so the engine and backend can
be exercised together against a real server (test-only; no impact on the shipped/lean build).

Follow-up (noted, not in this PR)

map_err in real.rs classifies SFTP errors by matching on the stringified message. Matching
russh-sftp's typed StatusCode instead would be more robust; tracked separately.

Review gates

bug-bot and code-review run on the diff.

🤖 Generated with Claude Code

zoza1982 and others added 2 commits July 8, 2026 23:09
…y works

Copying a directory onto a remote SFTP path that already exists failed with
"Copy failed: …" (the reporter saw "not found"). OpenSSH's sftp-server reports
`mkdir` on an existing path as a generic SSH_FX_FAILURE — there is no dedicated
"already exists" status — and different servers phrase it differently ("Failure"
vs "not found"). The SSH backend surfaced this as an opaque `Backend` error, but
the transfer engine's `copy_tree` only tolerates `VfsError::AlreadyExists`; any
other error aborts the whole copy. So merging into an existing remote directory
(the common case: the destination folder is already there) always failed.

Fix `SftpVfs::create_dir` to disambiguate server-agnostically: on failure, stat
the path — if it's already a directory, return `AlreadyExists` (matching every
other backend, which lets the merge copy proceed); otherwise surface the original
error unchanged (a real failure, or a collision with a file, still propagates).

- Make the `MockSftp` faithful: `mkdir` on an existing path now fails like the
  real server (generic SSH_FX_FAILURE), so this is covered hermetically.
- Unit test (mock): create_dir on an existing dir → AlreadyExists; on an existing
  file → the real Backend error, not masked.
- Integration test (`tests/copy_repro.rs`, `CAIRN_IT_SFTP=1`): drives the real
  OpenSSH sftp-server as the transfer destination and runs the actual
  `cairn_transfer::run_transfer` local→SFTP — reproduces the abort before the fix
  and passes after (files: 2, dirs: 1). Adds dev-deps cairn-transfer +
  cairn-backend-local to exercise the engine and backend together end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rts safely

Review follow-up (bug-bot): the recovery probe used `stat`, which follows
symlinks — so a symlink-to-directory at the destination would be treated as the
directory it targets, and the merge copy would descend and write the source's
children *through* the link into its target, possibly outside the destination
tree. Use `lstat` instead: a symlink classifies as `Symlink` (not `Dir`), so we
fall through and surface the original mkdir error and the copy aborts, mirroring
the deliberate `lstat` caution already used in `remove`. Only a genuine directory
maps to `AlreadyExists`. Adds a regression test for the symlink-dest case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@zoza1982
zoza1982 merged commit cb143a0 into main Jul 9, 2026
9 checks passed
@zoza1982
zoza1982 deleted the fix/ssh-create-dir-already-exists branch July 9, 2026 04:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant