diff options
| author | Joel Klinghed <the_jk@spawned.biz> | 2024-09-25 21:12:24 +0200 |
|---|---|---|
| committer | Joel Klinghed <the_jk@spawned.biz> | 2024-09-25 21:12:24 +0200 |
| commit | 28a55fdc69e31490a4086ecae8cc687f40ba0b94 (patch) | |
| tree | 9bde6e49eb091f912e8a9f8b2853d87f6a932d27 /libs | |
| parent | 07d35782b377a8b98cf8dbbb5734d3f2514bccd5 (diff) | |
Add libs:sftp
sftp implementation using libssh2 and openssl
Diffstat (limited to 'libs')
28 files changed, 2223 insertions, 3 deletions
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 |
