Skip to content

Leave the original file alone when a download fails, and widen upload writes - #142

Open
Sadykhzadeh wants to merge 9 commits into
mainfrom
fix/sftp-transfer-integrity
Open

Sadykhzadeh wants to merge 9 commits into
mainfrom
fix/sftp-transfer-integrity

Conversation

@Sadykhzadeh

@Sadykhzadeh Sadykhzadeh commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Closes #141. Stacked on #140, which narrows the SFTP lock this code runs under. Merge #140 first; this PR's base then becomes main.

What this changes

Downloads never leave a partial file

write_download now owns the local side of a download.

  • Overwrite (the only mode the app uses, after the save dialog has confirmed): the data goes to .<name>.<uuid>.part beside the target. It is flushed, sync_all'd, then renamed over the target. On any error the .part file is removed and the original is untouched. A rename in the same directory is atomic on every platform the app ships on, so a reader sees either the old file or the complete new one.
  • New file: written in place with create_new as before, and removed if the transfer fails.

Cancellation is covered too. #140 now ends a transfer as soon as its session closes, by dropping the transfer future. A PartialDownload guard removes the file written so far whenever it is dropped before the download is in place, so that path leaves nothing behind either. The .part file is only left behind if the process itself dies mid-download. It is a dot-file named after the target, so it is easy to recognise.

Uploads keep more data in flight

The local file is read through a 256 KiB BufReader with copy_buf, so each poll_write hands russh-sftp a full-size write. With its 16-write pipeline that is about 4× the in-flight data tokio::io::copy's 8 KiB reads allowed, which matters on long links.

Tests

New unit tests in core/src/sftp.rs:

  • an_overwrite_replaces_the_file_only_once_complete: the target holds the new content and no .part is left.
  • a_failed_overwrite_keeps_the_original_and_no_partial_file: a source that fails part way, like a dropped connection, leaves the original content and nothing else in the directory.
  • a_failed_new_download_leaves_nothing_behind
  • a_download_without_overwrite_keeps_an_existing_file
  • a_cancelled_download_leaves_the_original_and_nothing_else: a source that goes quiet is cancelled by a timeout, for both an overwrite and a new file, and only the original file remains

Test plan

  • cargo test -p clavyn-core: all suites pass (--lib sftp: 7)
  • node scripts/verify/check-comments.mjs: ok
  • CI

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

Review follow-up

  • Closing a session stops only the copy. The open, the rename and the cleanup always run to completion as ordinary awaits. A file that cannot be removed is logged.
  • The replaced file's permissions survive. The staging file is created 0600 and takes the replaced file's permissions before the rename.
  • Symlinks are followed. The file a link names is replaced, and the link stays a link.
  • Long names still work. Staging names keep only the first 32 characters of the target's name, so a target at the length limit still works.
  • New files skip staging. A download to a name that does not exist yet writes it directly; there is nothing to protect.
  • New tests: an_overwrite_keeps_the_original_permissions, an_overwrite_through_a_link_replaces_the_file_it_names, an_overwrite_works_for_a_name_at_the_length_limit, a_download_stopped_by_a_close_leaves_the_original_and_nothing_else.
  • Core suite: cargo test -p clavyn-core passes in full (126 unit tests + all integration suites).

SessionManager and SftpManager each kept one map lock held across every
network await: a keystroke send, a disconnect, a directory listing, and in
SFTP's case an entire file transfer. One peer that stopped reading, or a
large download, therefore stalled every other session, and close_all could
not drain the map. The vault reset gives close_all five seconds and then
drops it, so an SFTP session busy in a transfer or an endless listing stayed
reachable after the reset that was meant to cut it off.

Entries are now Arc'd and looked up under a short lock; the I/O runs after
the guard is released. Writes to one SSH session still go out in order
through a per-session lock. close and close_all remove entries first and
bound the goodbye. SFTP listings get a 30 second overall deadline, since
russh-sftp reads until the server says end-of-file with no total limit.

The SSH reader also removes its own entry and disconnects when the remote
shell exits, instead of leaving the connection and its keepalives up until
the pane is closed, and it no longer spins on a closed resize channel while
a close is in flight.
@greptile-apps

greptile-apps Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

TREX TREX could not finish testing

  • Plan what to test
  • Set up the environment and test the changed flows
  • Post test results

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Preserve local files on failed SFTP downloads and buffer uploads

🐞 Bug fix ✨ Enhancement 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Stage overwrite downloads until complete, preserving the original file if a transfer fails.
• Remove incomplete new downloads and buffer upload reads to increase data in flight.
• Test successful replacement, failed-transfer cleanup, and refusal to overwrite without permission.
Diagram

graph TD
  S["SFTP manager"] --> D{"Overwrite?"} -->|yes| P["Staged file"] --> F["Sync and rename"] --> T["Local target"]
  D -->|no| T
  P -->|error| C["Remove partial"]
  T -->|write error| C
  S --> U["Buffered upload"] --> R["Remote file"]
Loading
High-Level Assessment

Keep the adjacent staging-file approach: it preserves the existing target during failed overwrites and allows replacement after a completed write. A temporary file elsewhere would not provide the same straightforward rename path.

Files changed (1) +136 / -10

Bug fix (1) +136 / -10
sftp.rsStage overwrite downloads and widen upload writes +136/-10

Stage overwrite downloads and widen upload writes

• Download writes now stage overwrites, sync before renaming, and remove incomplete staged or new files on transfer errors. Uploads use a 256 KiB buffered reader with copy_buf. New unit tests cover replacement, failed-write cleanup, and refusal to overwrite an existing file without permission.

core/src/sftp.rs

@qodo-code-review

qodo-code-review Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Code Review by Qodo


