Skip to content

Add SFTP client support (draft-ietf-secsh-filexfer)#112

Open
GlassOnTin wants to merge 10 commits intokruton:mainfrom
GlassOnTin:sftp-client
Open

Add SFTP client support (draft-ietf-secsh-filexfer)#112
GlassOnTin wants to merge 10 commits intokruton:mainfrom
GlassOnTin:sftp-client

Conversation

@GlassOnTin
Copy link
Copy Markdown
Contributor

Summary

Implements a full SFTPv3 client on top of the existing SSH subsystem channel support (#96), providing file transfer operations over SSH.

Public API:

  • SftpClient interface — all file I/O, stat, directory, and path operations as suspend functions
  • SftpAttributes, SftpFileHandle, SftpDirectoryEntry data types
  • SftpOpenFlag, SftpStatusCode enums
  • SftpException for protocol-level errors
  • SshClient.openSftp() / BlockingSshClient.openSftp() entry points

Internal architecture:

  • SftpPacketIO — SFTP packet framing with chunk accumulation over the SSH channel byte stream
  • SftpDispatcher — request ID sequencing and coroutine-based response routing via ConcurrentHashMap<Int, CompletableDeferred>
  • SftpFileAttributes — SFTPv3 ATTRS parser/serializer with flags-based conditional fields
  • SftpClientImpl — full protocol implementation including INIT/VERSION handshake

Design decisions:

  • Same module (sshlib) as discussed in SFTP subsystem support — architecture proposal #96
  • Manual ByteBuffer serialization instead of Kaitai Struct — SFTP's binary format is simple and the ATTRS structure needs bitwise flag conditions that Kaitai's read-write mode doesn't support well
  • Concurrent pipelined requests supported via atomic request IDs and deferred response matching
  • Handles OpenSSH's symlink argument order quirk (reversed from spec)
  • listdir() convenience method with default implementation in the interface

Tests:

  • Integration tests against OpenSSH 9.9p2 via testcontainers
  • Version negotiation, file CRUD, directory listing, stat, mkdir/rmdir, rename, large file transfer (64KB), permission setting, error handling

Metalava API surface updated in api.txt.

Closes #96

Test plan

  • ./gradlew :sshlib:test --tests "org.connectbot.sshlib.client.sftp.*" passes against OpenSSH container
  • Verify API surface: ./gradlew metalavaCheckCompatibilityRelease passes
  • Manual: connect to a real SSH server and transfer files via SshClient.openSftp()

session.close()
throw SshException("Server rejected SFTP subsystem request")
}
org.connectbot.sshlib.client.sftp.SftpClientImpl.create(session)
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Import dependencies instead of using the fully qualified name.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 811a145request() / 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
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This should return a sealed class instead of throwing an exception. Only throw exceptions on the BlockingSshClient.kt with a @Throws annotation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 811a145readPacket() (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)
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This should return a sealed class instead of throwing an exception.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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")
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This should return a sealed class instead of throwing an exception.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Already addressed in 712dd04openSftp() 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.
@GlassOnTin
Copy link
Copy Markdown
Contributor Author

Pushed 811a145 addressing the remaining feedback. Summary across the two commits (712dd04 + 811a145):

  • All public suspend functions on SshClient / SftpClient / SftpClientImpl / SftpDispatcher now return SftpResult<T> rather than throwing.
  • SftpPacketIO.readPacket / writePacket now return SftpResult<T> too — that was the last remaining throw site on the suspend layer (the dispatcher used to catch + translate; now the framing layer returns sealed directly).
  • Internal exceptions are scoped to a single private call chain (readExactreadPacket catches), and the SftpProtocolException class is retained only for the dispatcher-internal stop() signal.
  • BlockingSshClient.openSftp() is the only place @Throws(SftpException::class) lives, wrapping with runBlocking { … getOrThrow() }.
  • One pre-existing test breakage on the branch fixed in passing: SftpClientIntegrationTest's connect() / authenticatePassword() assertions now check the sealed-result variants upstream introduced.

Local :sshlib:test runs 426 unit tests green; the 5 testcontainers-based integration tests (including SftpClientIntegrationTest) skip because of a local Docker-image-substitution quirk in this dev environment, but they should run cleanly on PR CI.

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.
@GlassOnTin
Copy link
Copy Markdown
Contributor Author

Picked this back up — review feedback addressed inline above, plus a couple of follow-ups since then:

  • SftpPacketIO now returns SftpResult<T> from readPacket/writePacket. The throws moved to a private ChannelClosedException that readPacket catches and translates — the public surface is sealed-result throughout. SftpDispatcher consumes the new shape via when instead of try/catch.
  • SftpClient.kt:423 — replaced the fully-qualified java.lang.IllegalStateException with the import.
  • Test fixture — the integration test was passing OPENSSH_VERSION = "V_9_9_P2" (the GitHub tag) verbatim into the Dockerfile's wget, which expects the tarball name 9.9p2. Fixed.
  • Kaitai serialization bufferKaitaiUtils.toByteArray() had a fixed 16 KiB ByteBufferKaitaiStream, which overflows when SshMsgChannelData carries near-maxPacketSize (32 KiB) of payload. Exposed by the new SFTP large-file test (64 KiB write → 32 KiB chunks → BufferOverflowException). Now starts at 16 KiB and doubles on overflow up to a 1 MiB ceiling. Most messages still fit on the first try; only large channel-data writes hit the retry path.

CI status on the latest push (4ac6898):

  • Build and test (17) — 557/557 pass
  • Build and test (21) — 556/557; the only failure is RekeyTest > rekey triggered after interval elapses (TimeoutCancellationException at 5000ms). Looks like a timing flake — same test passes on Java 17, and RekeyTest isn't touched by this PR. I can't rerun jobs from the fork; mind retriggering or letting it shake out on the next 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.
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.

SFTP subsystem support — architecture proposal

2 participants