fix(tokio): send close_notify on finalize_retr_stream to fix TLS 1.3 data-channel 426 - #174
Merged
veeso merged 3 commits intoAug 31, 2026
Merged
Conversation
|
✔️ ebc6885 - Conventional commits check succeeded. |
finalize_retr_stream() (used by retr()/list()/nlst()/mlsd() on the tokio backend) dropped the data-connection stream without a graceful shutdown, unlike finalize_put_stream() which already calls stream.shutdown(). For a plain TCP data stream this is harmless, but for a TLS-secured FTPS data channel it means no close_notify is sent. TLS 1.2 servers tolerate the abrupt close (session-ID based resumption apparently masks it), but TLS-1.3-strict servers reply "426 Transfer failed (unable to close data connection gracefully)" even though the transfer already completed — reproduced live against test.rebex.net (public FTPS server, TLS 1.3) with RUST_LOG=trace: rustls confirms `Resuming using PSK` and the full LIST payload is read before the 426 appears, ruling out a session-resumption mismatch. The 426 only goes away when the data stream shuts down cleanly. This widens finalize_retr_stream()'s bound from `impl AsyncRead` to `impl AsyncRead + AsyncWriteExt + Unpin` (matching finalize_put_stream's existing bound) and sends the close_notify before dropping. Shutdown errors are ignored, mirroring the fact that the data has already been fully read by this point — a failed shutdown must not fail an otherwise-successful transfer (finalize_put_stream is stricter here since for uploads the write isn't confirmed complete until shutdown succeeds). Verified with a small standalone client exercising both an unrestricted rustls config (negotiates TLS 1.3) and one capped at TLS 1.2 against test.rebex.net: before this fix, only the TLS-1.2-capped path completed LIST; after, both do. Note: sync_ftp.rs and the async-std smol_ftp.rs backend have the same drop-without-shutdown pattern in their finalize_retr_stream. I only patched and verified the tokio backend (the one exercised above); the other two likely need the equivalent fix but I haven't tested them. BREAKING CHANGE: tokio finalize_retr_stream now requires streams to implement AsyncWrite and Unpin.
Mirror Tokio retrieval finalization by closing smol data streams before reading the final control response. Add deterministic coverage for both runtimes and document the response-authority policy. BREAKING CHANGE: smol finalize_retr_stream now requires streams to implement AsyncWrite and Unpin.
veeso
force-pushed
the
fix/tokio-finalize-retr-close-notify
branch
from
August 31, 2026 08:47
5393c5c to
66f6db4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
finalize_retr_stream()on the tokio backend (crates/suppaftp/src/async_ftp/tokio_ftp.rs) drops the data-connection stream without a graceful shutdown, unlikefinalize_put_stream(), which already callsstream.shutdown().await. For a plain TCP data stream this is harmless, but for a TLS-secured FTPS data channel it means noclose_notifyis ever sent.TLS 1.2 servers tolerate the abrupt close. TLS-1.3-strict servers reply
426 Transfer failed (unable to close data connection gracefully)even though the transfer already completed — this is what forces downstream consumers of this crate to cap theirrustls::ClientConfigat TLS 1.2 for FTPS to work at all.Root cause / how I verified it
Reproduced live against
test.rebex.net(public FTPS server, TLS 1.3) withRUST_LOG=trace. My first hypothesis was a TLS-session-resumption mismatch between the control and data channel (session-ID vs. session-ticket schemes), but the trace ruled that out: rustls logsResuming using PSK, and the fullLISTpayload is read from the data stream before the 426 shows up. The 426 only disappears once the data stream is shut down cleanly before being dropped — i.e. it's purely about the missingclose_notify, not resumption.I exercised this with a small standalone client running two scenarios against
test.rebex.net: an unrestrictedrustls::ClientConfig(negotiates TLS 1.3) and one capped at TLS 1.2. Before this fix, only the TLS-1.2-capped path completedLIST; after this fix, both do.Change
Widens
finalize_retr_stream()'s bound fromimpl AsyncReadtoimpl AsyncRead + AsyncWriteExt + Unpin(matchingfinalize_put_stream's existing bound) and sendsclose_notifybefore dropping. All existing callers (retr(),list()/nlst()/mlsd()viastream_lines(), and the test suite) already pass aDataStream<T>or aBufReaderwrapping one, both of which implementAsyncWrite, so no call sites needed to change.Shutdown errors are ignored deliberately: the data has already been fully read by the time
finalize_retr_streamruns, so a failed shutdown must not fail an otherwise-successful transfer. This is intentionally looser thanfinalize_put_stream, which still propagates the shutdown error (for uploads, the write isn't confirmed complete until shutdown succeeds).Not included
sync_ftp.rsand the async-stdsmol_ftp.rsbackend have the same drop-without-shutdown pattern in theirfinalize_retr_stream. I only patched and verified the tokio backend (the one exercised above) — the other two likely need the equivalent fix but I haven't tested them, so I left them out of this PR rather than guess.Testing
cargo build -p suppaftp --no-default-features --features tokio-rustls-ring— compiles clean.test.rebex.net(both TLS 1.2-capped and unrestricted/TLS 1.3 configs) — both completeLISTafter this fix; only the capped one did before.