Comment thread core/src/sftp.rs Resolved
Comment thread core/src/sftp.rs Resolved
Comment thread core/src/sftp.rs Resolved
Comment thread core/src/sftp.rs Resolved
Taking an entry out of the map stopped new calls from reaching a session,
but a call already holding the entry kept it alive: a keystroke send
stuck behind a peer that stopped reading, or an SFTP listing or transfer
still running, held the connection and the session id until it finished
on its own.

Each SSH session and SFTP connection now carries a closed signal that
close, close_all and the reader's own cleanup flip. Writes, listings and
transfers race against it and return an error once it fires. The goodbye
sent after a close holds only the connection handle, so it no longer
keeps the session id claimed while a stalled peer fails to answer.
@Sadykhzadeh
Sadykhzadeh force-pushed the fix/sftp-transfer-integrity branch from 44d28c4 to 66203b9 Compare October 16, 2026 23:08
Comment thread core/src/sftp.rs Resolved
Comment thread core/src/sftp.rs Resolved
Comment thread core/src/sftp.rs Resolved
Comment thread core/src/sftp.rs Resolved
Listings and transfers already stopped when their connection closed, but
mkdir, remove, rename, canonicalize and stat did not: a request issued
just before a close or a vault reset could still complete and report
success after teardown began. Every SftpManager operation now runs under
the connection's closed signal.
… writes

An overwrite download truncated the user's file before the first byte
arrived, so a dropped connection or a server timeout part way through
left a cut-off file where their original had been. Overwrites now go to a
temporary file beside the target that is renamed over it once it is
complete and synced; a new file is removed again if the transfer fails.

Uploads read through a 256 KiB buffer. russh-sftp pipelines up to 16
writes, each the size of the slice it is given, and tokio::io::copy's
8 KiB reads capped that at 128 KiB in flight per round trip.
…anly

Closing a session dropped the download future, which could interrupt the
local open or the final rename halfway and leave a file behind. The
close now stops only the copy, and the download cleans up after itself
with ordinary awaits, logging a file it could not remove.

The staging file is created owner-only and takes the replaced file's
permissions before the rename, so a 0600 file is not left world-readable
under a 022 umask. A symbolic link is resolved first, so the file it
names is replaced and the link survives. Staging names keep only the
start of the target's name, so a name at the length limit still fits,
and a download to a name that does not exist yet writes it directly.
@Sadykhzadeh
Sadykhzadeh force-pushed the fix/sftp-transfer-integrity branch from 66203b9 to c96ad56 Compare October 17, 2026 06:43
Comment thread core/src/sftp.rs Resolved
A second download that chose to overwrite could rename its finished file
over the path before the first one failed; the first then deleted the
second's result by name.
Comment thread core/src/sftp.rs Resolved
Sadykhzadeh and others added 2 commits October 17, 2026 08:51
Removal no longer depends on the handle having been opened with delete
sharing on Windows.
A download to a new name was written in place, so quitting the app or a
crash part way left a truncated file under the final name, and on Windows
a failed download could still delete a finished file another download had
put at the same path. Every download now goes through a staging file. A
download that may not overwrite takes its name through a hard link, which
refuses an existing name in the same step, so a failure only ever removes
its own staging file.

Replacing by rename needs write access only to the directory, so a file
marked read-only was silently replaced. The target is now opened for
writing first and refused if that fails, and only a regular file is
replaced. Set-ID bits are not carried over, and a failed chmod is logged
instead of discarding a complete download.

An overwrite through a dangling symlink failed with "local file already
exists"; links are now followed with read_link, so the file they name is
created. A close that lands after the last byte, while the file is being
synced, still stops the download before it is put in place, and it reports
the same SSH error as every other operation. Local writes go through a
256 KiB buffer, the handle is closed only after tokio's in-flight write
finishes, and the directory is synced after the rename.

The upload buffer comment claimed russh-sftp sends writes as large as the
slice it is given; it caps them at 32 KiB by default, so the buffer keeps
packets full (about 512 KiB in flight) rather than 4 MiB.
@computerbox124

Copy link
Copy Markdown
Member

@codex review the PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-13T11:31:34.593849Z dbcf4a4 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dbcf4a4d87

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread core/src/sftp.rs
Comment on lines +619 to +620
}
path

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject symlink chains that exhaust the traversal limit

When an overwrite targets a chain of 41 symbolic links, the loop returns the 41st link rather than rejecting the excessive chain. replaceable then follows the remaining link successfully, but publish renames the staging file over that link itself, destroying an intermediate symlink and leaving the actual destination file unchanged while reporting success. The previous direct open rejected this path with ELOOP. Return an error if the traversal limit is exhausted and the resulting path is still a symlink, rather than passing that unresolved link to the replacement logic.

Useful? React with 👍 / 👎.

#140 landed on main as one squashed commit, while this branch still
carried its earlier commits, so session.rs, commands.rs, sftp.rs and
session_isolation.rs conflicted. Each takes main's version, which is
#140's final state, and this branch's own changes are carried over on
top of it.

download_to_local keeps main's per-destination lock, so of two downloads
to one local file the one started last is left in place, and claims it
and opens the remote file under until_closed. The copy then runs through
write_download against the connection's CloseSignal, so a close stops
the download without cutting its cleanup short.

a_download_waits_for_one_already_writing_the_same_local_file watched the
target grow, which a staged download never shows until it is complete.
It now watches the staging file, checks the first download is still
running when the second starts, and that no staging file is left.
@computerbox124
computerbox124 changed the base branch from fix/session-lock-scope to main November 13, 2026 11:49
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.

A failed SFTP download truncates the file it was overwriting, and uploads use 8 KiB writes

2 participants