diff --git a/protocol/src/main/kotlin/org/connectbot/sshlib/protocol/KaitaiUtils.kt b/protocol/src/main/kotlin/org/connectbot/sshlib/protocol/KaitaiUtils.kt index 0f5ff84..e8bb73a 100644 --- a/protocol/src/main/kotlin/org/connectbot/sshlib/protocol/KaitaiUtils.kt +++ b/protocol/src/main/kotlin/org/connectbot/sshlib/protocol/KaitaiUtils.kt @@ -21,12 +21,31 @@ import io.kaitai.struct.KaitaiStruct /** * Serialize a Kaitai struct to a byte array. + * + * Kaitai's [ByteBufferKaitaiStream] is fixed-capacity, so the underlying + * `ByteBuffer.put` throws [java.nio.BufferOverflowException] if the + * pre-allocated buffer is too small. We don't have a cheap way to know + * the encoded size up front, so start at 16 KiB and double on overflow + * until the message fits or we cross [MAX_BUFFER]. Most SSH messages + * encode in well under 16 KiB; this only matters for [SshMsgChannelData] + * carrying near-`maxPacketSize` (32 KiB) of data — e.g. SFTP transfers. */ fun KaitaiStruct.ReadWrite.toByteArray(): ByteArray { _check() - val io = ByteBufferKaitaiStream(1024 * 16) - _write(io) - val size = io.pos() - io.seek(0) - return io.readBytes(size.toLong()) + var capacity = INITIAL_BUFFER + while (true) { + try { + val io = ByteBufferKaitaiStream(capacity) + _write(io) + val size = io.pos() + io.seek(0) + return io.readBytes(size.toLong()) + } catch (_: java.nio.BufferOverflowException) { + if (capacity >= MAX_BUFFER) throw IllegalStateException("Kaitai message exceeds $MAX_BUFFER byte serialization limit") + capacity = minOf(capacity * 2, MAX_BUFFER) + } + } } + +private const val INITIAL_BUFFER = 1024L * 16 +private const val MAX_BUFFER = 1024L * 1024 diff --git a/sshlib/api.txt b/sshlib/api.txt index fd09a04..230946a 100644 --- a/sshlib/api.txt +++ b/sshlib/api.txt @@ -230,6 +230,180 @@ package org.connectbot.sshlib { property public String type; } + public final class SftpAttributes { + ctor public SftpAttributes(); + ctor public SftpAttributes(optional java.lang.Long? size, optional java.lang.Integer? uid, optional java.lang.Integer? gid, optional java.lang.Integer? permissions, optional java.lang.Integer? atime, optional java.lang.Integer? mtime); + method public java.lang.Long? component1(); + method public java.lang.Integer? component2(); + method public java.lang.Integer? component3(); + method public java.lang.Integer? component4(); + method public java.lang.Integer? component5(); + method public java.lang.Integer? component6(); + method public org.connectbot.sshlib.SftpAttributes copy(optional java.lang.Long? size, optional java.lang.Integer? uid, optional java.lang.Integer? gid, optional java.lang.Integer? permissions, optional java.lang.Integer? atime, optional java.lang.Integer? mtime); + method public boolean equals(java.lang.Object? other); + method @InaccessibleFromKotlin public java.lang.Integer? getAtime(); + method @InaccessibleFromKotlin public java.lang.Integer? getGid(); + method @InaccessibleFromKotlin public java.lang.Integer? getMtime(); + method @InaccessibleFromKotlin public java.lang.Integer? getPermissions(); + method @InaccessibleFromKotlin public java.lang.Long? getSize(); + method @InaccessibleFromKotlin public java.lang.Integer? getUid(); + method public int hashCode(); + method public java.lang.String toString(); + property public Integer? atime; + property public Integer? gid; + property public Integer? mtime; + property public Integer? permissions; + property public Long? size; + property public Integer? uid; + field public static final org.connectbot.sshlib.SftpAttributes.Companion Companion; + } + + public static final class SftpAttributes.Companion { + method @InaccessibleFromKotlin public org.connectbot.sshlib.SftpAttributes getEMPTY(); + property public org.connectbot.sshlib.SftpAttributes EMPTY; + } + + 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? 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 int getProtocolVersion(); + method @InaccessibleFromKotlin public boolean isOpen(); + method public default suspend java.lang.Object? listdir(java.lang.String path, kotlin.coroutines.Continuation>>); + method public suspend java.lang.Object? lstat(java.lang.String path, kotlin.coroutines.Continuation>); + method public suspend java.lang.Object? mkdir(java.lang.String path, optional org.connectbot.sshlib.SftpAttributes attrs, kotlin.coroutines.Continuation>); + method public suspend java.lang.Object? open(java.lang.String path, java.util.Set flags, optional org.connectbot.sshlib.SftpAttributes attrs, kotlin.coroutines.Continuation>); + method public suspend java.lang.Object? opendir(java.lang.String path, kotlin.coroutines.Continuation>); + method public suspend java.lang.Object? read(org.connectbot.sshlib.SftpFileHandle handle, long offset, int length, kotlin.coroutines.Continuation>); + method public suspend java.lang.Object? readdir(org.connectbot.sshlib.SftpFileHandle handle, kotlin.coroutines.Continuation?>>); + method public suspend java.lang.Object? readlink(java.lang.String path, kotlin.coroutines.Continuation>); + method public suspend java.lang.Object? realpath(java.lang.String path, kotlin.coroutines.Continuation>); + method public suspend java.lang.Object? remove(java.lang.String path, kotlin.coroutines.Continuation>); + method public suspend java.lang.Object? rename(java.lang.String oldPath, java.lang.String newPath, kotlin.coroutines.Continuation>); + method public suspend java.lang.Object? rmdir(java.lang.String path, kotlin.coroutines.Continuation>); + method public suspend java.lang.Object? setstat(java.lang.String path, org.connectbot.sshlib.SftpAttributes attrs, kotlin.coroutines.Continuation>); + 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 boolean isOpen; + property public abstract int protocolVersion; + } + + public final class SftpDirectoryEntry { + ctor public SftpDirectoryEntry(java.lang.String filename, java.lang.String longname, org.connectbot.sshlib.SftpAttributes attrs); + method public java.lang.String component1(); + method public java.lang.String component2(); + method public org.connectbot.sshlib.SftpAttributes component3(); + method public org.connectbot.sshlib.SftpDirectoryEntry copy(optional java.lang.String filename, optional java.lang.String longname, optional org.connectbot.sshlib.SftpAttributes attrs); + method public boolean equals(java.lang.Object? other); + method @InaccessibleFromKotlin public org.connectbot.sshlib.SftpAttributes getAttrs(); + method @InaccessibleFromKotlin public java.lang.String getFilename(); + method @InaccessibleFromKotlin public java.lang.String getLongname(); + method public int hashCode(); + method public java.lang.String toString(); + property public org.connectbot.sshlib.SftpAttributes attrs; + property public String filename; + property public String longname; + } + + public final class SftpException extends org.connectbot.sshlib.SshException { + ctor public SftpException(org.connectbot.sshlib.SftpStatusCode statusCode, java.lang.String message, optional java.lang.Throwable? cause); + method @InaccessibleFromKotlin public org.connectbot.sshlib.SftpStatusCode getStatusCode(); + method public java.lang.String toString(); + property public org.connectbot.sshlib.SftpStatusCode statusCode; + } + + public final class SftpFileHandle { + method public boolean equals(java.lang.Object? other); + method public int hashCode(); + } + + public enum SftpOpenFlag { + method @InaccessibleFromKotlin public int getValue(); + property public int value; + enum_constant public static final org.connectbot.sshlib.SftpOpenFlag APPEND; + enum_constant public static final org.connectbot.sshlib.SftpOpenFlag CREATE; + enum_constant public static final org.connectbot.sshlib.SftpOpenFlag EXCLUDE; + enum_constant public static final org.connectbot.sshlib.SftpOpenFlag READ; + enum_constant public static final org.connectbot.sshlib.SftpOpenFlag TRUNCATE; + enum_constant public static final org.connectbot.sshlib.SftpOpenFlag WRITE; + } + + public sealed exhaustive interface SftpResult { + } + + public static final class SftpResult.IoError implements org.connectbot.sshlib.SftpResult { + ctor public SftpResult.IoError(java.lang.Throwable cause); + method public java.lang.Throwable component1(); + method public org.connectbot.sshlib.SftpResult.IoError copy(optional java.lang.Throwable cause); + method public boolean equals(java.lang.Object? other); + method @InaccessibleFromKotlin public java.lang.Throwable getCause(); + method public int hashCode(); + method public java.lang.String toString(); + property public Throwable cause; + } + + public static final class SftpResult.ProtocolError implements org.connectbot.sshlib.SftpResult { + ctor public SftpResult.ProtocolError(java.lang.String message); + method public java.lang.String component1(); + method public org.connectbot.sshlib.SftpResult.ProtocolError copy(optional java.lang.String message); + method public boolean equals(java.lang.Object? other); + method @InaccessibleFromKotlin public java.lang.String getMessage(); + method public int hashCode(); + method public java.lang.String toString(); + property public String message; + } + + public static final class SftpResult.ServerError implements org.connectbot.sshlib.SftpResult { + ctor public SftpResult.ServerError(org.connectbot.sshlib.SftpStatusCode statusCode, java.lang.String message); + method public org.connectbot.sshlib.SftpStatusCode component1(); + method public java.lang.String component2(); + method public org.connectbot.sshlib.SftpResult.ServerError copy(optional org.connectbot.sshlib.SftpStatusCode statusCode, optional java.lang.String message); + method public boolean equals(java.lang.Object? other); + method @InaccessibleFromKotlin public java.lang.String getMessage(); + method @InaccessibleFromKotlin public org.connectbot.sshlib.SftpStatusCode getStatusCode(); + method public int hashCode(); + method public java.lang.String toString(); + property public String message; + property public org.connectbot.sshlib.SftpStatusCode statusCode; + } + + public static final class SftpResult.Success implements org.connectbot.sshlib.SftpResult { + ctor public SftpResult.Success(T value); + method public T component1(); + method public org.connectbot.sshlib.SftpResult.Success copy(optional T value); + method public boolean equals(java.lang.Object? other); + method @InaccessibleFromKotlin public T getValue(); + method public int hashCode(); + method public java.lang.String toString(); + property public T value; + } + + public final class SftpResultKt { + method public static T? getOrNull(org.connectbot.sshlib.SftpResult); + method public static T getOrThrow(org.connectbot.sshlib.SftpResult); + } + + public enum SftpStatusCode { + method @InaccessibleFromKotlin public int getCode(); + property public int code; + enum_constant public static final org.connectbot.sshlib.SftpStatusCode BAD_MESSAGE; + enum_constant public static final org.connectbot.sshlib.SftpStatusCode CONNECTION_LOST; + enum_constant public static final org.connectbot.sshlib.SftpStatusCode EOF; + enum_constant public static final org.connectbot.sshlib.SftpStatusCode FAILURE; + enum_constant public static final org.connectbot.sshlib.SftpStatusCode NO_CONNECTION; + enum_constant public static final org.connectbot.sshlib.SftpStatusCode NO_SUCH_FILE; + enum_constant public static final org.connectbot.sshlib.SftpStatusCode OK; + enum_constant public static final org.connectbot.sshlib.SftpStatusCode OP_UNSUPPORTED; + enum_constant public static final org.connectbot.sshlib.SftpStatusCode PERMISSION_DENIED; + field public static final org.connectbot.sshlib.SftpStatusCode.Companion Companion; + } + + public static final class SftpStatusCode.Companion { + method public org.connectbot.sshlib.SftpStatusCode fromCode(int code); + } + public interface Socks5Authenticator { method public boolean authenticate(java.lang.String username, java.lang.String password); } @@ -254,6 +428,7 @@ package org.connectbot.sshlib { method public suspend java.lang.Object? localPortForward(int bindPort, java.lang.String remoteHost, int remotePort, kotlin.coroutines.Continuation); method public org.connectbot.sshlib.transport.TransportFactory? openDirectTcpipTransport(java.lang.String remoteHost, int remotePort, optional java.lang.String originAddr, optional int originPort); method public suspend java.lang.Object? openSession(kotlin.coroutines.Continuation); + method public suspend java.lang.Object? openSftp(kotlin.coroutines.Continuation>); method public suspend java.lang.Object? remotePortForward(java.lang.String remoteBindAddress, int remoteBindPort, java.lang.String localHost, int localPort, kotlin.coroutines.Continuation); property public kotlinx.coroutines.flow.SharedFlow disconnectedFlow; property public boolean isAuthenticated; @@ -419,6 +594,7 @@ package org.connectbot.sshlib.blocking { method public org.connectbot.sshlib.transport.TransportFactory? openDirectTcpipTransport(java.lang.String remoteHost, int remotePort, optional java.lang.String originAddr); method public org.connectbot.sshlib.transport.TransportFactory? openDirectTcpipTransport(java.lang.String remoteHost, int remotePort, optional java.lang.String originAddr, optional int originPort); method public org.connectbot.sshlib.SshSession? openSession(); + method @kotlin.jvm.Throws(exceptionClasses=SftpException::class) public org.connectbot.sshlib.SftpClient openSftp() throws org.connectbot.sshlib.SftpException; method public org.connectbot.sshlib.PortForwarder? remotePortForward(java.lang.String remoteBindAddress, int remoteBindPort, java.lang.String localHost, int localPort); property public kotlinx.coroutines.flow.SharedFlow disconnectedFlow; property public boolean isAuthenticated; diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/SftpClient.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/SftpClient.kt new file mode 100644 index 0000000..522ea20 --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/SftpClient.kt @@ -0,0 +1,161 @@ +/* + * Copyright 2025 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib + +/** + * SFTP client for file transfer over SSH (draft-ietf-secsh-filexfer). + * + * Obtain an instance via [SshClient.openSftp]. All methods are suspend functions + * for use with Kotlin coroutines. Multiple concurrent operations are supported + * via SFTP request pipelining. + * + * All operations return [SftpResult] instead of throwing exceptions, so errors + * can be handled structurally. Use [getOrNull] or [getOrThrow] for convenience. + * + * Usage: + * ```kotlin + * val sftp = client.openSftp() ?: error("Failed to open SFTP") + * try { + * when (val result = sftp.listdir("/home/user")) { + * is SftpResult.Success -> result.value.forEach { println(it.filename) } + * is SftpResult.ServerError -> println("Error: ${result.message}") + * is SftpResult.ProtocolError -> println("Protocol error: ${result.message}") + * is SftpResult.IoError -> println("I/O error: ${result.cause}") + * } + * } finally { + * sftp.close() + * } + * ``` + */ +interface SftpClient : AutoCloseable { + /** The negotiated SFTP protocol version (typically 3). */ + val protocolVersion: Int + + /** Whether this SFTP session is still open. */ + val isOpen: Boolean + + // --- File I/O --- + + /** Open a file. Returns a handle for subsequent read/write/close operations. */ + suspend fun open( + path: String, + flags: Set, + attrs: SftpAttributes = SftpAttributes.EMPTY, + ): SftpResult + + /** Close a file or directory handle. */ + suspend fun close(handle: SftpFileHandle): SftpResult + + /** + * Read data from an open file at the given offset. + * Returns [SftpResult.Success] with data, or with null at EOF. + */ + suspend fun read(handle: SftpFileHandle, offset: Long, length: Int): SftpResult + + /** Write data to an open file at the given offset. */ + suspend fun write(handle: SftpFileHandle, offset: Long, data: ByteArray): SftpResult + + // --- Stat operations --- + + /** Get file attributes, following symlinks. */ + suspend fun stat(path: String): SftpResult + + /** Get file attributes without following symlinks. */ + suspend fun lstat(path: String): SftpResult + + /** Get attributes of an open file handle. */ + suspend fun fstat(handle: SftpFileHandle): SftpResult + + /** Set file attributes by path. */ + suspend fun setstat(path: String, attrs: SftpAttributes): SftpResult + + /** Set attributes of an open file handle. */ + suspend fun fsetstat(handle: SftpFileHandle, attrs: SftpAttributes): SftpResult + + // --- Directory operations --- + + /** Open a directory for reading. */ + suspend fun opendir(path: String): SftpResult + + /** + * Read the next batch of directory entries. + * Returns [SftpResult.Success] with entries, or with null at end of directory. + */ + suspend fun readdir(handle: SftpFileHandle): SftpResult?> + + /** + * List all entries in a directory. Convenience method that handles + * opendir/readdir/close internally. + */ + suspend fun listdir(path: String): SftpResult> { + val handleResult = opendir(path) + val handle = when (handleResult) { + is SftpResult.Success -> handleResult.value + is SftpResult.ServerError -> return handleResult + is SftpResult.ProtocolError -> return handleResult + is SftpResult.IoError -> return handleResult + } + try { + val entries = mutableListOf() + while (true) { + when (val batch = readdir(handle)) { + is SftpResult.Success -> { + if (batch.value == null) break + entries.addAll(batch.value) + } + + is SftpResult.ServerError -> return batch + + is SftpResult.ProtocolError -> return batch + + is SftpResult.IoError -> return batch + } + } + return SftpResult.Success(entries) + } finally { + close(handle) + } + } + + /** Create a directory. */ + suspend fun mkdir(path: String, attrs: SftpAttributes = SftpAttributes.EMPTY): SftpResult + + /** Remove an empty directory. */ + suspend fun rmdir(path: String): SftpResult + + // --- File management --- + + /** Delete a file. */ + suspend fun remove(path: String): SftpResult + + /** Rename or move a file. */ + suspend fun rename(oldPath: String, newPath: String): SftpResult + + // --- Path operations --- + + /** Resolve a path to its canonical absolute form. */ + suspend fun realpath(path: String): SftpResult + + /** Read the target of a symbolic link. */ + suspend fun readlink(path: String): SftpResult + + /** Create a symbolic link. */ + suspend fun symlink(targetPath: String, linkPath: String): SftpResult + + /** Close this SFTP session and the underlying SSH channel. */ + override fun close() +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/SftpException.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/SftpException.kt new file mode 100644 index 0000000..a7f0f67 --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/SftpException.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2025 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib + +/** + * Exception thrown for SFTP protocol errors. + * + * @param statusCode The SFTP status code from the server + * @param message Human-readable error message + */ +class SftpException( + val statusCode: SftpStatusCode, + message: String, + cause: Throwable? = null, +) : SshException(message, cause) { + override fun toString(): String = "SftpException(${statusCode.name}): $message" +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/SftpResult.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/SftpResult.kt new file mode 100644 index 0000000..859de30 --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/SftpResult.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib + +/** + * Result type for SFTP operations. Replaces thrown exceptions with a + * sealed interface so callers can handle errors structurally. + */ +sealed interface SftpResult { + /** Operation succeeded with [value]. */ + data class Success(val value: T) : SftpResult + + /** SFTP server returned an error status. */ + data class ServerError( + val statusCode: SftpStatusCode, + val message: String, + ) : SftpResult + + /** SFTP protocol violation (unexpected packet type, malformed data). */ + data class ProtocolError(val message: String) : SftpResult + + /** Network or I/O error. */ + data class IoError(val cause: Throwable) : SftpResult +} + +/** Convenience: extract value or null for success, throws nothing. */ +fun SftpResult.getOrNull(): T? = when (this) { + is SftpResult.Success -> value + else -> null +} + +/** Convenience: extract value or throw for interop with blocking APIs. */ +fun SftpResult.getOrThrow(): T = when (this) { + is SftpResult.Success -> value + is SftpResult.ServerError -> throw SftpException(statusCode, message) + is SftpResult.ProtocolError -> throw SftpException(SftpStatusCode.BAD_MESSAGE, message) + is SftpResult.IoError -> throw SftpException(SftpStatusCode.FAILURE, cause.message ?: "I/O error", cause) +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/SftpTypes.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/SftpTypes.kt new file mode 100644 index 0000000..369cab9 --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/SftpTypes.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2025 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib + +/** + * Opaque file or directory handle returned by SFTP open/opendir operations. + * + * Handles are server-assigned and should be closed when no longer needed. + */ +class SftpFileHandle internal constructor(internal val handle: ByteArray) { + override fun equals(other: Any?): Boolean = other is SftpFileHandle && handle.contentEquals(other.handle) + + override fun hashCode(): Int = handle.contentHashCode() +} + +/** + * File attributes for SFTP operations (SFTPv3 ATTRS structure). + * + * All fields are optional — only fields with non-null values are transmitted. + */ +data class SftpAttributes( + val size: Long? = null, + val uid: Int? = null, + val gid: Int? = null, + val permissions: Int? = null, + val atime: Int? = null, + val mtime: Int? = null, +) { + companion object { + val EMPTY = SftpAttributes() + } +} + +/** + * Entry from an SFTP directory listing. + * + * @param filename Short filename (e.g. "file.txt") + * @param longname Long-format listing (e.g. "-rw-r--r-- 1 user group 1234 Jan 1 00:00 file.txt") + * @param attrs File attributes + */ +data class SftpDirectoryEntry( + val filename: String, + val longname: String, + val attrs: SftpAttributes, +) + +/** + * Flags for SFTP file open operations. + */ +enum class SftpOpenFlag(val value: Int) { + READ(0x00000001), + WRITE(0x00000002), + APPEND(0x00000004), + CREATE(0x00000008), + TRUNCATE(0x00000010), + EXCLUDE(0x00000020), +} + +/** + * SFTP status codes (draft-ietf-secsh-filexfer-02 section 7). + */ +enum class SftpStatusCode(val code: Int) { + OK(0), + EOF(1), + NO_SUCH_FILE(2), + PERMISSION_DENIED(3), + FAILURE(4), + BAD_MESSAGE(5), + NO_CONNECTION(6), + CONNECTION_LOST(7), + OP_UNSUPPORTED(8), + ; + + companion object { + fun fromCode(code: Int): SftpStatusCode = entries.firstOrNull { it.code == code } ?: FAILURE + } +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/SshClient.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/SshClient.kt index e7b435a..bfe96e6 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/SshClient.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/SshClient.kt @@ -30,6 +30,7 @@ import org.connectbot.sshlib.client.DynamicPortForwarder import org.connectbot.sshlib.client.LocalPortForwarder import org.connectbot.sshlib.client.RemotePortForwarder import org.connectbot.sshlib.client.SshConnection +import org.connectbot.sshlib.client.sftp.SftpClientImpl import org.connectbot.sshlib.crypto.PrivateKeyReader import org.connectbot.sshlib.transport.ForwardingChannelTransport import org.connectbot.sshlib.transport.Transport @@ -401,6 +402,36 @@ class SshClient private constructor( } } + /** + * Open an SFTP session for file transfer. + * + * Opens a new session channel, starts the "sftp" subsystem, and performs + * SFTP version negotiation. + * + * @return [SftpResult.Success] with the client, or an error variant + */ + suspend fun openSftp(): SftpResult { + val conn = connection + if (conn == null || !authenticated) { + logger.error("Not authenticated - call connect() and authenticate first") + return SftpResult.IoError(IllegalStateException("Not authenticated")) + } + + return try { + logger.info("Opening SFTP session") + val session = conn.openSessionChannel() + ?: return SftpResult.ProtocolError("Failed to open session channel for SFTP") + if (!session.requestSubsystem("sftp")) { + session.close() + return SftpResult.ProtocolError("Server rejected SFTP subsystem request") + } + SftpClientImpl.create(session) + } catch (e: Exception) { + logger.error("Failed to open SFTP session", e) + SftpResult.IoError(e) + } + } + /** * Start local port forwarding (RFC 4254 section 7.2). * diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/blocking/BlockingSshClient.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/blocking/BlockingSshClient.kt index 3a7e011..40b181c 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/blocking/BlockingSshClient.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/blocking/BlockingSshClient.kt @@ -27,12 +27,15 @@ import org.connectbot.sshlib.ConnectResult import org.connectbot.sshlib.HostKeyVerifier import org.connectbot.sshlib.KeyboardInteractiveCallback import org.connectbot.sshlib.PortForwarder +import org.connectbot.sshlib.SftpClient +import org.connectbot.sshlib.SftpException import org.connectbot.sshlib.Socks5Authenticator import org.connectbot.sshlib.SshClient import org.connectbot.sshlib.SshClientConfig import org.connectbot.sshlib.SshException import org.connectbot.sshlib.SshSession import org.connectbot.sshlib.StreamForwarder +import org.connectbot.sshlib.getOrThrow import org.connectbot.sshlib.transport.TransportFactory import java.net.InetSocketAddress @@ -238,6 +241,12 @@ class BlockingSshClient internal constructor( */ fun openSession(): SshSession? = runBlocking { client.openSession() } + /** + * Open an SFTP session for file transfer (blocking wrapper). + */ + @Throws(SftpException::class) + fun openSftp(): SftpClient = runBlocking { client.openSftp().getOrThrow() } + /** * Start local port forwarding. */ 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 new file mode 100644 index 0000000..1bb19b0 --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/sftp/SftpClientImpl.kt @@ -0,0 +1,478 @@ +/* + * Copyright 2025 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.client.sftp + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import org.connectbot.sshlib.SftpAttributes +import org.connectbot.sshlib.SftpClient +import org.connectbot.sshlib.SftpDirectoryEntry +import org.connectbot.sshlib.SftpFileHandle +import org.connectbot.sshlib.SftpOpenFlag +import org.connectbot.sshlib.SftpResult +import org.connectbot.sshlib.SftpStatusCode +import org.connectbot.sshlib.SshSession +import org.slf4j.LoggerFactory +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets + +/** + * Internal implementation of [SftpClient]. + * + * SFTP message types (draft-ietf-secsh-filexfer-02 section 3): + */ +internal class SftpClientImpl private constructor( + private val session: SshSession, + private val dispatcher: SftpDispatcher, + private val readJob: Job, + override val protocolVersion: Int, +) : SftpClient { + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var closed = false + + override val isOpen: Boolean get() = !closed && session.isOpen + + // --- File I/O --- + + override suspend fun open(path: String, flags: Set, attrs: SftpAttributes): SftpResult { + val pflags = flags.fold(0) { acc, flag -> acc or flag.value } + val pathBytes = path.toByteArray(StandardCharsets.UTF_8) + val attrsBytes = SftpFileAttributes.encode(attrs) + + val payload = ByteBuffer.allocate(4 + pathBytes.size + 4 + attrsBytes.size) + putString(payload, pathBytes) + payload.putInt(pflags) + payload.put(attrsBytes) + + return dispatchRequest(SSH_FXP_OPEN, payload.array()) { response -> + when (response.type) { + SSH_FXP_HANDLE -> SftpResult.Success(SftpFileHandle(extractString(ByteBuffer.wrap(response.payload)))) + SSH_FXP_STATUS -> decodeStatusError(response.payload) + else -> SftpResult.ProtocolError("Unexpected response type ${response.type} for OPEN") + } + } + } + + override suspend fun close(handle: SftpFileHandle): SftpResult { + val payload = ByteBuffer.allocate(4 + handle.handle.size) + putString(payload, handle.handle) + + return dispatchRequest(SSH_FXP_CLOSE, payload.array()) { response -> + if (response.type == SSH_FXP_STATUS) { + val status = decodeStatus(response.payload) + if (status == SftpStatusCode.OK) { + SftpResult.Success(Unit) + } else { + decodeStatusError(response.payload) + } + } else { + SftpResult.Success(Unit) + } + } + } + + override suspend fun read(handle: SftpFileHandle, offset: Long, length: Int): SftpResult { + val payload = ByteBuffer.allocate(4 + handle.handle.size + 8 + 4) + putString(payload, handle.handle) + payload.putLong(offset) + payload.putInt(length) + + return dispatchRequest(SSH_FXP_READ, payload.array()) { response -> + when (response.type) { + SSH_FXP_DATA -> SftpResult.Success(extractString(ByteBuffer.wrap(response.payload))) + + SSH_FXP_STATUS -> { + val status = decodeStatus(response.payload) + if (status == SftpStatusCode.EOF) { + SftpResult.Success(null) + } else { + decodeStatusError(response.payload) + } + } + + else -> SftpResult.ProtocolError("Unexpected response type ${response.type} for READ") + } + } + } + + override suspend fun write(handle: SftpFileHandle, offset: Long, data: ByteArray): SftpResult { + val payload = ByteBuffer.allocate(4 + handle.handle.size + 8 + 4 + data.size) + putString(payload, handle.handle) + payload.putLong(offset) + putString(payload, data) + + return dispatchStatusRequest(SSH_FXP_WRITE, payload.array()) + } + + // --- Stat operations --- + + override suspend fun stat(path: String): SftpResult = statRequest(SSH_FXP_STAT, path) + + override suspend fun lstat(path: String): SftpResult = statRequest(SSH_FXP_LSTAT, path) + + private suspend fun statRequest(type: Int, path: String): SftpResult { + val pathBytes = path.toByteArray(StandardCharsets.UTF_8) + val payload = ByteBuffer.allocate(4 + pathBytes.size) + putString(payload, pathBytes) + + return dispatchRequest(type, payload.array()) { response -> + when (response.type) { + SSH_FXP_ATTRS -> SftpResult.Success(SftpFileAttributes.decode(ByteBuffer.wrap(response.payload))) + SSH_FXP_STATUS -> decodeStatusError(response.payload) + else -> SftpResult.ProtocolError("Unexpected response type ${response.type} for STAT") + } + } + } + + override suspend fun fstat(handle: SftpFileHandle): SftpResult { + val payload = ByteBuffer.allocate(4 + handle.handle.size) + putString(payload, handle.handle) + + return dispatchRequest(SSH_FXP_FSTAT, payload.array()) { response -> + when (response.type) { + SSH_FXP_ATTRS -> SftpResult.Success(SftpFileAttributes.decode(ByteBuffer.wrap(response.payload))) + SSH_FXP_STATUS -> decodeStatusError(response.payload) + else -> SftpResult.ProtocolError("Unexpected response type ${response.type} for FSTAT") + } + } + } + + override suspend fun setstat(path: String, attrs: SftpAttributes): SftpResult { + val pathBytes = path.toByteArray(StandardCharsets.UTF_8) + val attrsBytes = SftpFileAttributes.encode(attrs) + val payload = ByteBuffer.allocate(4 + pathBytes.size + attrsBytes.size) + putString(payload, pathBytes) + payload.put(attrsBytes) + + return dispatchStatusRequest(SSH_FXP_SETSTAT, payload.array()) + } + + override suspend fun fsetstat(handle: SftpFileHandle, attrs: SftpAttributes): SftpResult { + val attrsBytes = SftpFileAttributes.encode(attrs) + val payload = ByteBuffer.allocate(4 + handle.handle.size + attrsBytes.size) + putString(payload, handle.handle) + payload.put(attrsBytes) + + return dispatchStatusRequest(SSH_FXP_FSETSTAT, payload.array()) + } + + // --- Directory operations --- + + override suspend fun opendir(path: String): SftpResult { + val pathBytes = path.toByteArray(StandardCharsets.UTF_8) + val payload = ByteBuffer.allocate(4 + pathBytes.size) + putString(payload, pathBytes) + + return dispatchRequest(SSH_FXP_OPENDIR, payload.array()) { response -> + when (response.type) { + SSH_FXP_HANDLE -> SftpResult.Success(SftpFileHandle(extractString(ByteBuffer.wrap(response.payload)))) + SSH_FXP_STATUS -> decodeStatusError(response.payload) + else -> SftpResult.ProtocolError("Unexpected response type ${response.type} for OPENDIR") + } + } + } + + override suspend fun readdir(handle: SftpFileHandle): SftpResult?> { + val payload = ByteBuffer.allocate(4 + handle.handle.size) + putString(payload, handle.handle) + + return dispatchRequest(SSH_FXP_READDIR, payload.array()) { response -> + when (response.type) { + SSH_FXP_NAME -> SftpResult.Success(decodeName(response.payload)) + + SSH_FXP_STATUS -> { + val status = decodeStatus(response.payload) + if (status == SftpStatusCode.EOF) { + SftpResult.Success(null) + } else { + decodeStatusError(response.payload) + } + } + + else -> SftpResult.ProtocolError("Unexpected response type ${response.type} for READDIR") + } + } + } + + override suspend fun mkdir(path: String, attrs: SftpAttributes): SftpResult { + val pathBytes = path.toByteArray(StandardCharsets.UTF_8) + val attrsBytes = SftpFileAttributes.encode(attrs) + val payload = ByteBuffer.allocate(4 + pathBytes.size + attrsBytes.size) + putString(payload, pathBytes) + payload.put(attrsBytes) + + return dispatchStatusRequest(SSH_FXP_MKDIR, payload.array()) + } + + override suspend fun rmdir(path: String): SftpResult = simplePathRequest(SSH_FXP_RMDIR, path) + + // --- File management --- + + override suspend fun remove(path: String): SftpResult = simplePathRequest(SSH_FXP_REMOVE, path) + + override suspend fun rename(oldPath: String, newPath: String): SftpResult { + val oldBytes = oldPath.toByteArray(StandardCharsets.UTF_8) + val newBytes = newPath.toByteArray(StandardCharsets.UTF_8) + val payload = ByteBuffer.allocate(4 + oldBytes.size + 4 + newBytes.size) + putString(payload, oldBytes) + putString(payload, newBytes) + + return dispatchStatusRequest(SSH_FXP_RENAME, payload.array()) + } + + // --- Path operations --- + + override suspend fun realpath(path: String): SftpResult { + val pathBytes = path.toByteArray(StandardCharsets.UTF_8) + val payload = ByteBuffer.allocate(4 + pathBytes.size) + putString(payload, pathBytes) + + return dispatchRequest(SSH_FXP_REALPATH, payload.array()) { response -> + when (response.type) { + SSH_FXP_NAME -> { + val entries = decodeName(response.payload) + val filename = entries.firstOrNull()?.filename + if (filename != null) { + SftpResult.Success(filename) + } else { + SftpResult.ProtocolError("REALPATH returned empty NAME") + } + } + + SSH_FXP_STATUS -> decodeStatusError(response.payload) + + else -> SftpResult.ProtocolError("Unexpected response type ${response.type} for REALPATH") + } + } + } + + override suspend fun readlink(path: String): SftpResult { + val pathBytes = path.toByteArray(StandardCharsets.UTF_8) + val payload = ByteBuffer.allocate(4 + pathBytes.size) + putString(payload, pathBytes) + + return dispatchRequest(SSH_FXP_READLINK, payload.array()) { response -> + when (response.type) { + SSH_FXP_NAME -> { + val entries = decodeName(response.payload) + val filename = entries.firstOrNull()?.filename + if (filename != null) { + SftpResult.Success(filename) + } else { + SftpResult.ProtocolError("READLINK returned empty NAME") + } + } + + SSH_FXP_STATUS -> decodeStatusError(response.payload) + + else -> SftpResult.ProtocolError("Unexpected response type ${response.type} for READLINK") + } + } + } + + override suspend fun symlink(targetPath: String, linkPath: String): SftpResult { + // Note: OpenSSH has a known bug where symlink arguments are reversed + // from the spec. The spec says (targetpath, linkpath) but OpenSSH + // expects (linkpath, targetpath). We follow the OpenSSH convention + // since it's the most common server implementation. + val linkBytes = linkPath.toByteArray(StandardCharsets.UTF_8) + val targetBytes = targetPath.toByteArray(StandardCharsets.UTF_8) + val payload = ByteBuffer.allocate(4 + linkBytes.size + 4 + targetBytes.size) + putString(payload, linkBytes) + putString(payload, targetBytes) + + return dispatchStatusRequest(SSH_FXP_SYMLINK, payload.array()) + } + + override fun close() { + if (closed) return + closed = true + dispatcher.stop() + session.close() + } + + // --- Internal helpers --- + + /** + * Send a request and map the response. + */ + private suspend fun dispatchRequest( + type: Int, + payload: ByteArray, + map: (SftpRawPacket) -> SftpResult, + ): SftpResult = when (val result = dispatcher.request(type, payload)) { + is SftpResult.Success -> map(result.value) + is SftpResult.ServerError -> result + is SftpResult.ProtocolError -> result + is SftpResult.IoError -> result + } + + /** + * Send a request that expects SSH_FXP_STATUS with OK. + */ + private suspend fun dispatchStatusRequest(type: Int, payload: ByteArray): SftpResult = dispatchRequest(type, payload) { response -> + if (response.type == SSH_FXP_STATUS) { + val status = decodeStatus(response.payload) + if (status == SftpStatusCode.OK) { + SftpResult.Success(Unit) + } else { + decodeStatusError(response.payload) + } + } else { + SftpResult.Success(Unit) + } + } + + private suspend fun simplePathRequest(type: Int, path: String): SftpResult { + val pathBytes = path.toByteArray(StandardCharsets.UTF_8) + val payload = ByteBuffer.allocate(4 + pathBytes.size) + putString(payload, pathBytes) + + return dispatchStatusRequest(type, payload.array()) + } + + companion object { + private val logger = LoggerFactory.getLogger(SftpClientImpl::class.java) + + // SFTP message types + private const val SSH_FXP_INIT = 1 + private const val SSH_FXP_VERSION = 2 + private const val SSH_FXP_OPEN = 3 + private const val SSH_FXP_CLOSE = 4 + private const val SSH_FXP_READ = 5 + private const val SSH_FXP_WRITE = 6 + private const val SSH_FXP_LSTAT = 7 + private const val SSH_FXP_FSTAT = 8 + private const val SSH_FXP_SETSTAT = 9 + private const val SSH_FXP_FSETSTAT = 10 + private const val SSH_FXP_OPENDIR = 11 + private const val SSH_FXP_READDIR = 12 + private const val SSH_FXP_REMOVE = 13 + private const val SSH_FXP_MKDIR = 14 + private const val SSH_FXP_RMDIR = 15 + private const val SSH_FXP_REALPATH = 16 + private const val SSH_FXP_STAT = 17 + private const val SSH_FXP_RENAME = 18 + private const val SSH_FXP_READLINK = 19 + private const val SSH_FXP_SYMLINK = 20 + + private const val SSH_FXP_STATUS = 101 + private const val SSH_FXP_HANDLE = 102 + private const val SSH_FXP_DATA = 103 + private const val SSH_FXP_NAME = 104 + private const val SSH_FXP_ATTRS = 105 + + private const val SFTP_VERSION = 3 + + /** + * Create an SFTP client by performing the INIT/VERSION handshake. + */ + suspend fun create(session: SshSession): SftpResult { + val packetIO = SftpPacketIO(session) + val dispatcher = SftpDispatcher(packetIO) + + // Send SSH_FXP_INIT + val initPayload = ByteBuffer.allocate(4) + initPayload.putInt(SFTP_VERSION) + when (val w = dispatcher.writeRaw(SSH_FXP_INIT, initPayload.array())) { + is SftpResult.Success -> {} + is SftpResult.ServerError -> return w + is SftpResult.ProtocolError -> return w + is SftpResult.IoError -> return w + } + + // Read SSH_FXP_VERSION + val versionPacket = when (val r = dispatcher.readRaw()) { + is SftpResult.Success -> r.value + is SftpResult.ServerError -> return r + is SftpResult.ProtocolError -> return r + is SftpResult.IoError -> return r + } + if (versionPacket.type != SSH_FXP_VERSION) { + return SftpResult.ProtocolError( + "Expected SSH_FXP_VERSION (2), got ${versionPacket.type}", + ) + } + if (versionPacket.payload.size < 4) { + return SftpResult.ProtocolError("SSH_FXP_VERSION payload too short") + } + val serverVersion = ByteBuffer.wrap(versionPacket.payload, 0, 4).int + val negotiatedVersion = minOf(SFTP_VERSION, serverVersion) + logger.info("SFTP version negotiated: {} (server: {})", negotiatedVersion, serverVersion) + + // Start the background read loop + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val readJob = dispatcher.startReadLoop(scope) + + return SftpResult.Success(SftpClientImpl(session, dispatcher, readJob, negotiatedVersion)) + } + + // --- Wire format helpers --- + + /** Write a length-prefixed string/byte array to a ByteBuffer. */ + private fun putString(buf: ByteBuffer, data: ByteArray) { + buf.putInt(data.size) + buf.put(data) + } + + /** Read a length-prefixed string/byte array from a ByteBuffer. */ + private fun extractString(buf: ByteBuffer): ByteArray { + val len = buf.int + val data = ByteArray(len) + buf.get(data) + return data + } + + /** Decode a STATUS response to get the status code. */ + private fun decodeStatus(payload: ByteArray): SftpStatusCode { + if (payload.size < 4) return SftpStatusCode.FAILURE + val code = ByteBuffer.wrap(payload, 0, 4).int + return SftpStatusCode.fromCode(code) + } + + /** Decode a STATUS response into an [SftpResult.ServerError]. */ + private fun decodeStatusError(payload: ByteArray): SftpResult.ServerError { + val buf = ByteBuffer.wrap(payload) + val code = if (buf.remaining() >= 4) buf.int else 4 + val statusCode = SftpStatusCode.fromCode(code) + val message = if (buf.remaining() >= 4) { + val msgBytes = extractString(buf) + String(msgBytes, StandardCharsets.UTF_8) + } else { + statusCode.name + } + return SftpResult.ServerError(statusCode, message) + } + + /** Decode a NAME response (used by readdir, realpath, readlink). */ + private fun decodeName(payload: ByteArray): List { + val buf = ByteBuffer.wrap(payload) + val count = buf.int + val entries = mutableListOf() + repeat(count) { + val filename = String(extractString(buf), StandardCharsets.UTF_8) + val longname = String(extractString(buf), StandardCharsets.UTF_8) + val attrs = SftpFileAttributes.decode(buf) + entries.add(SftpDirectoryEntry(filename, longname, attrs)) + } + return entries + } + } +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/sftp/SftpDispatcher.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/sftp/SftpDispatcher.kt new file mode 100644 index 0000000..7d67107 --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/sftp/SftpDispatcher.kt @@ -0,0 +1,179 @@ +/* + * Copyright 2025 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.client.sftp + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import org.connectbot.sshlib.SftpResult +import org.slf4j.LoggerFactory +import java.nio.ByteBuffer +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +/** + * Dispatches SFTP requests and routes responses back to waiting coroutines. + * + * Each outbound request gets a unique request ID. A background read loop + * parses incoming SFTP packets and completes the matching deferred. + */ +internal class SftpDispatcher(private val packetIO: SftpPacketIO) { + private val nextRequestId = AtomicInteger(1) + private val pending = ConcurrentHashMap>() + private val writeMutex = Mutex() + private var readJob: Job? = null + + /** + * Send an SFTP request and wait for the matching response. + * + * @param type SFTP message type + * @param payload Request payload (without request ID — it will be prepended) + * @param timeoutMs Maximum time to wait for a response + * @return The raw response packet + */ + suspend fun request(type: Int, payload: ByteArray, timeoutMs: Long = 30_000L): SftpResult { + val requestId = nextRequestId.getAndIncrement() + val deferred = CompletableDeferred() + pending[requestId] = deferred + + // Prepend request ID to payload + val fullPayload = ByteBuffer.allocate(4 + payload.size) + fullPayload.putInt(requestId) + fullPayload.put(payload) + + // The framing layer ([packetIO]) now returns sealed SftpResult + // rather than throwing — propagate any error from the write + // straight back to the caller. Timeout / await are still + // exception-paths so they keep the catch. + val writeResult = writeMutex.withLock { + packetIO.writePacket(type, fullPayload.array()) + } + if (writeResult is SftpResult.IoError) { + pending.remove(requestId) + return writeResult + } + if (writeResult is SftpResult.ProtocolError) { + pending.remove(requestId) + return writeResult + } + if (writeResult is SftpResult.ServerError) { + pending.remove(requestId) + return writeResult + } + + return try { + val packet = withTimeout(timeoutMs) { + deferred.await() + } + SftpResult.Success(packet) + } catch (e: Exception) { + pending.remove(requestId) + SftpResult.IoError(e) + } + } + + /** + * Send an SFTP packet without a request ID (used for INIT). Just + * forwards the framing-layer result. + */ + suspend fun writeRaw(type: Int, payload: ByteArray): SftpResult = writeMutex.withLock { + packetIO.writePacket(type, payload) + } + + /** + * Read a single raw packet (used for VERSION response during init). + * Just forwards the framing-layer result. + */ + suspend fun readRaw(): SftpResult = packetIO.readPacket() + + /** + * Start the background read loop that routes responses to waiting callers. + */ + fun startReadLoop(scope: CoroutineScope): Job { + val job = scope.launch { + try { + loop@ while (true) { + val packet = when (val result = packetIO.readPacket()) { + is SftpResult.Success -> result.value + + is SftpResult.IoError -> { + logger.debug("SFTP read loop ended: {}", result.cause.message) + pending.values.forEach { it.completeExceptionally(result.cause) } + pending.clear() + break@loop + } + + is SftpResult.ProtocolError -> { + logger.debug("SFTP channel closed: {}", result.message) + val err = SftpProtocolException(result.message) + pending.values.forEach { it.completeExceptionally(err) } + pending.clear() + break@loop + } + + is SftpResult.ServerError -> { + // Framing layer never produces ServerError, but the + // sealed exhaustiveness check requires this branch. + logger.warn("Unexpected ServerError from packet framing: code={}", result.statusCode) + break@loop + } + } + + // Extract request ID from first 4 bytes of payload + if (packet.payload.size < 4) { + logger.warn("SFTP packet type {} with payload too short for request ID", packet.type) + continue + } + + val requestId = ByteBuffer.wrap(packet.payload, 0, 4).int + val responsePayload = packet.payload.copyOfRange(4, packet.payload.size) + val responsePacket = SftpRawPacket(packet.type, responsePayload) + + val deferred = pending.remove(requestId) + if (deferred != null) { + deferred.complete(responsePacket) + } else { + logger.warn("SFTP response for unknown request ID {}", requestId) + } + } + } catch (e: Exception) { + // Coroutine cancellation or other unexpected error. + logger.debug("SFTP read loop ended unexpectedly: {}", e.message) + pending.values.forEach { it.completeExceptionally(e) } + pending.clear() + } + } + readJob = job + return job + } + + fun stop() { + readJob?.cancel() + pending.values.forEach { + it.completeExceptionally(SftpProtocolException("SFTP session closed")) + } + pending.clear() + } + + companion object { + private val logger = LoggerFactory.getLogger(SftpDispatcher::class.java) + } +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/sftp/SftpFileAttributes.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/sftp/SftpFileAttributes.kt new file mode 100644 index 0000000..683f3c2 --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/sftp/SftpFileAttributes.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2025 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.client.sftp + +import org.connectbot.sshlib.SftpAttributes +import java.nio.ByteBuffer + +/** + * Internal parser/serializer for SFTPv3 ATTRS structure. + * + * The ATTRS structure uses a flags field to indicate which subsequent + * fields are present (draft-ietf-secsh-filexfer-02 section 5). + * + * Flags: + * - SSH_FILEXFER_ATTR_SIZE (0x00000001): size is present + * - SSH_FILEXFER_ATTR_UIDGID (0x00000002): uid and gid are present + * - SSH_FILEXFER_ATTR_PERMISSIONS(0x00000004): permissions is present + * - SSH_FILEXFER_ATTR_ACMODTIME (0x00000008): atime and mtime are present + * - SSH_FILEXFER_ATTR_EXTENDED (0x80000000): extended attributes present + */ +internal object SftpFileAttributes { + private const val SSH_FILEXFER_ATTR_SIZE = 0x00000001 + private const val SSH_FILEXFER_ATTR_UIDGID = 0x00000002 + private const val SSH_FILEXFER_ATTR_PERMISSIONS = 0x00000004 + private const val SSH_FILEXFER_ATTR_ACMODTIME = 0x00000008 + private const val SSH_FILEXFER_ATTR_EXTENDED = 0x80000000.toInt() + + /** + * Parse ATTRS from a ByteBuffer at its current position. + */ + fun decode(buf: ByteBuffer): SftpAttributes { + val flags = buf.int + val size = if (flags and SSH_FILEXFER_ATTR_SIZE != 0) buf.long else null + val uid = if (flags and SSH_FILEXFER_ATTR_UIDGID != 0) buf.int else null + val gid = if (flags and SSH_FILEXFER_ATTR_UIDGID != 0) buf.int else null + val permissions = if (flags and SSH_FILEXFER_ATTR_PERMISSIONS != 0) buf.int else null + val atime = if (flags and SSH_FILEXFER_ATTR_ACMODTIME != 0) buf.int else null + val mtime = if (flags and SSH_FILEXFER_ATTR_ACMODTIME != 0) buf.int else null + + // Skip extended attributes if present + if (flags and SSH_FILEXFER_ATTR_EXTENDED != 0) { + val extCount = buf.int + repeat(extCount) { + val typeLen = buf.int + buf.position(buf.position() + typeLen) // skip type string + val dataLen = buf.int + buf.position(buf.position() + dataLen) // skip data string + } + } + + return SftpAttributes( + size = size, + uid = uid, + gid = gid, + permissions = permissions, + atime = atime, + mtime = mtime, + ) + } + + /** + * Encode ATTRS to a byte array. + */ + fun encode(attrs: SftpAttributes): ByteArray { + var flags = 0 + if (attrs.size != null) flags = flags or SSH_FILEXFER_ATTR_SIZE + if (attrs.uid != null || attrs.gid != null) flags = flags or SSH_FILEXFER_ATTR_UIDGID + if (attrs.permissions != null) flags = flags or SSH_FILEXFER_ATTR_PERMISSIONS + if (attrs.atime != null || attrs.mtime != null) flags = flags or SSH_FILEXFER_ATTR_ACMODTIME + + // Calculate size + var size = 4 // flags + if (flags and SSH_FILEXFER_ATTR_SIZE != 0) size += 8 + if (flags and SSH_FILEXFER_ATTR_UIDGID != 0) size += 8 + if (flags and SSH_FILEXFER_ATTR_PERMISSIONS != 0) size += 4 + if (flags and SSH_FILEXFER_ATTR_ACMODTIME != 0) size += 8 + + val buf = ByteBuffer.allocate(size) + buf.putInt(flags) + if (attrs.size != null) buf.putLong(attrs.size) + if (flags and SSH_FILEXFER_ATTR_UIDGID != 0) { + buf.putInt(attrs.uid ?: 0) + buf.putInt(attrs.gid ?: 0) + } + if (attrs.permissions != null) buf.putInt(attrs.permissions) + if (flags and SSH_FILEXFER_ATTR_ACMODTIME != 0) { + buf.putInt(attrs.atime ?: 0) + buf.putInt(attrs.mtime ?: 0) + } + + return buf.array() + } +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/sftp/SftpPacketIO.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/sftp/SftpPacketIO.kt new file mode 100644 index 0000000..673e988 --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/sftp/SftpPacketIO.kt @@ -0,0 +1,149 @@ +/* + * Copyright 2025 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.client.sftp + +import org.connectbot.sshlib.SftpResult +import org.connectbot.sshlib.SshSession +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +/** + * SFTP packet framing over an SSH session channel. + * + * SFTP packets are length-prefixed: `uint32 length + byte type + payload`. + * The length field counts everything after itself (type + payload). + * + * SSH channel data arrives in arbitrary chunks that may not align with SFTP + * packet boundaries. This class accumulates bytes until a complete packet + * is available. + */ +internal class SftpPacketIO(private val session: SshSession) { + private val buffer = ByteArrayOutputStream() + private var bufferedBytes = ByteArray(0) + private var bufferedOffset = 0 + private var bufferedLength = 0 + + /** + * Read a complete SFTP packet. Blocks (suspends) until enough data arrives. + * + * Returns a sealed [SftpResult] rather than throwing — network errors and + * malformed packets are normal failure modes that callers should handle + * explicitly. (Reviewed by @kruton on PR #112: Kotlin library APIs + * shouldn't throw for things they can manage themselves.) + */ + suspend fun readPacket(): SftpResult { + return try { + // Read the 4-byte length prefix + val lengthBytes = readExact(4) + val length = ByteBuffer.wrap(lengthBytes).int + if (length < 1 || length > MAX_PACKET_SIZE) { + return SftpResult.ProtocolError("Invalid SFTP packet length: $length") + } + + // Read the packet body (type + payload) + val body = readExact(length) + val type = body[0].toInt() and 0xFF + val payload = body.copyOfRange(1, body.size) + SftpResult.Success(SftpRawPacket(type, payload)) + } catch (e: ChannelClosedException) { + SftpResult.IoError(e) + } catch (e: Exception) { + SftpResult.IoError(e) + } + } + + /** + * Write an SFTP packet with the given type and payload. + * + * Returns [SftpResult.Success] on send or [SftpResult.IoError] if the + * underlying SSH session write fails. + */ + suspend fun writePacket(type: Int, payload: ByteArray): SftpResult = try { + val length = 1 + payload.size // type byte + payload + val packet = ByteBuffer.allocate(4 + length) + packet.putInt(length) + packet.put(type.toByte()) + packet.put(payload) + session.write(packet.array()) + SftpResult.Success(Unit) + } catch (e: Exception) { + SftpResult.IoError(e) + } + + /** + * Read exactly [count] bytes from the session, accumulating across + * multiple channel data chunks as needed. Throws [ChannelClosedException] + * if the channel closes mid-packet — callers (only [readPacket]) catch + * and translate to [SftpResult.IoError]. Kept private so the throw + * doesn't leak past the API surface. + */ + private suspend fun readExact(count: Int): ByteArray { + val result = ByteArray(count) + var filled = 0 + + // Drain any leftover buffered data first + if (bufferedLength > 0) { + val toCopy = minOf(count, bufferedLength) + System.arraycopy(bufferedBytes, bufferedOffset, result, 0, toCopy) + bufferedOffset += toCopy + bufferedLength -= toCopy + filled += toCopy + } + + // Read from the session until we have enough + while (filled < count) { + val data = session.read() + ?: throw ChannelClosedException("SSH channel closed before complete SFTP packet") + + val toCopy = minOf(count - filled, data.size) + System.arraycopy(data, 0, result, filled, toCopy) + filled += toCopy + + // Buffer any leftover bytes for the next readExact call + if (toCopy < data.size) { + bufferedBytes = data + bufferedOffset = toCopy + bufferedLength = data.size - toCopy + } + } + + return result + } + + companion object { + /** Maximum SFTP packet size (256KB — generous limit). */ + private const val MAX_PACKET_SIZE = 256 * 1024 + } +} + +/** + * Internal exception used by [SftpPacketIO.readExact] to signal a closed + * channel mid-packet. Caught by [SftpPacketIO.readPacket] and translated + * into [SftpResult.IoError]; never escapes this file. + */ +internal class ChannelClosedException(message: String) : Exception(message) + +/** + * Raw SFTP packet with type byte and payload (without the length prefix). + */ +internal data class SftpRawPacket(val type: Int, val payload: ByteArray) { + override fun equals(other: Any?): Boolean = other is SftpRawPacket && type == other.type && payload.contentEquals(other.payload) + + override fun hashCode(): Int = 31 * type + payload.contentHashCode() +} + +internal class SftpProtocolException(message: String) : Exception(message) 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 new file mode 100644 index 0000000..1f397c5 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/sftp/SftpClientIntegrationTest.kt @@ -0,0 +1,322 @@ +/* + * Copyright 2025 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.client.sftp + +import kotlinx.coroutines.runBlocking +import org.connectbot.sshlib.HostKeyVerifier +import org.connectbot.sshlib.PublicKey +import org.connectbot.sshlib.SftpClient +import org.connectbot.sshlib.SftpOpenFlag +import org.connectbot.sshlib.SftpResult +import org.connectbot.sshlib.SftpStatusCode +import org.connectbot.sshlib.SshClient +import org.connectbot.sshlib.SshClientConfig +import org.connectbot.sshlib.getOrThrow +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.slf4j.LoggerFactory +import org.testcontainers.containers.GenericContainer +import org.testcontainers.containers.output.Slf4jLogConsumer +import org.testcontainers.containers.wait.strategy.Wait +import org.testcontainers.images.builder.ImageFromDockerfile +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers + +/** + * Integration tests for SFTP client against a real OpenSSH server. + */ +@Testcontainers +class SftpClientIntegrationTest { + + companion object { + private val logger = LoggerFactory.getLogger(SftpClientIntegrationTest::class.java) + private val logConsumer = Slf4jLogConsumer(logger).withPrefix("DOCKER") + + private const val USERNAME = "testuser" + private const val PASSWORD = "testpass" + + // Tarball-name format expected by wget in the test Dockerfile + // (`openssh-${OPENSSH_VERSION}.tar.gz`). The renovate annotation + // there extracts this from the github tag `V_9_9_P2`. + private const val OPENSSH_VERSION = "9.9p2" + private const val DEBUG_CFLAGS = "" + + @Container + @JvmStatic + val opensshContainer: GenericContainer<*> = GenericContainer( + ImageFromDockerfile("openssh-sftp-test", false) + .withFileFromClasspath(".", "openssh-server") + .withBuildArg("OPENSSH_VERSION", OPENSSH_VERSION) + .withBuildArg("DEBUG_CFLAGS", DEBUG_CFLAGS), + ) + .withExposedPorts(22) + .withLogConsumer(logConsumer) + .waitingFor( + Wait.forLogMessage(".*Server listening.*", 1), + ) + } + + private val acceptAllVerifier = object : HostKeyVerifier { + override suspend fun verify(key: PublicKey): Boolean = true + } + + private suspend fun openSftp(): Pair { + val host = opensshContainer.host + val port = opensshContainer.getMappedPort(22) + + val config = SshClientConfig { + this.host = host + this.port = port + this.hostKeyVerifier = acceptAllVerifier + } + val client = SshClient(config) + + val connectResult = client.connect() + assertTrue(connectResult is org.connectbot.sshlib.ConnectResult.Success, "Should connect to SSH server, got: $connectResult") + val authResult = client.authenticatePassword(USERNAME, PASSWORD) + assertTrue(authResult is org.connectbot.sshlib.AuthResult.Success, "Should authenticate, got: $authResult") + + val sftpResult = client.openSftp() + assertTrue(sftpResult is SftpResult.Success, "Should open SFTP session, got: $sftpResult") + val sftp = (sftpResult as SftpResult.Success).value + return Pair(client, sftp) + } + + @Test + fun `should negotiate SFTP version 3`() = runBlocking { + val (client, sftp) = openSftp() + try { + assertTrue(sftp.protocolVersion >= 3, "SFTP version should be >= 3") + assertTrue(sftp.isOpen, "SFTP session should be open") + } finally { + sftp.close() + client.disconnect() + } + } + + @Test + fun `should stat root directory`() = runBlocking { + val (client, sftp) = openSftp() + try { + val attrs = sftp.stat("/").getOrThrow() + assertNotNull(attrs.permissions, "Root dir should have permissions") + } finally { + sftp.close() + client.disconnect() + } + } + + @Test + fun `should resolve realpath`() = runBlocking { + val (client, sftp) = openSftp() + try { + val path = sftp.realpath(".").getOrThrow() + assertTrue(path.startsWith("/"), "Realpath should return absolute path: $path") + } finally { + sftp.close() + client.disconnect() + } + } + + @Test + fun `should create write read and delete file`() = runBlocking { + val (client, sftp) = openSftp() + try { + val testPath = "/tmp/sftp-test-${System.currentTimeMillis()}.txt" + val testData = "Hello SFTP!\n".toByteArray() + + // Create and write + val handle = sftp.open( + testPath, + setOf(SftpOpenFlag.WRITE, SftpOpenFlag.CREATE, SftpOpenFlag.TRUNCATE), + ).getOrThrow() + sftp.write(handle, 0, testData).getOrThrow() + sftp.close(handle).getOrThrow() + + // Read back + val readHandle = sftp.open(testPath, setOf(SftpOpenFlag.READ)).getOrThrow() + val readData = sftp.read(readHandle, 0, testData.size).getOrThrow() + sftp.close(readHandle).getOrThrow() + + assertNotNull(readData) + assertTrue(testData.contentEquals(readData!!), "Read data should match written data") + + // Stat + val attrs = sftp.stat(testPath).getOrThrow() + assertEquals(testData.size.toLong(), attrs.size, "File size should match") + + // Delete + sftp.remove(testPath).getOrThrow() + + // Verify deleted + val result = sftp.stat(testPath) + assertTrue(result is SftpResult.ServerError, "stat should fail after delete") + assertEquals(SftpStatusCode.NO_SUCH_FILE, (result as SftpResult.ServerError).statusCode) + } finally { + sftp.close() + client.disconnect() + } + } + + @Test + fun `should list directory`() = runBlocking { + val (client, sftp) = openSftp() + try { + val entries = sftp.listdir("/tmp").getOrThrow() + assertTrue(entries.isNotEmpty(), "Directory listing should not be empty") + assertTrue(entries.any { it.filename == "." }, "Should contain '.'") + assertTrue(entries.any { it.filename == ".." }, "Should contain '..'") + } finally { + sftp.close() + client.disconnect() + } + } + + @Test + fun `should create and remove directory`() = runBlocking { + val (client, sftp) = openSftp() + try { + val dirPath = "/tmp/sftp-dir-${System.currentTimeMillis()}" + + sftp.mkdir(dirPath).getOrThrow() + + val attrs = sftp.stat(dirPath).getOrThrow() + assertNotNull(attrs.permissions) + + sftp.rmdir(dirPath).getOrThrow() + + val result = sftp.stat(dirPath) + assertTrue(result is SftpResult.ServerError, "stat should fail after rmdir") + assertEquals(SftpStatusCode.NO_SUCH_FILE, (result as SftpResult.ServerError).statusCode) + } finally { + sftp.close() + client.disconnect() + } + } + + @Test + fun `should rename a file`() = runBlocking { + val (client, sftp) = openSftp() + try { + val ts = System.currentTimeMillis() + val oldPath = "/tmp/sftp-old-$ts.txt" + val newPath = "/tmp/sftp-new-$ts.txt" + + // Create file + val handle = sftp.open(oldPath, setOf(SftpOpenFlag.WRITE, SftpOpenFlag.CREATE)).getOrThrow() + sftp.write(handle, 0, "rename test".toByteArray()).getOrThrow() + sftp.close(handle).getOrThrow() + + // Rename + sftp.rename(oldPath, newPath).getOrThrow() + + // Old path should not exist + val result = sftp.stat(oldPath) + assertTrue(result is SftpResult.ServerError, "Old path should not exist after rename") + assertEquals(SftpStatusCode.NO_SUCH_FILE, (result as SftpResult.ServerError).statusCode) + + // New path should exist + sftp.stat(newPath).getOrThrow() + + // Cleanup + sftp.remove(newPath).getOrThrow() + } finally { + sftp.close() + client.disconnect() + } + } + + @Test + fun `should handle file not found`() = runBlocking { + val (client, sftp) = openSftp() + try { + val result = sftp.stat("/nonexistent/path/that/does/not/exist") + assertTrue(result is SftpResult.ServerError, "Should return ServerError for nonexistent path") + assertEquals(SftpStatusCode.NO_SUCH_FILE, (result as SftpResult.ServerError).statusCode) + } finally { + sftp.close() + client.disconnect() + } + } + + @Test + fun `should handle large file transfer`() = runBlocking { + val (client, sftp) = openSftp() + try { + val testPath = "/tmp/sftp-large-${System.currentTimeMillis()}.bin" + // 64KB — exercises SSH channel window adjustment + val testData = ByteArray(64 * 1024) { (it % 256).toByte() } + + val writeHandle = sftp.open( + testPath, + setOf(SftpOpenFlag.WRITE, SftpOpenFlag.CREATE, SftpOpenFlag.TRUNCATE), + ).getOrThrow() + sftp.write(writeHandle, 0, testData).getOrThrow() + sftp.close(writeHandle).getOrThrow() + + // Read back in chunks + val readHandle = sftp.open(testPath, setOf(SftpOpenFlag.READ)).getOrThrow() + val readBuffer = mutableListOf() + var offset = 0L + while (true) { + val chunk = sftp.read(readHandle, offset, 16384).getOrThrow() ?: break + readBuffer.addAll(chunk.toList()) + offset += chunk.size + } + sftp.close(readHandle).getOrThrow() + + assertEquals(testData.size, readBuffer.size, "Should read back all bytes") + assertTrue( + testData.contentEquals(readBuffer.toByteArray()), + "Read data should match written data", + ) + + sftp.remove(testPath).getOrThrow() + } finally { + sftp.close() + client.disconnect() + } + } + + @Test + fun `should set file attributes`() = runBlocking { + val (client, sftp) = openSftp() + try { + val testPath = "/tmp/sftp-attrs-${System.currentTimeMillis()}.txt" + + val handle = sftp.open(testPath, setOf(SftpOpenFlag.WRITE, SftpOpenFlag.CREATE)).getOrThrow() + sftp.close(handle).getOrThrow() + + // Set permissions to 0644 + sftp.setstat(testPath, org.connectbot.sshlib.SftpAttributes(permissions = 0b110_100_100)).getOrThrow() + + val attrs = sftp.stat(testPath).getOrThrow() + assertEquals( + 0b110_100_100, + (attrs.permissions ?: 0) and 0x1FF, + "Permissions should be 0644", + ) + + sftp.remove(testPath).getOrThrow() + } finally { + sftp.close() + client.disconnect() + } + } +}