diff --git a/sshlib/api.txt b/sshlib/api.txt index a0fc640..0a011d0 100644 --- a/sshlib/api.txt +++ b/sshlib/api.txt @@ -433,8 +433,10 @@ package org.connectbot.sshlib { public interface SftpClient { method public void close(); method public suspend java.lang.Object? close(org.connectbot.sshlib.SftpFileHandle handle, kotlin.coroutines.Continuation>); + method public suspend java.lang.Object? copyData(org.connectbot.sshlib.SftpFileHandle srcHandle, long srcOffset, long length, org.connectbot.sshlib.SftpFileHandle dstHandle, long dstOffset, kotlin.coroutines.Continuation>); method public suspend java.lang.Object? fsetstat(org.connectbot.sshlib.SftpFileHandle handle, org.connectbot.sshlib.SftpAttributes attrs, kotlin.coroutines.Continuation>); method public suspend java.lang.Object? fstat(org.connectbot.sshlib.SftpFileHandle handle, kotlin.coroutines.Continuation>); + method @InaccessibleFromKotlin public java.util.Set getExtensions(); method @InaccessibleFromKotlin public int getProtocolVersion(); method @InaccessibleFromKotlin public boolean isOpen(); method public default suspend java.lang.Object? listdir(java.lang.String path, kotlin.coroutines.Continuation>>); @@ -453,6 +455,7 @@ package org.connectbot.sshlib { method public suspend java.lang.Object? stat(java.lang.String path, kotlin.coroutines.Continuation>); method public suspend java.lang.Object? symlink(java.lang.String targetPath, java.lang.String linkPath, kotlin.coroutines.Continuation>); method public suspend java.lang.Object? write(org.connectbot.sshlib.SftpFileHandle handle, long offset, byte[] data, kotlin.coroutines.Continuation>); + property public abstract java.util.Set extensions; property public abstract boolean isOpen; property public abstract int protocolVersion; } @@ -617,6 +620,7 @@ package org.connectbot.sshlib { method @InaccessibleFromKotlin public java.lang.String getEncryptionAlgorithms(); method @InaccessibleFromKotlin public java.lang.String getHostKeyAlgorithms(); method @InaccessibleFromKotlin public org.connectbot.sshlib.HostKeyVerifier getHostKeyVerifier(); + method @InaccessibleFromKotlin public long getKeepAliveIntervalMs(); method @InaccessibleFromKotlin public java.lang.String getKexAlgorithms(); method @InaccessibleFromKotlin public java.lang.String getMacAlgorithms(); method @InaccessibleFromKotlin public long getObscureKeystrokeTimingIntervalMs(); @@ -629,6 +633,7 @@ package org.connectbot.sshlib { property public String encryptionAlgorithms; property public String hostKeyAlgorithms; property public org.connectbot.sshlib.HostKeyVerifier hostKeyVerifier; + property public long keepAliveIntervalMs; property public String kexAlgorithms; property public String macAlgorithms; property public long obscureKeystrokeTimingIntervalMs; @@ -650,6 +655,7 @@ package org.connectbot.sshlib { method @InaccessibleFromKotlin public java.lang.String getHostKeyAlgorithms(); method @InaccessibleFromKotlin public org.connectbot.sshlib.HostKeyVerifier? getHostKeyVerifier(); method @InaccessibleFromKotlin public org.connectbot.sshlib.transport.IpVersion getIpVersion(); + method @InaccessibleFromKotlin public long getKeepAliveIntervalMs(); method @InaccessibleFromKotlin public java.lang.String getKexAlgorithms(); method @InaccessibleFromKotlin public java.lang.String getMacAlgorithms(); method @InaccessibleFromKotlin public long getObscureKeystrokeTimingIntervalMs(); @@ -666,6 +672,7 @@ package org.connectbot.sshlib { method @InaccessibleFromKotlin public void setHostKeyAlgorithms(java.lang.String); method @InaccessibleFromKotlin public void setHostKeyVerifier(org.connectbot.sshlib.HostKeyVerifier?); method @InaccessibleFromKotlin public void setIpVersion(org.connectbot.sshlib.transport.IpVersion); + method @InaccessibleFromKotlin public void setKeepAliveIntervalMs(long); method @InaccessibleFromKotlin public void setKexAlgorithms(java.lang.String); method @InaccessibleFromKotlin public void setMacAlgorithms(java.lang.String); method @InaccessibleFromKotlin public void setObscureKeystrokeTimingIntervalMs(long); @@ -682,6 +689,7 @@ package org.connectbot.sshlib { property public String hostKeyAlgorithms; property public org.connectbot.sshlib.HostKeyVerifier? hostKeyVerifier; property public org.connectbot.sshlib.transport.IpVersion ipVersion; + property public long keepAliveIntervalMs; property public String kexAlgorithms; property public String macAlgorithms; property public long obscureKeystrokeTimingIntervalMs; diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/SftpClient.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/SftpClient.kt index 02ac57d..6c5e8c4 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/SftpClient.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/SftpClient.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -46,6 +46,18 @@ interface SftpClient : AutoCloseable { /** The negotiated SFTP protocol version (typically 3). */ val protocolVersion: Int + /** + * SFTP protocol extensions the server advertised as `extension-name`/`extension-data` + * pairs trailing its `SSH_FXP_VERSION` reply (draft-ietf-secsh-filexfer-02 section 3). + * Only the names are kept; extension-specific data (if any) is discarded. + * + * Common OpenSSH extensions found here: `"copy-data"` (see [copyData]), + * `"posix-rename@openssh.com"`, `"hardlink@openssh.com"`, `"fsync@openssh.com"`, + * `"statvfs@openssh.com"`. Empty if the server advertised none (or a server this old + * predates extensions entirely). + */ + val extensions: Set + /** Whether this SFTP session is still open. */ val isOpen: Boolean @@ -146,6 +158,37 @@ interface SftpClient : AutoCloseable { /** Rename or move a file. */ suspend fun rename(oldPath: String, newPath: String): SftpResult + // --- Server-side data copy (OpenSSH extension) --- + + /** + * Copies [length] bytes from [srcHandle] at [srcOffset] into [dstHandle] at [dstOffset], + * entirely on the server — no data crosses the wire. This is the `"copy-data"` SFTP + * protocol extension OpenSSH added in 9.0 (April 2022); it lets the server use an + * efficient server-side copy (e.g. `copy_file_range()` on Linux) instead of the client + * reading the whole file and writing it back, and works even for accounts restricted to + * `internal-sftp` with no shell access (where server-side `cp` via SSH exec cannot run + * at all). + * + * Both handles must already be open ([open] with [SftpOpenFlag.READ] for [srcHandle], + * [SftpOpenFlag.WRITE] for [dstHandle]) — this call does not open, create, or close + * anything. Only regular files are supported; there is no protocol-level operation for + * copying whole directory trees, so recursive copies still need to be driven by the + * caller (walk the tree, `mkdir` each directory, `copyData` each regular file). + * + * Check [extensions] for `"copy-data"` before calling, or be prepared to fall back on an + * [SftpResult.ServerError] with [SftpStatusCode.OP_UNSUPPORTED] — older or non-OpenSSH + * servers may not implement this extension at all. + * + * @param length Number of bytes to copy; `0` means "copy through EOF of the source file". + */ + suspend fun copyData( + srcHandle: SftpFileHandle, + srcOffset: Long, + length: Long, + dstHandle: SftpFileHandle, + dstOffset: Long, + ): SftpResult + // --- Path operations --- /** Resolve a path to its canonical absolute form. */ diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/SshClient.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/SshClient.kt index f8dfafc..e725e1d 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/SshClient.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/SshClient.kt @@ -23,9 +23,11 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.connectbot.sshlib.PingResult import org.connectbot.sshlib.client.DynamicPortForwarder @@ -146,6 +148,8 @@ class SshClient private constructor( private var authenticated = initialAuthenticated private val forwardingScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var disconnectForwardJob: Job? = null + private var keepAliveJob: Job? = null + private val keepAliveScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val _disconnectedFlow = MutableSharedFlow(extraBufferCapacity = 1) @@ -227,6 +231,7 @@ class SshClient private constructor( val result = conn.authenticatePassword(username, password) if (result is AuthResult.Success) { + startKeepAlive() authenticated = true logger.info("Authentication successful") } else { @@ -263,6 +268,7 @@ class SshClient private constructor( val result = conn.authenticateKeyboardInteractive(username, callback) if (result is AuthResult.Success) { + startKeepAlive() authenticated = true logger.info("Keyboard-interactive authentication successful") } else { @@ -317,6 +323,7 @@ class SshClient private constructor( val result = conn.authenticatePublicKey(username, privateKey) if (result is AuthResult.Success) { + startKeepAlive() authenticated = true logger.info("Public key authentication successful") } else { @@ -354,6 +361,7 @@ class SshClient private constructor( val result = conn.authenticate(username, handler) if (result is AuthResult.Success) { + startKeepAlive() authenticated = true logger.info("Auth handler authentication successful") } else { @@ -694,6 +702,30 @@ class SshClient private constructor( return conn.ping() } + /** + * Start sending SSH_MSG_IGNORE heartbeats periodically. + * Called internally after successful authentication. + * No-op if keepAliveIntervalMs is 0 or keepalive already running. + */ + private fun startKeepAlive() { + val intervalMs = config.keepAliveIntervalMs + if (intervalMs <= 0) return + if (keepAliveJob?.isActive == true) return + val connRef = connection ?: return + keepAliveJob = keepAliveScope.launch { + logger.info("Starting SSH keepalive every ${intervalMs}ms (SSH_MSG_IGNORE)") + while (isActive) { + delay(intervalMs) + try { + connRef.writeIgnore() + } catch (e: Exception) { + logger.warn("Keepalive failed, stopping: ${e.message}") + break + } + } + } + } + /** * Disconnect from the SSH server. */ @@ -703,6 +735,9 @@ class SshClient private constructor( disconnectForwardJob?.cancel() disconnectForwardJob = null + keepAliveJob?.cancel() + keepAliveJob = null + connection?.close() connection = null diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/SshClientConfig.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/SshClientConfig.kt index d82a3e2..c91841c 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/SshClientConfig.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/SshClientConfig.kt @@ -59,6 +59,7 @@ class SshClientConfig private constructor( val rekeyBytesLimit: Long, val obscureKeystrokeTimingIntervalMs: Long, val autoDisconnectOnLastChannelClose: Boolean, + val keepAliveIntervalMs: Long, ) { class Builder { /** @@ -134,6 +135,21 @@ class SshClientConfig private constructor( */ var autoDisconnectOnLastChannelClose: Boolean = true + /** + * Send an SSH_MSG_IGNORE heartbeat every N milliseconds to keep the + * connection alive across NAT/VPN/firewall idle timeouts. + * + * The message is a single empty payload that the server silently ignores + * (RFC 4253 §11.2). It does NOT expect a response — this is purely to + * prevent intermediaries from killing the TCP connection during idle. + * + * Recommended for long-lived connections behind aggressive firewalls. + * Set to 0 to disable (default). + * + * Common values: 15000 (15s, sshj default), 30000 (30s). + */ + var keepAliveIntervalMs: Long = 0L + fun build(): SshClientConfig { val factory = transportFactory ?: run { require(host.isNotBlank()) { "Host must be specified when using default TCP transport" } @@ -143,6 +159,9 @@ class SshClientConfig private constructor( require(obscureKeystrokeTimingIntervalMs >= 0) { "obscureKeystrokeTimingIntervalMs must be non-negative" } + require(keepAliveIntervalMs >= 0) { + "keepAliveIntervalMs must be non-negative" + } val verifier = hostKeyVerifier requireNotNull(verifier) { "hostKeyVerifier must be set" } @@ -166,6 +185,7 @@ class SshClientConfig private constructor( rekeyBytesLimit, obscureKeystrokeTimingIntervalMs, autoDisconnectOnLastChannelClose, + keepAliveIntervalMs, ) } } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt index 61c3ec3..5e567bd 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt @@ -566,6 +566,15 @@ class SshConnection( } } + /** + * Send an SSH_MSG_IGNORE heartbeat to the server. + * The server silently discards this message (RFC 4253 §11.2). + * Used to keep NAT/VPN/firewall connections alive during idle. + */ + internal suspend fun writeIgnore() { + writePacket(SshEnums.MessageType.SSH_MSG_IGNORE.id().toInt(), byteArrayOf()) + } + /** * Initiate SSH connection. * Performs SSH version exchange, key exchange, and service negotiation. @@ -3019,8 +3028,23 @@ class SshConnection( * * @return SessionChannel instance if successful, null otherwise */ + + /** + * Opens a session channel with the given local flow-control parameters. + * + * The initialWindowSize default was raised from 64KB to 16MB: SFTP (which runs over a + * session channel) stalls hard with a small window — the server can only send 64KB of + * data before pausing for SSH_MSG_CHANNEL_WINDOW_ADJUST, i.e. one full round-trip per + * 64KB of transfer. With 16MB the server can keep data in flight without pausing, + * which is what SFTP high-throughput transfers need (measured: a pipelined read of a + * 712MB file went from ~6MB/s to ~30MB/s with the large window). + * + * maxPacketSize stays at 32KB (a safe default that works with OpenSSH servers; a + * larger packet size caused ChannelClosedException when the server responded to a + * large SFTP read with an oversized SSH_MSG_CHANNEL_DATA — see fork history). + */ suspend fun openSessionChannel( - initialWindowSize: Int = 64 * 1024, + initialWindowSize: Int = 16 * 1024 * 1024, maxPacketSize: Int = 32 * 1024, ): SessionChannel? { val localChannelNumber = allocateChannelNumber() diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/sftp/SftpClientImpl.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/sftp/SftpClientImpl.kt index 31fe5a7..08c1ede 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/sftp/SftpClientImpl.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/sftp/SftpClientImpl.kt @@ -51,6 +51,7 @@ internal class SftpClientImpl private constructor( private val readJob: Job, override val protocolVersion: Int, private val stateMachine: SftpStateMachine, + override val extensions: Set, ) : SftpClient { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -246,6 +247,31 @@ internal class SftpClientImpl private constructor( return dispatchStatusRequest(SSH_FXP_RENAME, payload.array()) } + // --- Server-side data copy (OpenSSH extension) --- + + override suspend fun copyData( + srcHandle: SftpFileHandle, + srcOffset: Long, + length: Long, + dstHandle: SftpFileHandle, + dstOffset: Long, + ): SftpResult { + val nameBytes = EXT_COPY_DATA.toByteArray(StandardCharsets.UTF_8) + val payload = ByteBuffer.allocate( + 4 + nameBytes.size + + 4 + srcHandle.handle.size + 8 + 8 + + 4 + dstHandle.handle.size + 8, + ) + putString(payload, nameBytes) + putString(payload, srcHandle.handle) + payload.putLong(srcOffset) + payload.putLong(length) + putString(payload, dstHandle.handle) + payload.putLong(dstOffset) + + return dispatchStatusRequest(SSH_FXP_EXTENDED, payload.array()) + } + // --- Path operations --- override suspend fun realpath(path: String): SftpResult { @@ -398,8 +424,13 @@ internal class SftpClientImpl private constructor( private const val SSH_FXP_NAME = 104 private const val SSH_FXP_ATTRS = 105 + private const val SSH_FXP_EXTENDED = 200 + private const val SFTP_VERSION = 3 + /** OpenSSH SFTP extension (added in OpenSSH 9.0) for server-side data copy. */ + private const val EXT_COPY_DATA = "copy-data" + /** * Create an SFTP client by performing the INIT/VERSION handshake. */ @@ -439,15 +470,37 @@ internal class SftpClientImpl private constructor( if (versionPacket.payload.size < 4) { return SftpResult.ProtocolError("SSH_FXP_VERSION payload too short") } - val serverVersion = ByteBuffer.wrap(versionPacket.payload, 0, 4).int + val versionBuf = ByteBuffer.wrap(versionPacket.payload) + val serverVersion = versionBuf.int val negotiatedVersion = minOf(SFTP_VERSION, serverVersion) logger.info("SFTP version negotiated: {} (server: {})", negotiatedVersion, serverVersion) + // The VERSION reply may be followed by zero or more + // extension-name/extension-data string pairs (section 3). Only the + // names are kept — see [SftpClient.extensions]. Parsed defensively: + // a malformed/truncated trailing pair just stops parsing early + // rather than failing the whole handshake, since extensions are + // optional and servers this old still work fine without them. + val extensions = mutableSetOf() + try { + while (versionBuf.remaining() >= 4) { + val name = String(extractString(versionBuf), StandardCharsets.UTF_8) + if (versionBuf.remaining() < 4) break + extractString(versionBuf) // extension-data, unused for now + extensions.add(name) + } + } catch (e: Exception) { + logger.warn("Failed to parse SFTP VERSION extension pairs, ignoring remainder: {}", e.message) + } + if (extensions.isNotEmpty()) { + logger.info("SFTP server extensions: {}", extensions) + } + // Start the background read loop val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) val readJob = dispatcher.startReadLoop(scope) - return SftpResult.Success(SftpClientImpl(session, dispatcher, readJob, negotiatedVersion, stateMachine)) + return SftpResult.Success(SftpClientImpl(session, dispatcher, readJob, negotiatedVersion, stateMachine, extensions)) } // --- Wire format helpers --- diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/SftpClientTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/SftpClientTest.kt index 02ac70a..f9582b4 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/SftpClientTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/SftpClientTest.kt @@ -108,8 +108,17 @@ class SftpClientTest { val closedHandles = mutableListOf() override val protocolVersion: Int = 3 + override val extensions: Set = emptySet() override val isOpen: Boolean = true + override suspend fun copyData( + srcHandle: SftpFileHandle, + srcOffset: Long, + length: Long, + dstHandle: SftpFileHandle, + dstOffset: Long, + ): SftpResult = throw UnsupportedOperationException() + override suspend fun open( path: String, flags: Set, diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpClientImplTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpClientImplTest.kt index 5939def..e385f1a 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpClientImplTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpClientImplTest.kt @@ -293,6 +293,78 @@ class SftpClientImplTest { } } + @Test + fun `create parses extension pairs from the VERSION reply`() = runBlocking { + val session = FakeSshSession() + session.enqueueRead( + packet( + SSH_FXP_VERSION, + versionPayloadWithExtensions( + 3, + "copy-data" to "1", + "posix-rename@openssh.com" to "1", + ), + ), + ) + val client = assertSuccess(SftpClientImpl.create(session)) + + assertEquals(setOf("copy-data", "posix-rename@openssh.com"), client.extensions) + } + + @Test + fun `create tolerates a VERSION reply with no extensions`() = runBlocking { + // Same payload shape createClient() already uses elsewhere in this file: + // just the 4-byte version int, nothing trailing. + val session = FakeSshSession() + val client = createClient(session) + + assertEquals(emptySet(), client.extensions) + } + + @Test + fun `copyData sends the copy-data extension request and maps responses`() = runBlocking { + val srcHandle = SftpFileHandle(byteArrayOf(1, 2)) + val dstHandle = SftpFileHandle(byteArrayOf(3, 4)) + var capturedPayload: ByteArray? = null + + val okSession = FakeSshSession( + responseFor = { type, payload -> + if (type == SSH_FXP_EXTENDED) capturedPayload = payload + response(SSH_FXP_STATUS, statusPayload(SftpStatusCode.OK)) + }, + ) + val client = createClient(okSession) + + val result = client.copyData(srcHandle, 10L, 20L, dstHandle, 30L) + + assertEquals(SftpResult.Success(Unit), result) + assertEquals(listOf(SSH_FXP_EXTENDED), okSession.requestTypes) + + // Verify the wire payload: string "copy-data", then src handle/offset/length, + // then dst handle/offset — matches the OpenSSH PROTOCOL definition. + val buf = ByteBuffer.wrap(capturedPayload!!) + val nameLen = buf.int + val name = ByteArray(nameLen).also { buf.get(it) } + assertEquals("copy-data", String(name, Charsets.UTF_8)) + val srcLen = buf.int + val src = ByteArray(srcLen).also { buf.get(it) } + assertContentEquals(srcHandle.handle, src) + assertEquals(10L, buf.long) + assertEquals(20L, buf.long) + val dstLen = buf.int + val dst = ByteArray(dstLen).also { buf.get(it) } + assertContentEquals(dstHandle.handle, dst) + assertEquals(30L, buf.long) + + val errorSession = FakeSshSession( + responseFor = { _, _ -> response(SSH_FXP_STATUS, statusPayload(SftpStatusCode.OP_UNSUPPORTED, "no copy-data")) }, + ) + val errorClient = createClient(errorSession) + val errorResult = errorClient.copyData(srcHandle, 0L, 0L, dstHandle, 0L) + val serverError = assertIs(errorResult) + assertEquals(SftpStatusCode.OP_UNSUPPORTED, serverError.statusCode) + } + @Test fun `dispatcher propagates request write failures`() { runBlocking { @@ -383,6 +455,24 @@ class SftpClientImplTest { return packet(type, responsePayload.array()) } + /** Builds a VERSION reply payload: 4-byte version int + name/data string pairs. */ + private fun versionPayloadWithExtensions(version: Int, vararg extensions: Pair): ByteArray { + val encodedPairs = extensions.map { (name, data) -> + val nameBytes = name.toByteArray(Charsets.UTF_8) + val dataBytes = data.toByteArray(Charsets.UTF_8) + ByteBuffer.allocate(4 + nameBytes.size + 4 + dataBytes.size).apply { + putInt(nameBytes.size) + put(nameBytes) + putInt(dataBytes.size) + put(dataBytes) + }.array() + } + val payload = ByteBuffer.allocate(4 + encodedPairs.sumOf { it.size }) + payload.putInt(version) + encodedPairs.forEach(payload::put) + return payload.array() + } + private fun stringPayload(data: ByteArray): ByteArray { val payload = ByteBuffer.allocate(4 + data.size) payload.putInt(data.size) @@ -544,5 +634,6 @@ class SftpClientImplTest { const val SSH_FXP_DATA = 103 const val SSH_FXP_NAME = 104 const val SSH_FXP_ATTRS = 105 + const val SSH_FXP_EXTENDED = 200 } } diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpClientIntegrationTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpClientIntegrationTest.kt index 5ea18fa..90385ec 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpClientIntegrationTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpClientIntegrationTest.kt @@ -333,6 +333,24 @@ class SftpClientIntegrationTest { } } + @Test + fun `should advertise the copy-data extension`() = runBlocking { + val (client, sftp) = openSftp() + try { + // OpenSSH added "copy-data" in 9.0; the test container runs 9.9p2, so a real + // server should always advertise it here. This is the strongest signal that + // VERSION extension-pair parsing (SftpClientImpl.create) works against real + // wire bytes, not just the hand-built payloads in SftpClientImplTest. + assertTrue( + "copy-data" in sftp.extensions, + "Expected OpenSSH 9.9p2 to advertise the copy-data extension, got: ${sftp.extensions}", + ) + } finally { + sftp.close() + client.disconnect() + } + } + @Test fun `SFTP operation completes after interval rekey while idle`() = runBlocking { val (client, sftp) = openSftp( @@ -352,6 +370,52 @@ class SftpClientIntegrationTest { } } + @Test + fun `copyData copies bytes entirely on the server`() = runBlocking { + val (client, sftp) = openSftp() + try { + val ts = System.currentTimeMillis() + val srcPath = "/tmp/sftp-copydata-src-$ts.bin" + val dstPath = "/tmp/sftp-copydata-dst-$ts.bin" + val testData = ByteArray(8192) { (it % 256).toByte() } + + val writeHandle = sftp.open( + srcPath, + setOf(SftpOpenFlag.WRITE, SftpOpenFlag.CREATE, SftpOpenFlag.TRUNCATE), + ).getOrThrow() + sftp.write(writeHandle, 0, testData).getOrThrow() + sftp.close(writeHandle).getOrThrow() + + val srcHandle = sftp.open(srcPath, setOf(SftpOpenFlag.READ)).getOrThrow() + val dstHandle = sftp.open( + dstPath, + setOf(SftpOpenFlag.WRITE, SftpOpenFlag.CREATE, SftpOpenFlag.TRUNCATE), + ).getOrThrow() + + // length=0 means "copy through EOF of the source file" per the extension spec. + sftp.copyData(srcHandle, 0L, 0L, dstHandle, 0L).getOrThrow() + + sftp.close(srcHandle).getOrThrow() + sftp.close(dstHandle).getOrThrow() + + val dstAttrs = sftp.stat(dstPath).getOrThrow() + assertEquals(testData.size.toLong(), dstAttrs.size, "Copied file size should match source") + + val readHandle = sftp.open(dstPath, setOf(SftpOpenFlag.READ)).getOrThrow() + val readData = sftp.read(readHandle, 0, testData.size).getOrThrow() + sftp.close(readHandle).getOrThrow() + + assertNotNull(readData) + assertTrue(testData.contentEquals(readData!!), "Server-side copied bytes should match the source") + + sftp.remove(srcPath).getOrThrow() + sftp.remove(dstPath).getOrThrow() + } finally { + sftp.close() + client.disconnect() + } + } + @Test fun `should set file attributes`() = runBlocking { val (client, sftp) = openSftp()