Add SFTP client support (draft-ietf-secsh-filexfer)#112
Add SFTP client support (draft-ietf-secsh-filexfer)#112GlassOnTin wants to merge 10 commits intokruton:mainfrom
Conversation
| session.close() | ||
| throw SshException("Server rejected SFTP subsystem request") | ||
| } | ||
| org.connectbot.sshlib.client.sftp.SftpClientImpl.create(session) |
There was a problem hiding this comment.
Import dependencies instead of using the fully qualified name.
There was a problem hiding this comment.
Done — the only IllegalStateException reference left at this site uses the bare class name (Kotlin auto-imports java.lang.IllegalStateException); no fully-qualified path needed.
| } | ||
| } catch (e: Exception) { | ||
| pending.remove(requestId) | ||
| throw e |
There was a problem hiding this comment.
A Kotlin library shouldn't throw unless it's something that can't be managed by the library itself (e.g., missing dependency in the runtime environment), but network issues or timeouts can be managed. In that case a sealed class can be used internally to the library or a sealed interface on the API of the library.
There was a problem hiding this comment.
Addressed in 811a145 — request() / writeRaw() / readRaw() now propagate the SftpResult from the framing layer directly instead of catching exceptions to translate. Only the withTimeout(...) { deferred.await() } path keeps a try/catch (Coroutines API throws on cancel/timeout, no result type to consume). startReadLoop switched to a when over the framing-layer result.
| * Read a complete SFTP packet. Blocks (suspends) until enough data arrives. | ||
| * | ||
| * @return Parsed SFTP packet (type + payload, without the length prefix) | ||
| * @throws SftpProtocolException if the channel closes before a complete packet |
There was a problem hiding this comment.
This should return a sealed class instead of throwing an exception. Only throw exceptions on the BlockingSshClient.kt with a @Throws annotation.
There was a problem hiding this comment.
Addressed in 811a145 — readPacket() (and writePacket()) now return SftpResult<…> instead of throwing. The readExact() private helper still throws an internal ChannelClosedException that readPacket catches and translates to SftpResult.IoError; that throw never escapes the file. Old SftpProtocolException retained only for the dispatcher-internal "session closed" signal in stop().
| /** | ||
| * Open a file. Returns a handle for subsequent read/write/close operations. | ||
| * | ||
| * @throws SftpException on server error (e.g. NO_SUCH_FILE, PERMISSION_DENIED) |
There was a problem hiding this comment.
This should return a sealed class instead of throwing an exception.
There was a problem hiding this comment.
Already done in 712dd04 — the SftpClient interface members all return SftpResult<…>; the BlockingSshClient wrapper exposes the throwing API with @Throws(SftpException::class) via runBlocking { … getOrThrow() }.
| return try { | ||
| logger.info("Opening SFTP session") | ||
| val session = conn.openSessionChannel() | ||
| ?: throw SshException("Failed to open session channel for SFTP") |
There was a problem hiding this comment.
This should return a sealed class instead of throwing an exception.
There was a problem hiding this comment.
Already addressed in 712dd04 — openSftp() returns SftpResult<SftpClient>. The "not authenticated" early return is now SftpResult.IoError(IllegalStateException("Not authenticated")); SftpClientImpl.create and the subsystem-rejection branch use SftpResult.ProtocolError(...).
Implements a full SFTPv3 client on top of the existing SSH subsystem channel support, providing file transfer operations over SSH. Public API: - SftpClient interface with file I/O, stat, directory, and path operations - SftpAttributes, SftpFileHandle, SftpDirectoryEntry data types - SftpOpenFlag and SftpStatusCode enums - SftpException for protocol errors - SshClient.openSftp() and BlockingSshClient.openSftp() entry points Internal implementation: - SftpPacketIO: SFTP packet framing with chunk accumulation over SSH channel - SftpDispatcher: request ID sequencing and coroutine-based response routing - SftpFileAttributes: SFTPv3 ATTRS parser/serializer with flags-based fields - SftpClientImpl: full protocol implementation with INIT/VERSION handshake All operations are suspend functions supporting concurrent pipelined requests via ConcurrentHashMap + CompletableDeferred response matching. Handles OpenSSH symlink argument order quirk (reversed from spec). Includes integration tests against OpenSSH 9.9p2 via testcontainers. Refs: kruton#96
Replace thrown SftpException/SftpProtocolException with a sealed SftpResult<T> interface (Success, ServerError, ProtocolError, IoError). All SftpClient methods now return SftpResult, making error handling structural rather than exception-based. - SftpResult.getOrThrow() provided for blocking API interop - SftpResult.getOrNull() for optional-style access - SftpClientImpl wraps dispatcher exceptions as IoError/ProtocolError - Updated integration tests to use getOrThrow() and check ServerError - Fixed fully qualified SftpClientImpl reference in SshClient (now imported)
Per kruton's review: - SshClient.openSftp() returns SftpResult<SftpClient> instead of nullable - SftpDispatcher.request/writeRaw/readRaw return SftpResult instead of throwing - SftpClientImpl.create() returns SftpResult instead of throwing - BlockingSshClient.openSftp() uses getOrThrow() with @throws annotation - Fix fully qualified StreamForwarder reference (internal, can't import) - Update integration tests for new return types - Regenerate Metalava API signature
Per @kruton's feedback on PR kruton#112: Kotlin library APIs shouldn't throw for things they can manage themselves (network errors, malformed packets). Throws are reserved for the BlockingSshClient wrapper layer (Java interop, @Throws-annotated). The previous "address review" commit converted the SftpClient and SshClient surfaces but left SftpPacketIO still throwing — SftpDispatcher caught the throws to translate to SftpResult, which kept the contract-on-the-wire correct but missed the design intent of keeping throws out of the suspend layer entirely. Changes: - SftpPacketIO.readPacket() now returns SftpResult<SftpRawPacket> instead of throwing. The two error paths (invalid packet length, channel closed mid-packet) become ProtocolError / IoError variants. The private readExact() helper still throws a new internal ChannelClosedException; readPacket catches and translates. Keeps the throw scoped to a single private call chain that never escapes the file. - SftpPacketIO.writePacket() now returns SftpResult<Unit>; underlying session.write() exceptions become IoError. - SftpDispatcher.request/writeRaw/readRaw now propagate the SftpResult from the framing layer directly instead of catching exceptions. Cleaner: only the timeout/await path keeps its catch (the Coroutines API throws on cancel / timeout, no result type to consume). - SftpDispatcher.startReadLoop's read loop uses a `when` on the SftpResult to handle each variant — IoError / ProtocolError drain pending requests with the matching exception, success case routes to the requestId match. - SftpClientIntegrationTest: pre-existing breakage on the branch (connect()/authenticatePassword() now return sealed types upstream). Updated test assertions to check for the Success variant.
|
Pushed 811a145 addressing the remaining feedback. Summary across the two commits (712dd04 + 811a145):
Local Re-requesting review when you have a moment. Happy to iterate further if any of the variants don't match the patterns you'd prefer for ssh-proto. |
CI failed on the previous push because of formatting violations across files I edited (SftpPacketIO, SftpDispatcher, the test) and a few that pre-existed on the branch (BlockingSshClient, SftpClientImpl, SftpFileAttributes).
The test was passing `V_9_9_P2` (the github-tag format) as the
build arg to Dockerfile, which used it verbatim in
`wget openssh-${VERSION}.tar.gz`. URL doesn't exist → docker build
fails → testcontainers reports `initializationError`. The
renovate annotation in the Dockerfile already documents the
mapping (`V_9_9_P2` → `9.9p2`) but no code did the conversion.
Pass the tarball name directly. Renovate bot can be configured
to write either form via its `extractVersion` setting if it
needs the tag form for tracking.
This is what's been failing the PR's CI ever since the original
push (712dd04, 2026-04-06) — not anything to do with the
sealed-result review feedback.
The fixed 16 KiB buffer in KaitaiUtils.toByteArray() overflows when SSH_MSG_CHANNEL_DATA carries near-maxPacketSize (32 KiB) of data — exposed by the new SFTP large-file integration test. Start at 16 KiB and double on BufferOverflowException up to a 1 MiB ceiling. Most SSH messages still fit on the first try; only large channel-data writes (SFTP, large port-forward chunks) hit the retry path.
|
Picked this back up — review feedback addressed inline above, plus a couple of follow-ups since then:
CI status on the latest push (
Re-requesting review. |
Last run had a flaky timing failure on Java 21 (RekeyTest's 'rekey triggered after interval elapses' hit the 5000ms timeout). RekeyTest passes on Java 17 and isn't touched by this PR — same test, same code, different JVM = environmental flake. Empty commit to retrigger and confirm.
Summary
Implements a full SFTPv3 client on top of the existing SSH subsystem channel support (#96), providing file transfer operations over SSH.
Public API:
SftpClientinterface — all file I/O, stat, directory, and path operations as suspend functionsSftpAttributes,SftpFileHandle,SftpDirectoryEntrydata typesSftpOpenFlag,SftpStatusCodeenumsSftpExceptionfor protocol-level errorsSshClient.openSftp()/BlockingSshClient.openSftp()entry pointsInternal architecture:
SftpPacketIO— SFTP packet framing with chunk accumulation over the SSH channel byte streamSftpDispatcher— request ID sequencing and coroutine-based response routing viaConcurrentHashMap<Int, CompletableDeferred>SftpFileAttributes— SFTPv3 ATTRS parser/serializer with flags-based conditional fieldsSftpClientImpl— full protocol implementation including INIT/VERSION handshakeDesign decisions:
sshlib) as discussed in SFTP subsystem support — architecture proposal #96listdir()convenience method with default implementation in the interfaceTests:
Metalava API surface updated in
api.txt.Closes #96
Test plan
./gradlew :sshlib:test --tests "org.connectbot.sshlib.client.sftp.*"passes against OpenSSH container./gradlew metalavaCheckCompatibilityReleasepassesSshClient.openSftp()