summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitmodules6
-rw-r--r--.idea/gradle.xml1
-rw-r--r--.idea/vcs.xml2
-rw-r--r--app/build.gradle.kts1
-rw-r--r--libs/samba/src/main/cpp/jni.cpp134
-rw-r--r--libs/samba/src/main/cpp/jni.hpp7
-rw-r--r--libs/sftp/CMakeLists.txt46
-rw-r--r--libs/sftp/build.gradle.kts205
m---------libs/sftp/libssh20
l---------libs/sftp/libssh2.pc.in1
m---------libs/sftp/openssl0
-rw-r--r--libs/sftp/src/.gitignore2
l---------libs/sftp/src/main/cpp/jni.cpp1
l---------libs/sftp/src/main/cpp/jni.hpp1
-rw-r--r--libs/sftp/src/main/cpp/sftp.cpp688
-rw-r--r--libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/NativeSftp.kt289
-rw-r--r--libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpConnection.kt132
-rw-r--r--libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpCredentials.kt13
-rw-r--r--libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpDirectory.kt195
-rw-r--r--libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpFile.kt113
-rw-r--r--libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpLink.kt77
-rw-r--r--libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpTree.kt17
-rw-r--r--libs/sftp/src/main/java/org/the_jk/cleversync/sftp/SftpTreeFactory.kt24
-rw-r--r--libs/sftp/src/test/docker/docker-compose.yml12
-rw-r--r--libs/sftp/src/test/docker/ssh_host_ed25519_key7
-rw-r--r--libs/sftp/src/test/docker/ssh_host_ed25519_key.pub1
-rw-r--r--libs/sftp/src/test/docker/ssh_host_rsa_key49
-rw-r--r--libs/sftp/src/test/docker/ssh_host_rsa_key.pub1
-rw-r--r--libs/sftp/src/test/docker/user_private.pem30
-rw-r--r--libs/sftp/src/test/docker/user_public.pem9
-rw-r--r--libs/sftp/src/test/java/org/the_jk/cleversync/sftp/SftpTreeTest.kt168
-rw-r--r--libs/test-utils/src/main/java/org/the_jk/cleversync/TreeAbstractTest.kt4
-rw-r--r--settings.gradle.kts1
33 files changed, 2234 insertions, 3 deletions
diff --git a/.gitmodules b/.gitmodules
index bdcf1e8..dcb5649 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,9 @@
[submodule "libs/samba/libsmb2"]
path = libs/samba/libsmb2
url = https://github.com/sahlberg/libsmb2.git
+[submodule "libs/sftp/libssh2"]
+ path = libs/sftp/libssh2
+ url = https://github.com/libssh2/libssh2.git
+[submodule "libs/sftp/openssl"]
+ path = libs/sftp/openssl
+ url = https://github.com/openssl/openssl.git
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 098a16f..86a0137 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -27,6 +27,7 @@
<option value="$PROJECT_DIR$/libs/io" />
<option value="$PROJECT_DIR$/libs/local" />
<option value="$PROJECT_DIR$/libs/samba" />
+ <option value="$PROJECT_DIR$/libs/sftp" />
<option value="$PROJECT_DIR$/libs/test-utils" />
<option value="$PROJECT_DIR$/libs/utils" />
</set>
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 81a60f7..2359781 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -3,5 +3,7 @@
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libs/samba/libsmb2" vcs="Git" />
+ <mapping directory="$PROJECT_DIR$/libs/sftp/libssh2" vcs="Git" />
+ <mapping directory="$PROJECT_DIR$/libs/sftp/openssl" vcs="Git" />
</component>
</project> \ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 729a124..a2feb42 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -29,6 +29,7 @@ dependencies {
implementation(project(":libs:local"))
implementation(project(":libs:io"))
implementation(project(":libs:samba"))
+ implementation(project(":libs:sftp"))
implementation(project(":libs:utils"))
implementation(libs.androidx.appcompat)
implementation(libs.androidx.constraintlayout)
diff --git a/libs/samba/src/main/cpp/jni.cpp b/libs/samba/src/main/cpp/jni.cpp
index 5a69dc5..b7bbd1a 100644
--- a/libs/samba/src/main/cpp/jni.cpp
+++ b/libs/samba/src/main/cpp/jni.cpp
@@ -1,5 +1,8 @@
#include "jni.hpp"
+#include <algorithm>
+#include <optional>
+
#ifdef ANDROID
#include <android/log.h>
#else
@@ -31,6 +34,103 @@ const char *_jni_error(jint err) {
}
}
+const char* u8_read(const char* str, uint32_t& out) {
+ // Assume valid UTF-8 for speed and so it can be used to read modified utf-8 as well
+ switch (*str >> 4) {
+ case 0xf: // 4 byte
+ out = (static_cast<uint32_t>(str[0] & 0x7) << 18) |
+ (static_cast<uint32_t>(str[1] & 0x3f) << 12) |
+ (static_cast<uint32_t>(str[2] & 0x3f) << 6) |
+ static_cast<uint8_t>(str[3] & 0x3f);
+ return str + 4;
+ case 0xe: // 3 byte
+ out = (static_cast<uint32_t>(str[0] & 0xf) << 12) |
+ (static_cast<uint32_t>(str[1] & 0x3f) << 6) |
+ static_cast<uint8_t>(str[2] & 0x3f);
+ return str + 3;
+ case 0xd:
+ case 0xc: // 2 byte
+ out = (static_cast<uint32_t>(str[0] & 0x1f) << 6) |
+ static_cast<uint8_t>(str[1] & 0x3f);
+ return str + 2;
+ default: // 1 byte
+ out = static_cast<uint8_t>(str[0]);
+ return str + 1;
+ }
+}
+
+void u8_write(std::string& ret, uint32_t c) {
+ if (c < 0x80) {
+ ret.push_back(static_cast<char>(c));
+ } else if (c < 0x800) {
+ ret.push_back(static_cast<char>(0xc0 | (c >> 6)));
+ ret.push_back(static_cast<char>(0x80 | (c & 0x3f)));
+ } else if (c < 0x10000) {
+ ret.push_back(static_cast<char>(0xe0 | (c >> 12)));
+ ret.push_back(static_cast<char>(0x80 | ((c >> 6) & 0x3f)));
+ ret.push_back(static_cast<char>(0x80 | (c & 0x3f)));
+ } else {
+ ret.push_back(static_cast<char>(0xf0 | (c >> 18)));
+ ret.push_back(static_cast<char>(0x80 | ((c >> 12) & 0x3f)));
+ ret.push_back(static_cast<char>(0x80 | ((c >> 6) & 0x3f)));
+ ret.push_back(static_cast<char>(0x80 | (c & 0x3f)));
+ }
+}
+
+std::string MakeModifiedUTF8(const char* str, const char* ptr, uint32_t c) {
+ std::string ret;
+ while (true) {
+ ret.append(str, ptr - str);
+ str = ptr;
+ u8_write(ret, 0xd800 + ((c & 0xffff) >> 10));
+ u8_write(ret, 0xdc00 + (c & 0x3ff));
+ if (!*ptr) break;
+ do {
+ ptr = u8_read(ptr, c);
+ if (c > 0xffff)
+ break;
+ } while (*ptr);
+ }
+ return ret;
+}
+
+std::optional<std::string> MakeModifiedUTF8IfNeeded(const char* str) {
+ auto* ptr = str;
+ while (*ptr) {
+ uint32_t c;
+ ptr = u8_read(ptr, c);
+ if (c > 0xffff) {
+ return MakeModifiedUTF8(str, ptr, c);
+ }
+ }
+ return std::nullopt;
+}
+
+const char* splice(std::string& str, const char* start, const char* end, const char* insert, size_t size) {
+ auto pos = start - str.c_str();
+ str.replace(pos, end - start, insert, size);
+ return str.c_str() + pos + size;
+}
+
+void UnmodifyUTF8(std::string& str) {
+ auto* ptr = str.c_str();
+ while (*ptr) {
+ uint32_t u;
+ auto* next = u8_read(ptr, u);
+ if (u == 0) {
+ next = splice(str, ptr, next, "\0", 1);
+ } else if (u >= 0xd800 && u <= 0xdfff) {
+ uint32_t v;
+ next = u8_read(next, v);
+ u = 0x10000 | ((u - 0xd800) << 10) | (v - 0xdc00);
+ std::string tmp;
+ u8_write(tmp, u);
+ next = splice(str, ptr, next, tmp.data(), tmp.size());
+ }
+ ptr = next;
+ }
+}
+
} // namespace
namespace jni {
@@ -132,14 +232,46 @@ std::string StringToUTF8(JNIEnv* env, const Ref<jstring>& str) {
auto len = env->GetStringUTFLength(str.get());
std::string ret(len, ' ');
env->GetStringUTFRegion(str.get(), 0, len, ret.data());
- // This returns modified UTF-8 encoding, don't care.
+ UnmodifyUTF8(ret);
return ret;
}
LocalRef<jstring> UTF8ToString(JNIEnv* env, const std::string& str) {
+ auto ret = MakeModifiedUTF8IfNeeded(str.c_str());
+ if (ret.has_value()) {
+ return {env, env->NewStringUTF(ret->c_str())};
+ }
return {env, env->NewStringUTF(str.c_str())};
}
+LocalRef<jstring> UTF8ToString(JNIEnv* env, const char* str) {
+ if (str == nullptr) return nullptr;
+ auto ret = MakeModifiedUTF8IfNeeded(str);
+ if (ret.has_value()) {
+ return {env, env->NewStringUTF(ret->c_str())};
+ }
+ return {env, env->NewStringUTF(str)};
+}
+
+LocalRef<jbyteArray> VectorToByteArray(JNIEnv* env, const std::vector<uint8_t>& data) {
+ auto len = static_cast<jsize>(data.size());
+ auto ret = LocalRef<jbyteArray>(env, env->NewByteArray(len));
+ ABORT_IF_NULL(env, ret);
+ auto* ptr = reinterpret_cast<jbyte*>(env->GetPrimitiveArrayCritical(ret.get(), nullptr));
+ std::copy_n(data.data(), data.size(), ptr);
+ env->ReleasePrimitiveArrayCritical(ret.get(), ptr, JNI_COMMIT);
+ return ret;
+}
+
+std::vector<uint8_t> ByteArrayToVector(JNIEnv* env, const Ref<jbyteArray>& data) {
+ if (!data) return {};
+ auto len = env->GetArrayLength(data.get());
+ std::vector<uint8_t> ret(len);
+ static_assert(sizeof(jbyte) == sizeof(uint8_t));
+ env->GetByteArrayRegion(data.get(), 0, len, reinterpret_cast<jbyte*>(ret.data()));
+ return ret;
+}
+
LocalRef<jobjectArray> CreateArray(JNIEnv* env, const Ref<jclass>& element_class, std::vector<LocalRef<jobject>> objects) {
auto ret = LocalRef<jobjectArray>(env, env->NewObjectArray(static_cast<jsize>(objects.size()), element_class.get(), nullptr));
ABORT_IF_NULL(env, ret);
diff --git a/libs/samba/src/main/cpp/jni.hpp b/libs/samba/src/main/cpp/jni.hpp
index 1729828..b9bdb69 100644
--- a/libs/samba/src/main/cpp/jni.hpp
+++ b/libs/samba/src/main/cpp/jni.hpp
@@ -2,6 +2,7 @@
#define CLEVERSYNC_JNI_HPP
#include <jni.h>
+#include <cstdint>
#include <string>
#include <vector>
@@ -186,6 +187,12 @@ std::string StringToUTF8(JNIEnv* env, const Ref<jstring>& str);
LocalRef<jstring> UTF8ToString(JNIEnv* env, const std::string& str);
+LocalRef<jstring> UTF8ToString(JNIEnv* env, const char* str);
+
+LocalRef<jbyteArray> VectorToByteArray(JNIEnv* env, const std::vector<uint8_t>& data);
+
+std::vector<uint8_t> ByteArrayToVector(JNIEnv* env, const Ref<jbyteArray>& data);
+
LocalRef<jobjectArray> CreateArray(JNIEnv* env, const Ref<jclass>& element_class, std::vector<LocalRef<jobject>> objects);
namespace internal {
diff --git a/libs/sftp/CMakeLists.txt b/libs/sftp/CMakeLists.txt
new file mode 100644
index 0000000..4d6ebda
--- /dev/null
+++ b/libs/sftp/CMakeLists.txt
@@ -0,0 +1,46 @@
+cmake_minimum_required(VERSION 3.12)
+
+project(libssh2
+ VERSION 0.11.0
+ LANGUAGES C CXX)
+
+add_compile_options(-Wno-deprecated-non-prototype -Wno-writable-strings)
+
+option(BUILD_SHARED_LIBS "Build shared libraries" ON)
+
+set(SOVERSION 1 CACHE STRING "" FORCE)
+
+include_directories(
+ ${CMAKE_CURRENT_BINARY_DIR}
+ libssh2/include
+)
+
+set(CRYPTO_BACKEND "OpenSSL")
+if (ANDROID)
+ set(OPENSSL_INCLUDE_DIR ${OPENSSL_BUILD_DIR}/${ANDROID_ABI}/include)
+ set(OPENSSL_CRYPTO_LIBRARY ${OPENSSL_BUILD_DIR}/${ANDROID_ABI}/libcrypto.so)
+ set(OPENSSL_SSL_LIBRARY ${OPENSSL_BUILD_DIR}/${ANDROID_ABI}/libssl.so)
+else()
+ set(OPENSSL_INCLUDE_DIR ${OPENSSL_BUILD_DIR}/host/include)
+ set(OPENSSL_CRYPTO_LIBRARY ${OPENSSL_BUILD_DIR}/host/libcrypto.so)
+ set(OPENSSL_SSL_LIBRARY ${OPENSSL_BUILD_DIR}/host/libssl.so)
+endif()
+
+add_subdirectory(libssh2)
+
+if (ANDROID)
+ find_library(log-lib log)
+else()
+ find_package(JNI)
+ include_directories(${JNI_INCLUDE_DIRS})
+endif()
+
+add_library(
+ sftpjni
+ SHARED
+ src/main/cpp/jni.cpp
+ src/main/cpp/jni.hpp
+ src/main/cpp/sftp.cpp
+)
+
+target_link_libraries(sftpjni libssh2_shared ${log-lib})
diff --git a/libs/sftp/build.gradle.kts b/libs/sftp/build.gradle.kts
new file mode 100644
index 0000000..4ccac9c
--- /dev/null
+++ b/libs/sftp/build.gradle.kts
@@ -0,0 +1,205 @@
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+val shareDir = layout.buildDirectory.dir("test-share")
+val dockerDir = layout.projectDirectory.dir("src/test/docker")
+
+val openssl = layout.projectDirectory.dir("openssl")
+val opensslBaseBuildDir = layout.buildDirectory.dir("openssl")
+
+android {
+ namespace = "org.the_jk.cleversync.sftp"
+
+ externalNativeBuild {
+ cmake {
+ path("CMakeLists.txt")
+ }
+ }
+
+ defaultConfig {
+ externalNativeBuild {
+ cmake {
+ // Need both source and build include, CMakeLists.txt sets up build
+ // so add source here.
+ cFlags += listOf("-I${openssl.dir("include")}")
+ targets("sftpjni")
+ arguments("-DOPENSSL_BUILD_DIR=${opensslBaseBuildDir.get()}")
+ }
+ }
+ }
+
+ buildTypes {
+ debug {
+ externalNativeBuild {
+ cmake {
+ cFlags += listOf("-g")
+ }
+ }
+ }
+ release {
+ externalNativeBuild {
+ cmake {
+ cFlags += listOf("-O3")
+ }
+ }
+ }
+ }
+
+ testOptions {
+ unitTests {
+ all { test ->
+ test.doFirst {
+ shareDir.get().asFile.mkdirs()
+ }
+
+ test.systemProperty("dockerDir", dockerDir.toString())
+ test.systemProperty("shareDir", shareDir.get().toString())
+ }
+ }
+ }
+}
+
+dependencies {
+ implementation(project(":libs:io"))
+ testImplementation(project(":libs:utils"))
+ testImplementation(project(":libs:test-utils"))
+}
+
+listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64").forEach { arch ->
+ val buildDir = opensslBaseBuildDir.map { it.dir(arch) }
+
+ val configure by tasks.register<Exec>(
+ "configureOpenssl[${arch}]"
+ ) {
+ doFirst {
+ buildDir.get().asFile.mkdirs()
+ }
+
+ onlyIf {
+ !buildDir.get().file("configdata.pm").asFile.exists()
+ }
+
+ environment(
+ "ANDROID_NDK_ROOT" to android.ndkDirectory.toString(),
+ "PATH" to "${android.ndkDirectory}/toolchains/llvm/prebuilt/linux-x86_64/bin:${System.getenv("PATH")}",
+ )
+ workingDir = buildDir.get().asFile
+ executable = openssl.file("Configure").asFile.toString()
+ args(
+ when (arch) {
+ "armeabi-v7a" -> "android-arm"
+ "arm64-v8a" -> "android-arm64"
+ "x86" -> "android-x86"
+ "x86_64" -> "android-x86_64"
+ else -> ""
+ },
+ )
+ }
+
+ val build by tasks.register<Exec>(
+ "buildOpenssl[${arch}]"
+ ) {
+ dependsOn(configure)
+ environment(
+ "ANDROID_NDK_ROOT" to android.ndkDirectory.toString(),
+ "PATH" to "${android.ndkDirectory}/toolchains/llvm/prebuilt/linux-x86_64/bin:${System.getenv("PATH")}",
+ )
+ workingDir = buildDir.get().asFile
+ executable = "make"
+ args(
+ listOf(
+ "-j",
+ Runtime.getRuntime().availableProcessors() - 1,
+ "--quiet",
+ )
+ )
+ }
+
+ tasks.named {
+ it == "configureCMakeDebug[$arch]" ||
+ it == "configureCMakeWithDebInfo[$arch]"
+ }.configureEach {
+ dependsOn(build)
+ }
+}
+
+val buildDirHostSsl = opensslBaseBuildDir.map { it.dir("host") }
+
+val configureHostSsl by tasks.register<Exec>(
+ "configureOpenssl[host]"
+) {
+ doFirst {
+ buildDirHostSsl.get().asFile.mkdirs()
+ }
+
+ onlyIf {
+ !buildDirHostSsl.get().file("configdata.pm").asFile.exists()
+ }
+
+ workingDir = buildDirHostSsl.get().asFile
+ executable = openssl.file("Configure").asFile.toString()
+}
+
+val buildHostSsl by tasks.register<Exec>(
+ "buildOpenssl[host]"
+) {
+ dependsOn(configureHostSsl)
+ workingDir = buildDirHostSsl.get().asFile
+ executable = "make"
+ args(
+ listOf(
+ "-j",
+ Runtime.getRuntime().availableProcessors() - 1,
+ "--quiet",
+ )
+ )
+}
+
+listOf("Debug", "Release").forEach { buildType ->
+ val buildDir = project.layout.buildDirectory.dir("test-cpp/$buildType/build")
+
+ val configure by tasks.register(
+ "configureLibsFor${buildType}UnitTest",
+ Exec::class
+ ) {
+ dependsOn(buildHostSsl)
+
+ environment(
+ "CFLAGS" to "-I${openssl.dir("include")}",
+ )
+ args(
+ "-S",
+ project.layout.projectDirectory.dir("."),
+ "-B",
+ buildDir.get(),
+ "-DOPENSSL_BUILD_DIR=${opensslBaseBuildDir.get()}",
+ )
+ executable = "cmake"
+ }
+
+ val compile by tasks.register(
+ "compileLibsFor${buildType}UnitTest",
+ Exec::class
+ ) {
+ dependsOn(configure)
+ args(
+ "--build", buildDir.get(),
+ "--target", "sftpjni",
+ )
+ executable = "cmake"
+ }
+
+ val copy by tasks.register(
+ "copyLibsFor${buildType}UnitTest",
+ Copy::class
+ ) {
+ dependsOn(compile)
+ from(buildDir.map { it.file("libsftpjni.so") })
+ into(project.layout.projectDirectory.dir("src/test${buildType}/jniLibs"))
+ }
+
+ tasks.matching { it.name == "test${buildType}UnitTest" }.all {
+ dependsOn(copy)
+ }
+}
diff --git a/libs/sftp/libssh2 b/libs/sftp/libssh2
new file mode 160000
+Subproject 1c3f1b7da588f2652260285529ec3c1f1125eb4
diff --git a/libs/sftp/libssh2.pc.in b/libs/sftp/libssh2.pc.in
new file mode 120000
index 0000000..4a05c96
--- /dev/null
+++ b/libs/sftp/libssh2.pc.in
@@ -0,0 +1 @@
+libssh2/libssh2.pc.in \ No newline at end of file
diff --git a/libs/sftp/openssl b/libs/sftp/openssl
new file mode 160000
+Subproject fb7fab9fa6f4869eaa8fbb97e0d593159f03ffe
diff --git a/libs/sftp/src/.gitignore b/libs/sftp/src/.gitignore
new file mode 100644
index 0000000..2b22da4
--- /dev/null
+++ b/libs/sftp/src/.gitignore
@@ -0,0 +1,2 @@
+testDebug/
+testRelease/
diff --git a/libs/sftp/src/main/cpp/jni.cpp b/libs/sftp/src/main/cpp/jni.cpp
new file mode 120000
index 0000000..4aa7f0b
--- /dev/null
+++ b/libs/sftp/src/main/cpp/jni.cpp
@@ -0,0 +1 @@
+../../../../samba/src/main/cpp/jni.cpp \ No newline at end of file
diff --git a/libs/sftp/src/main/cpp/jni.hpp b/libs/sftp/src/main/cpp/jni.hpp
new file mode 120000
index 0000000..ea14057
--- /dev/null
+++ b/libs/sftp/src/main/cpp/jni.hpp
@@ -0,0 +1 @@
+../../../../samba/src/main/cpp/jni.hpp \ No newline at end of file
diff --git a/libs/sftp/src/main/cpp/sftp.cpp b/libs/sftp/src/main/cpp/sftp.cpp
new file mode 100644
index 0000000..0feafb1
--- /dev/null
+++ b/libs/sftp/src/main/cpp/sftp.cpp
@@ -0,0 +1,688 @@
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <netdb.h>
+#include <unistd.h>
+
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "jni.hpp"
+#include "libssh2.h"
+#include "libssh2_sftp.h"
+
+namespace {
+
+class SftpOwner {
+ public:
+ SftpOwner(std::shared_ptr<LIBSSH2_SESSION> ssh_session, LIBSSH2_SFTP* sftp)
+ : ssh_session_(std::move(ssh_session)), sftp_(sftp) {}
+
+ ~SftpOwner() {
+ libssh2_sftp_shutdown(sftp_);
+ }
+
+ SftpOwner(const SftpOwner&) = delete;
+ SftpOwner& operator=(const SftpOwner&) = delete;
+
+ [[nodiscard]] LIBSSH2_SFTP* get() const { return sftp_; }
+
+ private:
+ std::shared_ptr<LIBSSH2_SESSION> ssh_session_;
+ LIBSSH2_SFTP* sftp_;
+};
+
+class SftpHandle {
+ public:
+ SftpHandle(std::shared_ptr<SftpOwner> sftp, LIBSSH2_SFTP_HANDLE* handle)
+ : sftp_(std::move(sftp)), handle_(handle) {}
+
+ ~SftpHandle() {
+ libssh2_sftp_close_handle(handle_);
+ }
+
+ SftpHandle(const SftpHandle&) = delete;
+ SftpHandle& operator=(const SftpHandle&) = delete;
+
+ protected:
+ [[nodiscard]] LIBSSH2_SFTP_HANDLE* get() const { return handle_; }
+
+ private:
+ std::shared_ptr<SftpOwner> sftp_;
+ LIBSSH2_SFTP_HANDLE* handle_;
+};
+
+jni::LocalRef<jobject> CreateDirEntry(JNIEnv* env, const char* name,
+ const LIBSSH2_SFTP_ATTRIBUTES& attrs);
+
+class Dir : SftpHandle {
+ public:
+ Dir(std::shared_ptr<SftpOwner> sftp, LIBSSH2_SFTP_HANDLE *handle)
+ : SftpHandle(std::move(sftp), handle) {}
+
+ jni::LocalRef<jobjectArray> List(JNIEnv* env, const jni::Ref<jclass>& entry_clazz) {
+ std::vector<jni::LocalRef<jobject>> tmp;
+ size_t bufsize = 1024;
+ auto buf = std::make_unique<char[]>(bufsize);
+ LIBSSH2_SFTP_ATTRIBUTES attrs;
+ while (true) {
+ auto size = bufsize / 2;
+ auto ret = libssh2_sftp_readdir_ex(get(), buf.get(), size, buf.get() + size, size, &attrs);
+ if (ret == 0)
+ break;
+ if (ret > 0) {
+ // Skip . and .. entries.
+ if (buf[0] == '.' && (ret == 1 || (buf[1] == '.' && ret == 2)))
+ continue;
+ auto obj = CreateDirEntry(env, buf.get(), attrs);
+ if (obj)
+ tmp.push_back(std::move(obj));
+ } else if (ret == LIBSSH2_ERROR_BUFFER_TOO_SMALL) {
+ auto newsize = bufsize * 2;
+ // Protect against overflow
+ if (newsize <= bufsize)
+ break;
+ buf = std::make_unique<char[]>(newsize);
+ } else {
+ // Error
+ break;
+ }
+ }
+ return jni::CreateArray(env, entry_clazz, std::move(tmp));
+ }
+};
+
+class File : SftpHandle {
+ public:
+ File(std::shared_ptr<SftpOwner> sftp, LIBSSH2_SFTP_HANDLE *handle)
+ : SftpHandle(std::move(sftp), handle) {}
+
+ int32_t Read(uint8_t* data, int32_t size) {
+ if (size <= 0) return 0;
+ return libssh2_sftp_read(get(), reinterpret_cast<char*>(data), size);
+ }
+
+ void Seek(int64_t offset) {
+ libssh2_sftp_seek64(get(), offset);
+ }
+
+ int32_t Write(const uint8_t* data, int32_t size) {
+ if (size <= 0) return 0;
+ return libssh2_sftp_write(get(), reinterpret_cast<const char*>(data), size);
+ }
+};
+
+class SftpSession {
+ public:
+ explicit SftpSession(std::shared_ptr<SftpOwner> sftp)
+ : sftp_(std::move(sftp)) {}
+
+ ~SftpSession() = default;
+
+ SftpSession(const SftpSession &) = delete;
+
+ SftpSession &operator=(const SftpSession &) = delete;
+
+ std::string GetLastError() {
+ switch (libssh2_sftp_last_error(sftp_->get())) {
+ case LIBSSH2_FX_OK:
+ return "";
+ case LIBSSH2_FX_EOF:
+ return "End of file";
+ case LIBSSH2_FX_NO_SUCH_FILE:
+ return "No such file";
+ case LIBSSH2_FX_PERMISSION_DENIED:
+ return "Permission denied";
+ case LIBSSH2_FX_FAILURE:
+ return "Failure";
+ case LIBSSH2_FX_BAD_MESSAGE:
+ return "Bad message";
+ case LIBSSH2_FX_NO_CONNECTION:
+ return "No connection";
+ case LIBSSH2_FX_CONNECTION_LOST:
+ return "Connection lost";
+ case LIBSSH2_FX_OP_UNSUPPORTED:
+ return "Operation unsupported";
+ case LIBSSH2_FX_INVALID_HANDLE:
+ return "Invalid handle";
+ case LIBSSH2_FX_NO_SUCH_PATH:
+ return "No such path";
+ case LIBSSH2_FX_FILE_ALREADY_EXISTS:
+ return "File already exists";
+ case LIBSSH2_FX_WRITE_PROTECT:
+ return "Write protected";
+ case LIBSSH2_FX_NO_MEDIA:
+ return "No media";
+ case LIBSSH2_FX_NO_SPACE_ON_FILESYSTEM:
+ return "No space on filesystem";
+ case LIBSSH2_FX_QUOTA_EXCEEDED:
+ return "Quota exceeded";
+ case LIBSSH2_FX_UNKNOWN_PRINCIPAL:
+ return "Unknown principal";
+ case LIBSSH2_FX_LOCK_CONFLICT:
+ return "Lock conflict";
+ case LIBSSH2_FX_DIR_NOT_EMPTY:
+ return "Directory is not empty";
+ case LIBSSH2_FX_NOT_A_DIRECTORY:
+ return "Not a directory";
+ case LIBSSH2_FX_INVALID_FILENAME:
+ return "Invalid filename";
+ case LIBSSH2_FX_LINK_LOOP:
+ return "Link loop";
+ default:
+ return "Unknown error";
+ }
+ }
+
+ std::unique_ptr<Dir> OpenDir(const std::string &path) {
+ auto* handle = libssh2_sftp_open_ex(sftp_->get(), path.data(), path.size(),
+ LIBSSH2_FXF_READ,
+ 0700,
+ LIBSSH2_SFTP_OPENDIR);
+ if (!handle) return nullptr;
+ return std::make_unique<Dir>(sftp_, handle);
+ }
+
+ bool MakeDir(const std::string &path, int32_t mode) {
+ return libssh2_sftp_mkdir_ex(sftp_->get(), path.data(), path.size(), mode) == 0;
+ }
+
+ bool RemoveDir(const std::string &path) {
+ return libssh2_sftp_rmdir_ex(sftp_->get(), path.data(), path.size()) == 0;
+ }
+
+ std::unique_ptr<File> OpenFile(const std::string &path, int32_t mode) {
+ unsigned long flags = 0;
+ switch (mode) {
+ case 0: // READ(0),
+ flags |= LIBSSH2_FXF_READ;
+ break;
+ case 1: // WRITE_CREATE_TRUNCATE(1),
+ flags |= LIBSSH2_FXF_WRITE | LIBSSH2_FXF_CREAT | LIBSSH2_FXF_TRUNC;
+ break;
+ default:
+ return nullptr;
+ }
+ auto* handle = libssh2_sftp_open_ex(sftp_->get(), path.data(), path.size(),
+ flags,
+ 0700,
+ LIBSSH2_SFTP_OPENFILE);
+ if (!handle) return nullptr;
+ return std::make_unique<File>(sftp_, handle);
+ }
+
+ bool Unlink(const std::string &path) {
+ return libssh2_sftp_unlink_ex(sftp_->get(), path.data(), path.size()) == 0;
+ }
+
+ bool Symlink(const std::string &target, const std::string &path) {
+ // symlink fails is path already exists, so remove any existing entry first.
+ libssh2_sftp_unlink_ex(sftp_->get(), path.data(), path.size());
+ // The argument order does not seem to match the documentation, so this
+ // might change?
+ return libssh2_sftp_symlink_ex(sftp_->get(), target.data(), target.size(),
+ const_cast<char*>(path.data()), path.size(),
+ LIBSSH2_SFTP_SYMLINK) == 0;
+ }
+
+ std::optional<std::string> Readlink(const std::string &path) {
+ unsigned int bufsize = 8192;
+ std::vector<char> buf;
+ while (true) {
+ buf.resize(bufsize);
+ auto ret = libssh2_sftp_symlink_ex(sftp_->get(), path.data(), path.size(),
+ buf.data(), buf.size(), LIBSSH2_SFTP_READLINK);
+ if (ret != LIBSSH2_ERROR_BUFFER_TOO_SMALL) {
+ if (ret >= 0) return std::string(buf.data(), ret);
+ return std::nullopt;
+ }
+ const auto previous = bufsize;
+ bufsize *= 2;
+ // Check for bufsize overflow.
+ if (bufsize <= previous)
+ return std::nullopt;
+ }
+ }
+
+ jni::LocalRef<jobject> Stat(JNIEnv* env, const std::string &path, bool follow_link) {
+ LIBSSH2_SFTP_ATTRIBUTES attrs;
+ auto ret = libssh2_sftp_stat_ex(sftp_->get(), path.data(), path.size(),
+ follow_link ? LIBSSH2_SFTP_STAT : LIBSSH2_SFTP_LSTAT,
+ &attrs);
+ if (ret) return nullptr;
+ return CreateDirEntry(env, path.c_str(), attrs);
+ }
+
+ private:
+ std::shared_ptr<SftpOwner> sftp_;
+};
+
+class unique_fd {
+ public:
+ constexpr unique_fd()
+ : fd_(-1) {}
+ explicit unique_fd(int fd)
+ : fd_(fd) {}
+
+ ~unique_fd() {
+ if (fd_ != -1) close(fd_);
+ }
+
+ unique_fd(const unique_fd&) = delete;
+ unique_fd& operator=(const unique_fd&) = delete;
+ unique_fd(unique_fd&& other) noexcept
+ : fd_(other.release()) {}
+ unique_fd& operator=(unique_fd&& other) noexcept {
+ reset(other.release());
+ return *this;
+ }
+
+ [[nodiscard]] int get() const { return fd_; }
+
+ explicit operator bool() const { return fd_ != -1; }
+
+ int release() {
+ int fd = fd_;
+ fd_ = -1;
+ return fd;
+ }
+
+ void reset() {
+ reset(-1);
+ }
+
+ void reset(int fd) {
+ if (fd_ != -1) close(fd_);
+ fd_ = fd;
+ }
+
+ private:
+ int fd_;
+};
+
+class SshSession {
+ public:
+ explicit SshSession(LIBSSH2_SESSION* session) : session_(session, SessionDeleter{}) {
+ libssh2_session_set_blocking(session_.get(), 1);
+ }
+ ~SshSession() = default;
+
+ SshSession(const SshSession&) = delete;
+ SshSession& operator=(const SshSession&) = delete;
+
+ std::string GetLastError() {
+ char* ptr = nullptr;
+ int len = 0;
+ libssh2_session_last_error(session_.get(), &ptr, &len, /* want_buf */ 0);
+ return {ptr, static_cast<size_t>(len)};
+ }
+
+ bool Connect(const std::string& host, int32_t port) {
+ struct addrinfo hints{};
+ hints.ai_family = AF_UNSPEC;
+ hints.ai_socktype = SOCK_STREAM;
+ hints.ai_protocol = 0;
+ hints.ai_flags = AI_ADDRCONFIG;
+ struct addrinfo* ptr = nullptr;
+ char tmp[10];
+ snprintf(tmp, sizeof(tmp), "%d", static_cast<int>(port));
+ int ret = getaddrinfo(host.c_str(), tmp, &hints, &ptr);
+ if (ret) {
+ libssh2_session_set_last_error(session_.get(), LIBSSH2_ERROR_SOCKET_NONE, gai_strerror(ret));
+ return false;
+ }
+ auto* i = ptr;
+ for (; i; i = i->ai_next) {
+ unique_fd fd(socket(i->ai_family, i->ai_socktype, i->ai_protocol));
+ if (fd) {
+ if (connect(fd.get(), i->ai_addr, i->ai_addrlen) == 0) {
+ fd_ = std::move(fd);
+ freeaddrinfo(ptr);
+ return true;
+ }
+ }
+ libssh2_session_set_last_error(session_.get(), LIBSSH2_ERROR_SOCKET_NONE, strerror(errno));
+ }
+ freeaddrinfo(ptr);
+ return false;
+ }
+
+ std::optional<std::vector<uint8_t>> Handshake() {
+ auto ret = libssh2_session_handshake(session_.get(), fd_.get());
+ if (ret)
+ return std::nullopt;
+ auto* fingerprint = libssh2_hostkey_hash(session_.get(), LIBSSH2_HOSTKEY_HASH_SHA256);
+ if (!fingerprint)
+ return std::nullopt;
+ return std::vector<uint8_t>(fingerprint, fingerprint + 32);
+ }
+
+ bool Authenticate(const std::string& username, const std::string& password) {
+ return libssh2_userauth_password_ex(session_.get(),
+ username.data(), username.size(),
+ password.data(), password.size(),
+ nullptr) == 0;
+ }
+
+ bool Authenticate(const std::string& username, const std::vector<uint8_t>& public_key,
+ const std::vector<uint8_t>& private_key, const std::string& passphrase) {
+ return libssh2_userauth_publickey_frommemory(
+ session_.get(), username.data(), username.size(),
+ reinterpret_cast<const char*>(public_key.data()), public_key.size(),
+ reinterpret_cast<const char*>(private_key.data()), private_key.size(),
+ passphrase.c_str()) == 0;
+ }
+
+ std::unique_ptr<SftpSession> NewSftpSession() {
+ auto* sftp_session = libssh2_sftp_init(session_.get());
+ if (!sftp_session) return nullptr;
+ return std::make_unique<SftpSession>(std::make_shared<SftpOwner>(session_, sftp_session));
+ }
+
+ private:
+ struct SessionDeleter {
+ void operator()(LIBSSH2_SESSION* session) {
+ if (session) {
+ libssh2_session_disconnect(session, "Normal Shutdown");
+ libssh2_session_free(session);
+ }
+ }
+ };
+
+ std::shared_ptr<LIBSSH2_SESSION> session_;
+ unique_fd fd_;
+};
+
+#pragma clang diagnostic push
+#pragma ide diagnostic ignored "MemoryLeak"
+jlong nativeSshSessionNew(JNIEnv*, jclass) {
+ auto* session = libssh2_session_init();
+ if (!session) return 0;
+ return reinterpret_cast<jlong>(new SshSession(session));
+}
+#pragma clang diagnostic pop
+
+void nativeSshSessionDestroy(JNIEnv*, jclass, jlong ptr) {
+ delete reinterpret_cast<SshSession*>(ptr);
+}
+
+jstring nativeSshSessionGetLastError(JNIEnv* env, jclass, jlong ptr) {
+ return jni::UTF8ToString(env, reinterpret_cast<SshSession*>(ptr)->GetLastError()).release();
+}
+
+jboolean nativeSshSessionConnect(JNIEnv* env, jclass, jlong ptr, jstring host, jint port) {
+ return reinterpret_cast<SshSession*>(ptr)->Connect(
+ jni::StringToUTF8(env, jni::ParamRef<jstring>(env, host)),
+ static_cast<int32_t>(port)) ? JNI_TRUE : JNI_FALSE;
+}
+
+jbyteArray nativeSshSessionHandshake(JNIEnv* env, jclass, jlong ptr) {
+ auto fingerprint = reinterpret_cast<SshSession*>(ptr)->Handshake();
+ if (fingerprint.has_value()) {
+ return jni::VectorToByteArray(env, fingerprint.value()).release();
+ }
+ return nullptr;
+}
+
+jboolean nativeSshSessionAuthenticate(JNIEnv* env, jclass, jlong ptr, jstring j_username,
+ jstring password, jbyteArray public_key,
+ jbyteArray private_key) {
+ auto username = jni::StringToUTF8(env, jni::ParamRef<jstring>(env, j_username));
+ if (public_key != nullptr && private_key != nullptr) {
+ return reinterpret_cast<SshSession*>(ptr)->Authenticate(
+ username,
+ jni::ByteArrayToVector(env, jni::ParamRef<jbyteArray>(env, public_key)),
+ jni::ByteArrayToVector(env, jni::ParamRef<jbyteArray>(env, private_key)),
+ password != nullptr ? jni::StringToUTF8(env, jni::ParamRef<jstring>(env, password)) : "")
+ ? JNI_TRUE : JNI_FALSE;
+ }
+ if (password != nullptr) {
+ return reinterpret_cast<SshSession *>(ptr)->Authenticate(
+ username,
+ jni::StringToUTF8(env, jni::ParamRef<jstring>(env, password)))
+ ? JNI_TRUE : JNI_FALSE;
+ }
+ return JNI_FALSE;
+}
+
+jlong nativeSshSessionNewSftpSession(JNIEnv*, jclass, jlong ptr) {
+ return reinterpret_cast<jlong>(reinterpret_cast<SshSession*>(ptr)->NewSftpSession().release());
+}
+
+void nativeSftpSessionDestroy(JNIEnv*, jclass, jlong ptr) {
+ delete reinterpret_cast<SftpSession*>(ptr);
+}
+
+jstring nativeSftpSessionGetLastError(JNIEnv* env, jclass, jlong ptr) {
+ return jni::UTF8ToString(env, reinterpret_cast<SftpSession*>(ptr)->GetLastError()).release();
+}
+
+jlong nativeSftpSessionOpenDir(JNIEnv* env, jclass, jlong ptr, jstring path) {
+ auto dir = reinterpret_cast<SftpSession*>(ptr)->OpenDir(
+ jni::StringToUTF8(env, jni::ParamRef<jstring>(env, path)));
+ if (!dir) return 0L;
+ return reinterpret_cast<jlong>(dir.release());
+}
+
+jboolean nativeSftpSessionMakeDir(JNIEnv* env, jclass, jlong ptr, jstring path, jint mode) {
+ return reinterpret_cast<SftpSession*>(ptr)->MakeDir(
+ jni::StringToUTF8(env, jni::ParamRef<jstring>(env, path)), mode)
+ ? JNI_TRUE : JNI_FALSE;
+}
+
+jboolean nativeSftpSessionRemoveDir(JNIEnv* env, jclass, jlong ptr, jstring path) {
+ return reinterpret_cast<SftpSession*>(ptr)->RemoveDir(
+ jni::StringToUTF8(env, jni::ParamRef<jstring>(env, path)))
+ ? JNI_TRUE : JNI_FALSE;
+}
+
+jlong nativeSftpSessionOpenFile(JNIEnv* env, jclass, jlong ptr, jstring path, jint mode) {
+ auto file = reinterpret_cast<SftpSession*>(ptr)->OpenFile(
+ jni::StringToUTF8(env, jni::ParamRef<jstring>(env, path)), mode);
+ if (!file) return 0L;
+ return reinterpret_cast<jlong>(file.release());
+}
+
+jboolean nativeSftpSessionUnlink(JNIEnv* env, jclass, jlong ptr, jstring path) {
+ return reinterpret_cast<SftpSession*>(ptr)->Unlink(
+ jni::StringToUTF8(env, jni::ParamRef<jstring>(env, path)))
+ ? JNI_TRUE : JNI_FALSE;
+}
+
+jboolean nativeSftpSessionSymlink(JNIEnv* env, jclass, jlong ptr, jstring target, jstring path) {
+ return reinterpret_cast<SftpSession*>(ptr)->Symlink(
+ jni::StringToUTF8(env, jni::ParamRef<jstring>(env, target)),
+ jni::StringToUTF8(env, jni::ParamRef<jstring>(env, path)))
+ ? JNI_TRUE : JNI_FALSE;
+}
+
+jstring nativeSftpSessionReadlink(JNIEnv* env, jclass, jlong ptr, jstring path) {
+ auto target = reinterpret_cast<SftpSession*>(ptr)->Readlink(
+ jni::StringToUTF8(env, jni::ParamRef<jstring>(env, path)));
+ if (target.has_value()) return jni::UTF8ToString(env, target.value()).release();
+ return nullptr;
+}
+
+jobject nativeSftpSessionStat(JNIEnv* env, jclass, jlong ptr, jstring path, jboolean follow_link) {
+ return reinterpret_cast<SftpSession*>(ptr)->Stat(
+ env, jni::StringToUTF8(env, jni::ParamRef<jstring>(env, path)), follow_link).release();
+}
+
+void nativeDirDestroy(JNIEnv*, jclass, jlong ptr) {
+ delete reinterpret_cast<Dir*>(ptr);
+}
+
+jni::GlobalRef<jclass> g_DirEntryClass(nullptr, nullptr);
+
+jobjectArray nativeDirList(JNIEnv* env, jclass, jlong ptr) {
+ return reinterpret_cast<Dir*>(ptr)->List(env, g_DirEntryClass).release();
+}
+
+void nativeFileDestroy(JNIEnv*, jclass, jlong ptr) {
+ delete reinterpret_cast<File*>(ptr);
+}
+
+jint nativeFileRead(JNIEnv* env, jclass, jlong ptr, jbyteArray array, jint offset, jint length) {
+ jboolean is_copy = JNI_FALSE;
+ bool critical = true;
+ auto* data = reinterpret_cast<jbyte*>(env->GetPrimitiveArrayCritical(array, &is_copy));
+ if (!data) {
+ critical = false;
+ data = env->GetByteArrayElements(array, &is_copy);
+ if (!data) return -1;
+ }
+ auto ret = reinterpret_cast<File *>(ptr)->Read(reinterpret_cast<uint8_t *>(data + offset), length);
+ if (critical) {
+ env->ReleasePrimitiveArrayCritical(array, data, JNI_COMMIT);
+ } else {
+ env->ReleaseByteArrayElements(array, data, JNI_COMMIT);
+ }
+ return ret;
+}
+
+void nativeFileSeek(JNIEnv*, jclass, jlong ptr, jlong offset) {
+ reinterpret_cast<File*>(ptr)->Seek(offset);
+}
+
+jint nativeFileWrite(JNIEnv* env, jclass, jlong ptr, jbyteArray array, jint offset, jint length) {
+ jboolean is_copy = JNI_FALSE;
+ bool critical = true;
+ auto* data = reinterpret_cast<jbyte*>(env->GetPrimitiveArrayCritical(array, &is_copy));
+ if (!data) {
+ critical = false;
+ data = env->GetByteArrayElements(array, &is_copy);
+ if (!data) return -1;
+ }
+ auto ret = reinterpret_cast<File *>(ptr)->Write(reinterpret_cast<uint8_t *>(data + offset), length);
+ if (critical) {
+ env->ReleasePrimitiveArrayCritical(array, data, JNI_ABORT);
+ } else {
+ env->ReleaseByteArrayElements(array, data, JNI_ABORT);
+ }
+ return ret;
+}
+
+jni::GlobalRef<jclass> g_NativeSftpClass(nullptr, nullptr);
+jmethodID g_CreateDirEntry;
+
+void RegisterSftp(JNIEnv* env) {
+ auto clazz = jni::FindClass(env, "org/the_jk/cleversync/io/sftp/NativeSftp");
+ ABORT_IF_NULL(env, clazz);
+ auto dir_entry_clazz = jni::FindClass(env, "org/the_jk/cleversync/io/sftp/NativeSftp$DirEntry");
+ ABORT_IF_NULL(env, dir_entry_clazz);
+ static const JNINativeMethod methods[] = {
+ { "nativeSshSessionNew", "()J", reinterpret_cast<void*>(&nativeSshSessionNew) },
+ { "nativeSshSessionDestroy", "(J)V", reinterpret_cast<void*>(&nativeSshSessionDestroy) },
+ { "nativeSshSessionGetLastError", "(J)Ljava/lang/String;", reinterpret_cast<void*>(&nativeSshSessionGetLastError) },
+ { "nativeSshSessionConnect", "(JLjava/lang/String;I)Z", reinterpret_cast<void*>(&nativeSshSessionConnect) },
+ { "nativeSshSessionHandshake", "(J)[B", reinterpret_cast<void*>(&nativeSshSessionHandshake) },
+ { "nativeSshSessionAuthenticate", "(JLjava/lang/String;Ljava/lang/String;[B[B)Z", reinterpret_cast<void*>(&nativeSshSessionAuthenticate) },
+ { "nativeSshSessionNewSftpSession", "(J)J", reinterpret_cast<void*>(&nativeSshSessionNewSftpSession) },
+
+ { "nativeSftpSessionDestroy", "(J)V", reinterpret_cast<void*>(&nativeSftpSessionDestroy) },
+ { "nativeSftpSessionGetLastError", "(J)Ljava/lang/String;", reinterpret_cast<void*>(&nativeSftpSessionGetLastError) },
+ { "nativeSftpSessionOpenDir", "(JLjava/lang/String;)J", reinterpret_cast<void*>(&nativeSftpSessionOpenDir) },
+ { "nativeSftpSessionMakeDir", "(JLjava/lang/String;I)Z", reinterpret_cast<void*>(&nativeSftpSessionMakeDir) },
+ { "nativeSftpSessionRemoveDir", "(JLjava/lang/String;)Z", reinterpret_cast<void*>(&nativeSftpSessionRemoveDir) },
+ { "nativeSftpSessionOpenFile", "(JLjava/lang/String;I)J", reinterpret_cast<void*>(&nativeSftpSessionOpenFile) },
+ { "nativeSftpSessionUnlink", "(JLjava/lang/String;)Z", reinterpret_cast<void*>(&nativeSftpSessionUnlink) },
+ { "nativeSftpSessionSymlink", "(JLjava/lang/String;Ljava/lang/String;)Z", reinterpret_cast<void*>(&nativeSftpSessionSymlink) },
+ { "nativeSftpSessionReadlink", "(JLjava/lang/String;)Ljava/lang/String;", reinterpret_cast<void*>(&nativeSftpSessionReadlink) },
+ { "nativeSftpSessionStat", "(JLjava/lang/String;Z)Lorg/the_jk/cleversync/io/sftp/NativeSftp$DirEntry;", reinterpret_cast<void*>(&nativeSftpSessionStat) },
+
+ { "nativeDirDestroy", "(J)V", reinterpret_cast<void*>(&nativeDirDestroy) },
+ { "nativeDirList", "(J)[Lorg/the_jk/cleversync/io/sftp/NativeSftp$DirEntry;", reinterpret_cast<void*>(&nativeDirList) },
+
+ { "nativeFileDestroy", "(J)V", reinterpret_cast<void*>(&nativeFileDestroy) },
+ { "nativeFileRead", "(J[BII)I", reinterpret_cast<void*>(&nativeFileRead) },
+ { "nativeFileSeek", "(JJ)V", reinterpret_cast<void*>(&nativeFileSeek) },
+ { "nativeFileWrite", "(J[BII)I", reinterpret_cast<void*>(&nativeFileWrite) },
+
+ };
+ auto ret = env->RegisterNatives(clazz.get(), methods, sizeof(methods) / sizeof(methods[0]));
+ ABORT_IF_NOT_OK(ret);
+
+ g_CreateDirEntry = env->GetStaticMethodID(
+ clazz.get(), "createDirEntry",
+ "(Ljava/lang/String;IJJ)Lorg/the_jk/cleversync/io/sftp/NativeSftp$DirEntry;");
+ ABORT_IF_NULL(env, g_CreateDirEntry);
+ g_NativeSftpClass = clazz;
+ g_DirEntryClass = dir_entry_clazz;
+}
+
+void UnregisterSftp() {
+ g_CreateDirEntry = nullptr;
+ g_NativeSftpClass.reset();
+ g_DirEntryClass.reset();
+}
+
+jni::LocalRef<jobject> CreateDirEntry(JNIEnv* env, const char* name,
+ const LIBSSH2_SFTP_ATTRIBUTES& attrs) {
+ auto j_name = jni::UTF8ToString(env, name);
+ jlong size;
+ jlong last_modified;
+ jint type;
+
+ if (attrs.flags & LIBSSH2_SFTP_ATTR_SIZE) {
+ // Kotlin size casts Long to ULong
+ size = static_cast<jlong>(attrs.filesize);
+ } else {
+ size = 0L;
+ }
+
+ if (attrs.flags & LIBSSH2_SFTP_ATTR_ACMODTIME) {
+ // Kotlin size casts Long to ULong
+ last_modified = static_cast<jlong>(attrs.mtime);
+ } else {
+ last_modified = 0L;
+ }
+
+ if (attrs.flags & LIBSSH2_SFTP_ATTR_PERMISSIONS) {
+ switch (attrs.permissions & LIBSSH2_SFTP_S_IFMT) {
+ case LIBSSH2_SFTP_S_IFREG:
+ type = 1;
+ break;
+ case LIBSSH2_SFTP_S_IFDIR:
+ type = 0;
+ break;
+ case LIBSSH2_SFTP_S_IFLNK:
+ type = 2;
+ break;
+ case LIBSSH2_SFTP_S_IFIFO:
+ case LIBSSH2_SFTP_S_IFCHR:
+ case LIBSSH2_SFTP_S_IFBLK:
+ case LIBSSH2_SFTP_S_IFSOCK:
+ default:
+ // Skip unknown or "weird" types
+ return nullptr;
+ }
+ } else {
+ // Skip unknown entries
+ return nullptr;
+ }
+
+ return jni::CallStaticObjectMethod<jobject>(env, g_NativeSftpClass, g_CreateDirEntry,
+ j_name.get(), type, size, last_modified);
+}
+
+} // namespace
+
+jint JNI_OnLoad(JavaVM *vm, void *) {
+ auto* env = jni::OnLoad(vm);
+
+ // TODO: Check return
+ libssh2_init(0);
+
+ RegisterSftp(env);
+
+ return jni::JNI_VERSION;
+}
+
+void JNI_OnUnload(JavaVM *, void *) {
+ // Not called on Android (or in general), but if it where, this would be the place to unregister.
+ UnregisterSftp();
+
+ libssh2_exit();
+}
diff --git a/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/NativeSftp.kt b/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/NativeSftp.kt
new file mode 100644
index 0000000..52d7a0a
--- /dev/null
+++ b/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/NativeSftp.kt
@@ -0,0 +1,289 @@
+// Using RegisterNatives
+@file:Suppress("KotlinJniMissingFunction")
+
+package org.the_jk.cleversync.io.sftp
+
+import androidx.annotation.Keep
+import java.time.Instant
+
+@Keep
+internal object NativeSftp {
+ fun newSshSession(): SshSession = NativeSshSession(nativeSshSessionNew())
+
+ interface Object {
+ fun destroy()
+ }
+
+ interface SshSession : Object {
+ fun lastError(): String
+ fun connect(host: String, port: Int = 22): Boolean
+ fun handshake(): Fingerprint?
+ fun authenticate(username: String, password: String?, keyPair: KeyPair?): Boolean
+
+ fun newSftpSession(): SftpSession?
+ }
+
+ interface SftpSession : Object {
+ fun lastError(): String
+ fun openDir(path: String): Dir?
+ fun makeDir(path: String, mode: Int): Boolean
+ fun removeDir(path: String): Boolean
+ fun openFile(path: String, mode: OpenMode): File?
+ fun unlink(path: String): Boolean
+ fun symlink(target: String, path: String): Boolean
+ fun readlink(path: String): String?
+ fun stat(path: String, followLink: Boolean): DirEntry?
+ }
+
+ enum class DirEntryType {
+ DIR,
+ FILE,
+ LINK,
+ }
+
+ @Keep
+ data class DirEntry(
+ val name: String,
+ val type: DirEntryType,
+ val size: ULong,
+ val lastModified: Instant,
+ )
+
+ interface Dir : Object {
+ val path: String
+
+ fun list(): Array<DirEntry>
+ }
+
+ enum class OpenMode(val value: Int) {
+ READ(0),
+ WRITE_CREATE_TRUNCATE(1),
+ }
+
+ interface File : Object {
+ val path: String
+
+ fun read(bytes: ByteArray, offset: Int, length: Int): Int
+ // Only call before read or write.
+ fun seek(offset: Long)
+
+ fun write(bytes: ByteArray, offset: Int, length: Int): Int
+ }
+
+ data class Fingerprint(
+ val data: ByteArray,
+ ) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Fingerprint
+
+ return data.contentEquals(other.data)
+ }
+
+ override fun hashCode(): Int {
+ return data.contentHashCode()
+ }
+ }
+
+ data class KeyPair(
+ val public: ByteArray,
+ val private: ByteArray,
+ ) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as KeyPair
+
+ if (!public.contentEquals(other.public)) return false
+ if (!private.contentEquals(other.private)) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = public.contentHashCode()
+ result = 31 * result + private.contentHashCode()
+ return result
+ }
+ }
+
+ private class NativeSshSession(private var ptr: Long): SshSession {
+ override fun destroy() {
+ if (ptr == 0L) return
+ nativeSshSessionDestroy(ptr)
+ ptr = 0L
+ }
+
+ override fun lastError(): String {
+ return nativeSshSessionGetLastError(ptr)
+ }
+
+ override fun connect(host: String, port: Int): Boolean {
+ return nativeSshSessionConnect(ptr, host, port)
+ }
+
+ override fun handshake(): Fingerprint? {
+ val data = nativeSshSessionHandshake(ptr) ?: return null
+ return Fingerprint(data)
+ }
+
+ override fun authenticate(
+ username: String,
+ password: String?,
+ keyPair: KeyPair?
+ ): Boolean {
+ return nativeSshSessionAuthenticate(ptr, username, password, keyPair?.public, keyPair?.private)
+ }
+
+ override fun newSftpSession(): SftpSession? {
+ val session = nativeSshSessionNewSftpSession(ptr)
+ if (session == 0L) return null
+ return NativeSftpSession(session)
+ }
+ }
+
+ private class NativeSftpSession(private var ptr: Long): SftpSession {
+ override fun destroy() {
+ if (ptr == 0L) return
+ nativeSftpSessionDestroy(ptr)
+ ptr = 0L
+ }
+
+ override fun lastError(): String {
+ return nativeSftpSessionGetLastError(ptr)
+ }
+
+ override fun openDir(path: String): Dir? {
+ val dir = nativeSftpSessionOpenDir(ptr, path)
+ if (dir == 0L) return null
+ return NativeDir(path, dir)
+ }
+
+ override fun makeDir(path: String, mode: Int): Boolean {
+ return nativeSftpSessionMakeDir(ptr, path, mode)
+ }
+
+ override fun removeDir(path: String): Boolean {
+ return nativeSftpSessionRemoveDir(ptr, path)
+ }
+
+ override fun openFile(path: String, mode: OpenMode): File? {
+ val file = nativeSftpSessionOpenFile(ptr, path, mode.value)
+ if (file == 0L) return null
+ return NativeFile(path, file)
+ }
+
+ override fun unlink(path: String): Boolean {
+ return nativeSftpSessionUnlink(ptr, path)
+ }
+
+ override fun symlink(target: String, path: String): Boolean {
+ return nativeSftpSessionSymlink(ptr, target, path)
+ }
+
+ override fun readlink(path: String): String? {
+ return nativeSftpSessionReadlink(ptr, path)
+ }
+
+ override fun stat(path: String, followLink: Boolean): DirEntry? {
+ return nativeSftpSessionStat(ptr, path, followLink)
+ }
+ }
+
+ private class NativeDir(override val path: String, private var ptr: Long): Dir {
+ private var valid = true
+
+ override fun destroy() {
+ if (ptr == 0L) return
+ nativeDirDestroy(ptr)
+ ptr = 0L
+ }
+
+ override fun list(): Array<DirEntry> {
+ if (valid) {
+ valid = false
+ } else {
+ // Can only list() once, there is no rewind
+ assert(false)
+ return emptyArray()
+ }
+ return nativeDirList(ptr)
+ }
+ }
+
+ private class NativeFile(override val path: String, private var ptr: Long): File {
+ override fun destroy() {
+ if (ptr == 0L) return
+ nativeFileDestroy(ptr)
+ ptr = 0L
+ }
+
+ override fun read(bytes: ByteArray, offset: Int, length: Int): Int {
+ return nativeFileRead(ptr, bytes, offset, length)
+ }
+
+ override fun seek(offset: Long) {
+ nativeFileSeek(ptr, offset)
+ }
+
+ override fun write(bytes: ByteArray, offset: Int, length: Int): Int {
+ return nativeFileWrite(ptr, bytes, offset, length)
+ }
+ }
+
+ init {
+ System.loadLibrary("sftpjni")
+ }
+
+ @JvmStatic
+ @Keep
+ @Suppress("UnusedPrivateMember")
+ private fun createDirEntry(name: String, type: Int, size: Long, lastModified: Long) =
+ DirEntry(
+ name = name,
+ when (type) {
+ 0 -> DirEntryType.DIR
+ 1 -> DirEntryType.FILE
+ 2 -> DirEntryType.LINK
+ else -> throw IllegalArgumentException("Unknown type: $type")
+ },
+ size = size.toULong(),
+ lastModified = Instant.ofEpochMilli(lastModified * 1000),
+ )
+
+ private external fun nativeSshSessionNew(): Long
+ private external fun nativeSshSessionDestroy(ptr: Long)
+ private external fun nativeSshSessionGetLastError(ptr: Long): String
+ private external fun nativeSshSessionConnect(ptr: Long, host: String, port: Int): Boolean
+ private external fun nativeSshSessionHandshake(ptr: Long): ByteArray?
+ private external fun nativeSshSessionAuthenticate(
+ ptr: Long,
+ username: String,
+ password: String?,
+ publicKey: ByteArray?,
+ privateKey: ByteArray?,
+ ): Boolean
+ private external fun nativeSshSessionNewSftpSession(ptr: Long): Long
+
+ private external fun nativeSftpSessionDestroy(ptr: Long)
+ private external fun nativeSftpSessionGetLastError(ptr: Long): String
+ private external fun nativeSftpSessionOpenDir(ptr: Long, path: String): Long
+ private external fun nativeSftpSessionMakeDir(ptr: Long, path: String, mode: Int): Boolean
+ private external fun nativeSftpSessionRemoveDir(ptr: Long, path: String): Boolean
+ private external fun nativeSftpSessionOpenFile(ptr: Long, path: String, mode: Int): Long
+ private external fun nativeSftpSessionUnlink(ptr: Long, path: String): Boolean
+ private external fun nativeSftpSessionSymlink(ptr: Long, target: String, path: String): Boolean
+ private external fun nativeSftpSessionReadlink(ptr: Long, path: String): String?
+ private external fun nativeSftpSessionStat(ptr: Long, path: String, followLink: Boolean): DirEntry?
+
+ private external fun nativeDirDestroy(ptr: Long)
+ private external fun nativeDirList(ptr: Long): Array<DirEntry>
+
+ private external fun nativeFileDestroy(ptr: Long)
+ private external fun nativeFileRead(ptr: Long, bytes: ByteArray, offset: Int, length: Int): Int
+ private external fun nativeFileSeek(ptr: Long, offset: Long)
+ private external fun nativeFileWrite(ptr: Long, bytes: ByteArray, offset: Int, length: Int): Int
+}
diff --git a/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpConnection.kt b/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpConnection.kt
new file mode 100644
index 0000000..706116b
--- /dev/null
+++ b/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpConnection.kt
@@ -0,0 +1,132 @@
+package org.the_jk.cleversync.io.sftp
+
+import android.net.Uri
+
+internal class SftpConnection(uri: Uri, credentials: SftpCredentials) {
+ val description = uri.toString()
+ private val baseDir = uri.path ?: ""
+ private val sshSession = NativeSftp.newSshSession()
+ private var sftpSession: NativeSftp.SftpSession? = null
+ private var destroyed = false
+
+ val connected = if (!destroyed) { login(uri, credentials) } else false
+
+ val error: String
+ get() = if (destroyed) "[destroyed]" else {
+ var err = sftpSession?.lastError()
+ if (err.isNullOrEmpty()) sshSession.lastError() else err
+ }
+
+ protected fun finalize() {
+ destroy()
+ }
+
+ fun destroy() {
+ if (destroyed) return
+ sftpSession?.destroy()
+ sshSession.destroy()
+ destroyed = true
+ }
+
+ fun openDir(path: String): NativeSftp.Dir? =
+ sftpSession?.openDir(join(baseDir, path))
+
+ fun entry(path: String, followLink: Boolean = true): NativeSftp.DirEntry? =
+ sftpSession?.stat(join(baseDir, path), followLink)
+
+ fun makeDir(path: String): Boolean =
+ sftpSession?.makeDir(join(baseDir, path), 511 /* 0777 */) ?: false
+
+ fun removeDir(path: String): Boolean =
+ sftpSession?.removeDir(join(baseDir, path)) ?: false
+
+ fun unlink(path: String): Boolean =
+ sftpSession?.unlink(join(baseDir, path)) ?: false
+
+ fun readLink(path: String): String? {
+ val target = sftpSession?.readlink(join(baseDir, path))
+ if (target?.startsWith(baseDir) == true) {
+ return target.substring(baseDir.length + 1)
+ }
+ return target
+ }
+
+ fun symlink(target: String, rawTarget: Boolean, path: String): Boolean {
+ val relativeTarget = if (rawTarget) {
+ target
+ } else {
+ join(baseDir, target)
+ }
+ return sftpSession?.symlink(relativeTarget, join(baseDir, path)) ?: false
+ }
+
+ fun openFile(path: String, mode: NativeSftp.OpenMode): NativeSftp.File? =
+ sftpSession?.openFile(join(baseDir, path), mode)
+
+ override fun toString() = description
+
+ private fun login(uri: Uri, credentials: SftpCredentials): Boolean {
+ if (!sshSession.connect(uri.host ?: "", if (uri.port == -1) DEFAULT_PORT else uri.port)) {
+ return false
+ }
+ // TODO: Check fingerprint against last one
+ if (sshSession.handshake() == null) return false
+ when (credentials) {
+ is SftpCredentials.SftpPasswordCredentials ->
+ if (!sshSession.authenticate(
+ credentials.username,
+ credentials.password,
+ null,
+ )
+ ) return false
+
+ is SftpCredentials.SftpKeyCredentials ->
+ if (!sshSession.authenticate(
+ credentials.username,
+ credentials.passphrase ?: "",
+ NativeSftp.KeyPair(credentials.publicKey, credentials.privateKey),
+ )
+ ) return false
+ }
+ sftpSession = sshSession.newSftpSession()
+ return sftpSession != null
+ }
+
+ companion object {
+ private const val DEFAULT_PORT = 22
+
+ fun join(a: String, b: String): String {
+ if (a.isEmpty() || b.startsWith("/")) return b
+ if (b.isEmpty()) return a
+ return if (a.endsWith("/")) a + b else "${a}/${b}"
+ }
+
+ fun dirname(path: String): String {
+ var start = path.lastIndex
+ while (start > -1 && path[start] == '/') start--;
+ if (start > -1) {
+ val index = path.lastIndexOf('/', startIndex = start)
+ if (index > -1) return path.substring(0, index)
+ }
+ return ""
+ }
+
+ fun resolve(path: String): String {
+ val parts = path.split('/').filterIndexed { index, part ->
+ index == 0 || (part.isNotEmpty() && part != ".")
+ }.toMutableList()
+ var i = 1
+ while (i < parts.size) {
+ if (parts[i] == "..") {
+ parts.removeAt(i)
+ if (parts[i].isNotEmpty()) {
+ parts.removeAt(i - 1)
+ }
+ } else {
+ i++
+ }
+ }
+ return parts.joinToString("/")
+ }
+ }
+}
diff --git a/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpCredentials.kt b/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpCredentials.kt
new file mode 100644
index 0000000..0097000
--- /dev/null
+++ b/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpCredentials.kt
@@ -0,0 +1,13 @@
+package org.the_jk.cleversync.io.sftp
+
+sealed class SftpCredentials(
+ val username: String,
+) {
+ class SftpPasswordCredentials(username: String, val password: String): SftpCredentials(username)
+ class SftpKeyCredentials(
+ username: String,
+ val publicKey: ByteArray,
+ val privateKey: ByteArray,
+ val passphrase: String?,
+ ): SftpCredentials(username)
+}
diff --git a/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpDirectory.kt b/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpDirectory.kt
new file mode 100644
index 0000000..d547471
--- /dev/null
+++ b/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpDirectory.kt
@@ -0,0 +1,195 @@
+package org.the_jk.cleversync.io.sftp
+
+import android.os.Handler
+import android.os.Looper
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.map
+import org.the_jk.cleversync.io.Directory
+import org.the_jk.cleversync.io.File
+import org.the_jk.cleversync.io.ModifiableDirectory
+import org.the_jk.cleversync.io.ModifiableFile
+import org.the_jk.cleversync.io.ModifiableLink
+import java.io.IOException
+import java.time.Instant
+import kotlin.time.Duration.Companion.seconds
+
+internal open class SftpDirectory(
+ private val conn: SftpConnection,
+ internal val path: String,
+ override val name: String,
+) : ModifiableDirectory {
+ private val modifiableLiveContent = object : MutableLiveData<ModifiableDirectory.Content>() {
+ private val looper = Looper.myLooper()
+ private val handler = if (looper != null) Handler(looper) else null
+ private val updateCallback = Runnable { update() }
+
+ override fun onActive() {
+ super.onActive()
+
+ value = modifiableList()
+ handler?.postDelayed(updateCallback, LIVE_UPDATE_INTERVAL.inWholeMilliseconds)
+ }
+
+ override fun onInactive() {
+ super.onInactive()
+
+ handler?.removeCallbacks(updateCallback)
+ }
+
+ private fun update() {
+ val newValue = modifiableList()
+ if (value != newValue) postValue(newValue)
+ handler?.postDelayed(updateCallback, LIVE_UPDATE_INTERVAL.inWholeMilliseconds)
+ }
+ }
+
+ private val liveContent: LiveData<Directory.Content> by lazy {
+ modifiableLiveContent.map {
+ Directory.Content(
+ it.directories,
+ it.files,
+ it.links,
+ )
+ }
+ }
+
+ override fun modifiableOpenDir(name: String): ModifiableDirectory? {
+ val newPath = SftpConnection.join(path, name)
+ val entry = conn.entry(newPath) ?: return null
+ if (entry.type != NativeSftp.DirEntryType.DIR) return null
+ return SftpDirectory(conn, newPath, name)
+ }
+
+ override fun modifiableOpenFile(name: String): ModifiableFile? {
+ val newPath = SftpConnection.join(path, name)
+ val entry = conn.entry(newPath) ?: return null
+ if (entry.type != NativeSftp.DirEntryType.FILE) return null
+ return SftpFile(conn, newPath, name, entry.size, entry.lastModified)
+ }
+
+ override fun modifiableOpenLink(name: String): ModifiableLink? {
+ val newPath = SftpConnection.join(path, name)
+ val entry = conn.entry(newPath, followLink = false) ?: return null
+ if (entry.type != NativeSftp.DirEntryType.LINK) return null
+ return SftpLink(conn, newPath, name)
+ }
+
+ override fun modifiableList(): ModifiableDirectory.Content {
+ val directories = mutableListOf<ModifiableDirectory>()
+ val files = mutableListOf<ModifiableFile>()
+ val links = mutableListOf<ModifiableLink>()
+ val dir = conn.openDir(path)
+ if (dir != null) {
+ dir.list().forEach { entry ->
+ val entryPath = SftpConnection.join(path, entry.name)
+ when (entry.type) {
+ NativeSftp.DirEntryType.DIR -> {
+ directories.add(SftpDirectory(conn, entryPath, entry.name))
+ }
+ NativeSftp.DirEntryType.FILE -> {
+ files.add(SftpFile(conn, entryPath, entry.name, entry.size, entry.lastModified))
+ }
+ NativeSftp.DirEntryType.LINK -> {
+ links.add(SftpLink(conn, entryPath, entry.name))
+ }
+ }
+ }
+ dir.destroy()
+ }
+ return ModifiableDirectory.Content(directories, files, links)
+ }
+
+ override fun modifiableLiveList() = modifiableLiveContent
+
+ override fun createDirectory(name: String): ModifiableDirectory {
+ val newPath = SftpConnection.join(path, name)
+ if (!conn.makeDir(newPath)) throw IOException(conn.error)
+ return SftpDirectory(conn, newPath, name)
+ }
+
+ override fun createFile(name: String): ModifiableFile {
+ val newPath = SftpConnection.join(path, name)
+ return SftpFile(conn, newPath, name, 0UL, Instant.EPOCH, Instant.EPOCH)
+ }
+
+ override fun createLink(name: String, target: Directory): ModifiableLink {
+ return createLink(name, (target as SftpDirectory).path, rawTarget=false)
+ }
+
+ override fun createLink(name: String, target: File): ModifiableLink {
+ return createLink(name, (target as SftpFile).path, rawTarget=false)
+ }
+
+ override fun createLink(name: String, target: String): ModifiableLink {
+ return createLink(name, target, rawTarget=true)
+ }
+
+ private fun createLink(name: String, target: String, rawTarget: Boolean): ModifiableLink {
+ val newPath = SftpConnection.join(path, name)
+ if (!conn.symlink(target, rawTarget, newPath)) throw IOException(conn.error)
+ return SftpLink(conn, newPath, name)
+ }
+
+ override fun removeDirectory(name: String): Boolean {
+ val removePath = SftpConnection.join(path, name)
+ val entry = conn.entry(removePath) ?: return false
+ if (entry.type != NativeSftp.DirEntryType.DIR) return false
+ return removeRecursive(removePath)
+ }
+
+ private fun removeRecursive(removePath: String): Boolean {
+ val dir = conn.openDir(removePath) ?: return false
+ try {
+ dir.list().forEach { entry ->
+ val entryPath = SftpConnection.join(removePath, entry.name)
+ if (!when (entry.type) {
+ NativeSftp.DirEntryType.FILE,
+ NativeSftp.DirEntryType.LINK,
+ -> conn.unlink(entryPath)
+ NativeSftp.DirEntryType.DIR
+ -> removeRecursive(entryPath)
+ }) {
+ return false
+ }
+ }
+ return conn.removeDir(removePath)
+ } finally {
+ dir.destroy()
+ }
+ }
+
+ override fun removeFile(name: String): Boolean {
+ val removePath = SftpConnection.join(path, name)
+ val entry = conn.entry(removePath) ?: return false
+ if (entry.type != NativeSftp.DirEntryType.FILE) return false
+ return conn.unlink(removePath)
+ }
+
+ override fun removeLink(name: String): Boolean {
+ val removePath = SftpConnection.join(path, name)
+ val entry = conn.entry(removePath, followLink = false) ?: return false
+ if (entry.type != NativeSftp.DirEntryType.LINK) return false
+ return conn.unlink(removePath)
+ }
+
+ override fun openDir(name: String) = modifiableOpenDir(name)
+
+ override fun openFile(name: String) = modifiableOpenFile(name)
+
+ override fun openLink(name: String) = modifiableOpenLink(name)
+
+ override fun list() = with(modifiableList()) {
+ Directory.Content(directories, files, links)
+ }
+
+ override fun liveList() = liveContent
+
+ override fun equals(other: Any?) = other is SftpDirectory && other.conn == conn && other.path == path
+ override fun hashCode() = path.hashCode()
+ override fun toString() = "$conn/$path"
+
+ private companion object {
+ private val LIVE_UPDATE_INTERVAL = 10.seconds
+ }
+}
diff --git a/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpFile.kt b/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpFile.kt
new file mode 100644
index 0000000..c335648
--- /dev/null
+++ b/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpFile.kt
@@ -0,0 +1,113 @@
+package org.the_jk.cleversync.io.sftp
+
+import org.the_jk.cleversync.io.ModifiableFile
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.time.Instant
+
+internal class SftpFile(
+ private val conn: SftpConnection,
+ internal val path: String,
+ override val name: String,
+ private val cachedSize: ULong,
+ private val cachedLastModified: Instant,
+ private var cacheEndOfLife: Instant = Instant.now().plusSeconds(60),
+) : ModifiableFile {
+ override fun write(): OutputStream {
+ val file = conn.openFile(path, NativeSftp.OpenMode.WRITE_CREATE_TRUNCATE)
+ ?: throw IOException(conn.error)
+ return object : OutputStream() {
+ override fun write(b: Int) {
+ val buffer = ByteArray(1)
+ buffer[0] = b.toByte()
+ if (file.write(buffer, 0, 1) != 1) throw IOException(conn.error)
+ }
+
+ override fun write(b: ByteArray?) = write(b, 0, b?.size ?: 0)
+
+ override fun write(b: ByteArray?, off: Int, len: Int) {
+ if (b == null) throw NullPointerException("b == null")
+ if (off < 0) throw IndexOutOfBoundsException("off < 0")
+ if (len < 0) throw java.lang.IndexOutOfBoundsException("len < 0")
+ if (off + len > b.size) throw IndexOutOfBoundsException("off + len > b.size")
+ if (file.write(b, off, len) != len) throw IOException(conn.error)
+ }
+
+ override fun flush() {
+ clearCache()
+ }
+
+ override fun close() {
+ file.destroy()
+ clearCache()
+ }
+ }
+ }
+
+ override fun read(): InputStream {
+ val file = conn.openFile(path, NativeSftp.OpenMode.READ) ?: throw IOException(conn.error)
+ return object : InputStream() {
+ private var readAnything = false
+
+ override fun read(): Int {
+ val buffer = ByteArray(1)
+ val got = file.read(buffer, 0, 1)
+ if (got == 0) return -1
+ if (got < 0) throw IOException(conn.error)
+ readAnything = true
+ return buffer[0].toInt()
+ }
+
+ override fun read(b: ByteArray?) = read(b, 0, b?.size ?: 0)
+
+ override fun read(b: ByteArray?, off: Int, len: Int): Int {
+ if (b == null) throw NullPointerException("b == null")
+ if (off < 0) throw IndexOutOfBoundsException("off < 0")
+ if (len < 0) throw java.lang.IndexOutOfBoundsException("len < 0")
+ if (off + len > b.size) throw IndexOutOfBoundsException("off + len > b.size")
+ if (len == 0) return 0
+ val got = file.read(b, off, len)
+ if (got == 0) return -1
+ if (got < 0) throw IOException(conn.error)
+ readAnything = true
+ return got
+ }
+
+ override fun skip(n: Long): Long {
+ if (n <= 0) return 0
+ if (readAnything) return 0
+ file.seek(n)
+ return n
+ }
+
+ override fun close() {
+ file.destroy()
+ }
+ }
+ }
+
+ override val size: ULong get() {
+ if (useCached()) return cachedSize
+ val entry = conn.entry(path) ?: throw IOException(conn.error)
+ return entry.size
+ }
+
+ override val lastModified: Instant get() {
+ if (useCached()) return cachedLastModified
+ val entry = conn.entry(path) ?: throw IOException(conn.error)
+ return entry.lastModified
+ }
+
+ override fun equals(other: Any?) = other is SftpFile && other.conn == conn && other.path == path
+ override fun hashCode() = path.hashCode()
+ override fun toString() = "$conn/$path"
+
+ private fun useCached(): Boolean {
+ return Instant.now().isBefore(cacheEndOfLife)
+ }
+
+ private fun clearCache() {
+ cacheEndOfLife = Instant.EPOCH
+ }
+}
diff --git a/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpLink.kt b/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpLink.kt
new file mode 100644
index 0000000..c2259ac
--- /dev/null
+++ b/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpLink.kt
@@ -0,0 +1,77 @@
+package org.the_jk.cleversync.io.sftp
+
+import org.the_jk.cleversync.io.Directory
+import org.the_jk.cleversync.io.ModifiableLink
+import org.the_jk.cleversync.io.File
+import org.the_jk.cleversync.io.Link
+import java.io.IOException
+
+internal class SftpLink(
+ private val conn: SftpConnection,
+ internal val path: String,
+ override val name: String,
+) : ModifiableLink {
+ override fun modifiableResolve(): ModifiableLink.ModifiableLinkTarget {
+ val (newPath, entry) = doResolve()
+ if (entry == null) return ModifiableLink.NoTarget
+ return when (entry.type) {
+ NativeSftp.DirEntryType.DIR ->
+ ModifiableLink.ModifiableDirectoryTarget(SftpDirectory(conn, newPath, entry.name))
+ NativeSftp.DirEntryType.FILE ->
+ ModifiableLink.ModifiableFileTarget(
+ SftpFile(conn, newPath, entry.name, entry.size, entry.lastModified),
+ )
+ NativeSftp.DirEntryType.LINK ->
+ ModifiableLink.NoTarget
+ }
+ }
+
+ override fun target(directory: Directory) {
+ target((directory as SftpDirectory).path, rawTarget=false)
+ }
+
+ override fun target(file: File) {
+ target((file as SftpFile).path, rawTarget=false)
+ }
+
+ override fun target(name: String) {
+ target(name, rawTarget=true)
+ }
+
+ private fun target(name: String, rawTarget: Boolean) {
+ if (!conn.symlink(name, rawTarget, path)) throw IOException(conn.error)
+ }
+
+ override fun resolve(): Link.LinkTarget {
+ val (newPath, entry) = doResolve()
+ if (entry == null) return Link.NoTarget
+ return when (entry.type) {
+ NativeSftp.DirEntryType.DIR ->
+ Link.DirectoryTarget(SftpDirectory(conn, newPath, entry.name))
+ NativeSftp.DirEntryType.FILE ->
+ Link.FileTarget(SftpFile(conn, newPath, entry.name, entry.size, entry.lastModified))
+ NativeSftp.DirEntryType.LINK ->
+ Link.NoTarget
+ }
+ }
+
+ override fun equals(other: Any?) = other is SftpLink && other.conn == conn && other.path == path
+ override fun hashCode() = path.hashCode()
+ override fun toString() = "$conn/$path"
+
+ private fun doResolve(): Pair<String, NativeSftp.DirEntry?> {
+ var linkPath = path
+ var paths = mutableSetOf(linkPath)
+ var entry: NativeSftp.DirEntry? = null
+ while (true) {
+ val target = conn.readLink(linkPath) ?: break
+ linkPath = SftpConnection.resolve(
+ SftpConnection.join(SftpConnection.dirname(linkPath), target),
+ )
+ if (!paths.add(linkPath)) break
+ entry = conn.entry(linkPath, followLink = false) ?: break
+ if (entry.type != NativeSftp.DirEntryType.LINK) break
+ }
+ return linkPath to entry
+ }
+}
diff --git a/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpTree.kt b/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpTree.kt
new file mode 100644
index 0000000..83a183d
--- /dev/null
+++ b/libs/sftp/src/main/java/org/the_jk/cleversync/io/sftp/SftpTree.kt
@@ -0,0 +1,17 @@
+package org.the_jk.cleversync.io.sftp
+
+import android.content.res.Resources
+import org.the_jk.cleversync.io.ModifiableTree
+
+internal class SftpTree(
+ private val conn: SftpConnection,
+ root: String,
+) : SftpDirectory(conn, root, ""), ModifiableTree {
+ override fun description(resources: Resources): CharSequence {
+ return conn.description
+ }
+
+ override fun close() {
+ conn.destroy()
+ }
+}
diff --git a/libs/sftp/src/main/java/org/the_jk/cleversync/sftp/SftpTreeFactory.kt b/libs/sftp/src/main/java/org/the_jk/cleversync/sftp/SftpTreeFactory.kt
new file mode 100644
index 0000000..7a45829
--- /dev/null
+++ b/libs/sftp/src/main/java/org/the_jk/cleversync/sftp/SftpTreeFactory.kt
@@ -0,0 +1,24 @@
+package org.the_jk.cleversync.sftp
+
+import android.net.Uri
+import org.the_jk.cleversync.io.ModifiableTree
+import org.the_jk.cleversync.io.Tree
+import org.the_jk.cleversync.io.sftp.SftpConnection
+import org.the_jk.cleversync.io.sftp.SftpCredentials
+import org.the_jk.cleversync.io.sftp.SftpTree
+
+object SftpTreeFactory {
+ fun tree(uri: String, credentials: SftpCredentials): Result<Tree> = modifiableTree(uri, credentials)
+
+ fun modifiableTree(uri: String, credentials: SftpCredentials): Result<ModifiableTree> {
+ val url = Uri.parse(uri)
+ if (url.scheme != "ssh") return Result.failure(IllegalArgumentException("Invalid url: $uri"))
+ val connection = SftpConnection(url, credentials)
+ if (!connection.connected) {
+ val e = Exception(connection.error)
+ connection.destroy()
+ return Result.failure(e)
+ }
+ return Result.success(SftpTree(connection, ""))
+ }
+}
diff --git a/libs/sftp/src/test/docker/docker-compose.yml b/libs/sftp/src/test/docker/docker-compose.yml
new file mode 100644
index 0000000..973a942
--- /dev/null
+++ b/libs/sftp/src/test/docker/docker-compose.yml
@@ -0,0 +1,12 @@
+services:
+ sftp:
+ image: atmoz/sftp:debian
+ restart: always
+ command: user:notverysecret:1000
+ volumes:
+ - ./ssh_host_ed25519_key:/etc/ssh/ssh_host_ed25519_key
+ - ./ssh_host_rsa_key:/etc/ssh/ssh_host_rsa_key
+ - ./user_public.pub:/home/user/.ssh/keys/id_rsa.pub:ro
+ - ../../../build/test-share:/home/user/share
+ ports:
+ - "127.0.0.1:10022:22"
diff --git a/libs/sftp/src/test/docker/ssh_host_ed25519_key b/libs/sftp/src/test/docker/ssh_host_ed25519_key
new file mode 100644
index 0000000..8cb5247
--- /dev/null
+++ b/libs/sftp/src/test/docker/ssh_host_ed25519_key
@@ -0,0 +1,7 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACC6aVbkT4F5PcnnVmCfdhyrrfOA+H6qpfcZRJDdZLqnJQAAAJBQNbBOUDWw
+TgAAAAtzc2gtZWQyNTUxOQAAACC6aVbkT4F5PcnnVmCfdhyrrfOA+H6qpfcZRJDdZLqnJQ
+AAAEDE99B9qMrmtNYkRaAy3nvXE82OG3MqIuElVoeXfLqz2rppVuRPgXk9yedWYJ92HKut
+84D4fqql9xlEkN1kuqclAAAADXRoZV9qa0B3aWxsb3c=
+-----END OPENSSH PRIVATE KEY-----
diff --git a/libs/sftp/src/test/docker/ssh_host_ed25519_key.pub b/libs/sftp/src/test/docker/ssh_host_ed25519_key.pub
new file mode 100644
index 0000000..936d72b
--- /dev/null
+++ b/libs/sftp/src/test/docker/ssh_host_ed25519_key.pub
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILppVuRPgXk9yedWYJ92HKut84D4fqql9xlEkN1kuqcl the_jk@willow
diff --git a/libs/sftp/src/test/docker/ssh_host_rsa_key b/libs/sftp/src/test/docker/ssh_host_rsa_key
new file mode 100644
index 0000000..4055a8a
--- /dev/null
+++ b/libs/sftp/src/test/docker/ssh_host_rsa_key
@@ -0,0 +1,49 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+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
+-----END OPENSSH PRIVATE KEY-----
diff --git a/libs/sftp/src/test/docker/ssh_host_rsa_key.pub b/libs/sftp/src/test/docker/ssh_host_rsa_key.pub
new file mode 100644
index 0000000..d53338e
--- /dev/null
+++ b/libs/sftp/src/test/docker/ssh_host_rsa_key.pub
@@ -0,0 +1 @@
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDwCDcD18wsPssz1G4ANESktHhsKiwudOWZj7lA8jKNHt5FDQmmpZHVBfkoy2R3i7I8cMzuriDd89ntOTCDlD4D/Ymsl5y4tL5hv+mMQKYHbDWk5/bOEyYybjf/vpFSYm7xzw/RyFvXBIsb+7jBuxbLRfcJmZugX8oEO5y+pYz15rsQ4KHNv//vKFQ6nQi/wocPRVpGsWZ/6Jb/pGlKGxbUp4qTU2c9UvTnrD1UOgEOuLTVuaXvj3ZINlTbLNzc58N6KqfkjjIEtuFx9WJbFiwaEkHLTZvRpTtz8x9cKFQD0KDMwntZUFtleESSmjJaOUZqCZFu51s4UJXdLmLVlOQGweEcd9mPIlueTyUUVAz0aa+P+d2Kz67eVOvChjcD+d/kk//UzhOnwBjfoiAVkJpbrXtRU78iAye6kV+7WZ2jlsSm9kkrqBfi7uYj7yZt40PJ105/eOeQ5HAgYAt+yLHT2q12uEOjjS2wJ3xdlmgfP5Mh0GNM1WP4fPoj8x3zz4WAWlFP0+elSimornEZkqQK+l44Jz6SKZrx7JQ5mSORLIToYUnaQoAJBv6+2xYSkL//IIhuju68G87WdcrDcSeNN5YNcNYN+ba5AIPu/8iZJ0w0nYGx3XnFV8+i+vQux7ArW8jyOUhm46pf3yGuRSQv9WWGnDbHDz3B+2te8CrUQQ== the_jk@willow
diff --git a/libs/sftp/src/test/docker/user_private.pem b/libs/sftp/src/test/docker/user_private.pem
new file mode 100644
index 0000000..327db4b
--- /dev/null
+++ b/libs/sftp/src/test/docker/user_private.pem
@@ -0,0 +1,30 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIIFJDBWBgkqhkiG9w0BBQ0wSTAxBgkqhkiG9w0BBQwwJAQQfvtNRgdziQX01T8h
+P1oWXwICCAAwDAYIKoZIhvcNAgkFADAUBggqhkiG9w0DBwQIT9jscTxj/XUEggTI
+Ua25QSpuCoi8SpcoCSEbo8Kcru+Mk7okrR1oEoziwYGSuF9vEgOKK4t2GppWMPv2
+VIx1r3M4Tbsr7MJ1lXi6yJHcKYnUuxmRx8HsSwrWDC4IjHiECF3fzphNirZu6mgH
+yG43Hkyt5mAVvb/sIviKCbmzhQ8h0L3KDNY+EFMbLHt3a1OIl5b8z4g0X6TxJgYC
+y7F65svUhiB/l1hwQl5YHqen/VVsa8ENme95/lNXrPUmddBzRqcOeEQL1zwwZ4Ag
+bx03xrOQObG3sslhbqCvDXd0BFJo9YNpCs/GPeYvUl2KU1PQ2ErmmJCGsyIZ2wYk
+QOLp4Ie6TiX7fApe+nV+Sl6DYa+ww7PHvukC5is9hAUO5WUcsIWpY/myGiVdKuxE
+VEmd6fykXhCZfyIBJrmxn9U/aaYDLO75Xzj6ktWbOSJe/DaDtpEBwjh6t8TewsdC
+mawoa6878oM8sji4Jn9Am4O/i0XHSg7qQ5vHJnoW5WKdl9jlcaOWGrZZEugWtSFn
+2I+DdMM3SWkz20w7+6abelTA8LUY11MEnGrHHFz6HIvVxd1wuqzqRsRgI7OTg7e1
+xgW3kwEwJiKLDfcAm+mq0tvyK0lIHZ7+a8vawso4SZKA0egbtbWrqC4flE8OvCDL
+12YUIpIGEGeyE4EwM57+Ivw/DllOUSE5ZI90HrGVG0KXbPbwRC+9VJddcaF4Mxue
+5GRHbRRp5HSRqgY76Sb0HkwypuJCmk8Yi8vmBqEckCJWIACHaBnrebUjE29IacPH
+etCEeHloucCck1kG0fOcM0e2WIhgjHUqJHNb3r9poy80aMijHFv1AlDlqawo5uFh
+tH19TL1feWokqRM71h+S9Z4oZ97Q8xuuk+MlFyKV2bnZ0JRSXdVfgKdMJWMosE0b
+zDFvngb9SWsUmxx+pJpE5tY/GYSQsT0A2rNVY66IWf39XfzauVHmEypMQ20Cb3Ma
+0JzdYPfeEs+r0Bda85sGDev2WAWTgy56SvdsKDDJyHh93RZ9q7T99Kt4w8VOvWxy
+5MekUTUwRTu89HPUU9OxcTHvyweVL4Rh8Y9cx2VglrdvNPWDX9Z6ydkZoXyHfbL3
+QVwNG2LsjihOkE7Ggg5VFTR4ZDi7QT4qF9PkEXC0wFNuRjL2yLB93rHIztA/QNP6
+0dQmMCFtelsAkG3mtGUDLRqTkQZelQx932hAA72pQCRnza/djXAyz7LINNz6AfLc
+RnqBpTEj6gB1MyJQo4Y/Mq06Wbkm2Zh4qhpqTPbtdxfO6vJlgdoAXj6lvmWeKj7v
+0Wv2qE8I0Ai5o0dHrxLlu5qiBW7E4NOXZAuK4uI285kgDXXJd8qriOM25MX9bYR7
+m5LgUuWjq4IhIdFgjEgCx007ZfnnwTpzWI1bJIPmRyStqGPAimT26uZkbQnoC3Ii
++tsAx58v8m7tcDauOF1Fcl1yzYfQgV1QRPkUX2ymWklmZOAUe9Poo2+takLG/5pY
+d2xRJVJ5165zexKLbxeU5bFcHkGm2KWn0Fj4pdJCodpVSqW4pHluDftElAztrlZk
+57eYRVF/xTS+nxMi+nMluV5qzFx1MIxX2TBRCBxCHuC0IlEqs1n7hqG/NiWr3u/c
+5Z/CIiawNGziWe+pqv2VlHnL2JtP+81o
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/libs/sftp/src/test/docker/user_public.pem b/libs/sftp/src/test/docker/user_public.pem
new file mode 100644
index 0000000..f0812d9
--- /dev/null
+++ b/libs/sftp/src/test/docker/user_public.pem
@@ -0,0 +1,9 @@
+-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4/t3pxBvR68V27NiEp76
+pKxlQWchdyScphxK0pC6sH9goQNOa8YH9pZjQ4ltrTiG4DkeAPG6THjT9sxq6VM7
+mBZkqfeuOdJk1XCE3i06QeUzT2O7XfMFkPllAedkSDKE+PnuiTZHl8LF7Xhx3gcV
+PgSLDs0oqR2NpOccG1gxIV0xQ49BIO/lyHumNN4xQ1WINheCkOQhdryJdVVjpUvm
+zAJ4BAs1daNAgY2shAFUuta+vO6vRT/viCyVo0YfkeQharCMGylv0H7sHzUb3SlR
+kGDci1l3X85LLbRX0JO4je+5sO7vr4ePRhGVNtGYpTldLBoM+Iu29ejnVzqBFieN
+ewIDAQAB
+-----END PUBLIC KEY-----
diff --git a/libs/sftp/src/test/java/org/the_jk/cleversync/sftp/SftpTreeTest.kt b/libs/sftp/src/test/java/org/the_jk/cleversync/sftp/SftpTreeTest.kt
new file mode 100644
index 0000000..60b25f2
--- /dev/null
+++ b/libs/sftp/src/test/java/org/the_jk/cleversync/sftp/SftpTreeTest.kt
@@ -0,0 +1,168 @@
+package org.the_jk.cleversync.sftp
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import com.google.common.truth.Truth.assertThat
+import org.junit.After
+import org.junit.AfterClass
+import org.junit.Before
+import org.junit.BeforeClass
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowLooper
+import org.the_jk.cleversync.TreeAbstractTest
+import org.the_jk.cleversync.io.Link
+import org.the_jk.cleversync.io.sftp.SftpCredentials
+import java.io.File
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.util.concurrent.TimeUnit
+
+@Config(manifest=Config.NONE)
+@RunWith(RobolectricTestRunner::class)
+class SftpTreeTest : TreeAbstractTest() {
+ @Before
+ fun setUpTest() {
+ assertThat(shareDir.listFiles()).isEmpty()
+
+ val credentials = SftpCredentials.SftpPasswordCredentials("user", "notverysecret")
+
+ tree = SftpTreeFactory.modifiableTree(uri, credentials).getOrThrow()
+ }
+
+ @After
+ fun tearDownTest() {
+ tree.close()
+
+ for (file in shareDir.listFiles()!!) {
+ if (file.isDirectory) {
+ file.deleteRecursively()
+ } else {
+ file.delete()
+ }
+ }
+ }
+
+ @Test
+ fun description() {
+ val resources = ApplicationProvider.getApplicationContext<Context>().resources
+ assertThat(tree.description(resources).toString()).contains(uri)
+ }
+
+ @Test
+ fun listRootWithSymlink() {
+ File(shareDir, "dir").mkdir()
+ File(shareDir, "file").writeText("foo")
+ Files.createSymbolicLink(File(shareDir, "link").toPath(), File("file").toPath())
+
+ val content = tree.list()
+
+ assertThat(content.directories).hasSize(1)
+ assertThat(content.directories[0].name).isEqualTo("dir")
+ assertThat(content.files).hasSize(1)
+ assertThat(content.files[0].name).isEqualTo("file")
+ assertThat(content.files[0].size).isEqualTo(3UL)
+ assertThat(content.links).hasSize(1)
+ assertThat(content.links[0].name).isEqualTo("link")
+ val target = content.links[0].resolve()
+ assertThat(
+ when (target) {
+ is Link.DirectoryTarget -> null
+ Link.NoTarget -> null
+ is Link.FileTarget -> target.file
+ },
+ ).isEqualTo(content.files[0])
+ }
+
+ @Test
+ fun readExistingFile() {
+ File(shareDir, "file").writeText("hello world")
+
+ val file = tree.openFile("file")
+ assertThat(file?.name).isEqualTo("file")
+ assertThat(file?.size).isEqualTo(11UL)
+
+ file?.read().use { input ->
+ assertThat(input?.readAllBytes()?.toString(StandardCharsets.UTF_8)).isEqualTo("hello world")
+ }
+
+ file?.read().use { input ->
+ val buffer = ByteArray(10)
+ assertThat(input?.read(buffer, 5, 5)).isEqualTo(5)
+ assertThat(buffer.sliceArray(5..<10).toString(StandardCharsets.UTF_8)).isEqualTo("hello")
+ assertThat(input?.read(buffer)).isEqualTo(6)
+ assertThat(buffer.sliceArray(0..<6).toString(StandardCharsets.UTF_8)).isEqualTo(" world")
+ }
+ }
+
+ @Test
+ override fun createFile() {
+ super.createFile()
+
+ assertThat(File(shareDir, "foo").readBytes()).isEqualTo(byteArrayOf(1, 2, 3, 4))
+ }
+
+ @Test
+ override fun overwriteFile() {
+ super.overwriteFile()
+
+ assertThat(File(shareDir, "foo").readBytes()).isEqualTo(byteArrayOf(127, 1))
+ }
+
+ @Test
+ override fun createDirectory() {
+ super.createDirectory()
+
+ assertThat(File(shareDir, "foo").isDirectory).isTrue()
+ }
+
+ @Test(timeout = 10000)
+ override fun observeCreateDirectory() {
+ super.observeCreateDirectory()
+
+ assertThat(File(shareDir, "foo").isDirectory).isTrue()
+ }
+
+ override fun supportSymlinks() = true
+
+ override fun idle() {
+ ShadowLooper.idleMainLooper(10, TimeUnit.SECONDS)
+ }
+
+ companion object {
+ private lateinit var uri: String
+ private lateinit var dockerDir: File
+ private lateinit var shareDir: File
+ private var dockerRunning = false
+
+ @BeforeClass
+ @JvmStatic
+ fun setUpClass() {
+ uri = "ssh://127.0.0.1:10022/share"
+ dockerDir = File(System.getProperty("dockerDir")!!)
+ shareDir = File(System.getProperty("shareDir")!!)
+ shareDir.deleteRecursively()
+ shareDir.mkdirs()
+
+ val pb = ProcessBuilder("docker", "compose", "up", "--wait", "--wait-timeout", "60")
+ pb.directory(dockerDir)
+ val exitCode = pb.start().waitFor()
+ assertThat(exitCode).isEqualTo(0)
+ dockerRunning = true
+ }
+
+ @AfterClass
+ @JvmStatic
+ fun tearDownClass() {
+ if (dockerRunning) {
+ val pb = ProcessBuilder("docker", "compose", "down")
+ pb.directory(dockerDir)
+ pb.start().waitFor()
+ dockerRunning = false
+ }
+ shareDir.deleteRecursively()
+ }
+ }
+}
diff --git a/libs/test-utils/src/main/java/org/the_jk/cleversync/TreeAbstractTest.kt b/libs/test-utils/src/main/java/org/the_jk/cleversync/TreeAbstractTest.kt
index ba65108..396e801 100644
--- a/libs/test-utils/src/main/java/org/the_jk/cleversync/TreeAbstractTest.kt
+++ b/libs/test-utils/src/main/java/org/the_jk/cleversync/TreeAbstractTest.kt
@@ -138,7 +138,7 @@ abstract class TreeAbstractTest {
is Link.FileTarget -> Assert.fail()
is Link.NoTarget -> Assert.fail()
}
- assertThat(tree.openDir("link")).isEqualTo(dir)
+ assertThat(tree.openDir("link")?.name).isAnyOf("dir", "link")
link.target(file)
target = link.resolve()
@@ -155,7 +155,7 @@ abstract class TreeAbstractTest {
is Link.NoTarget -> Assert.fail()
}
- assertThat(tree.openFile("link")).isEqualTo(file)
+ assertThat(tree.openFile("link")?.name).isAnyOf("file", "link")
}
@Test
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 1910476..e68e029 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -25,5 +25,6 @@ include(":libs:documents")
include(":libs:io")
include(":libs:local")
include(":libs:samba")
+include(":libs:sftp")
include(":libs:test-utils")
include(":libs:utils")