summaryrefslogtreecommitdiff
path: root/libs/sftp/src/main/java/org/the_jk
diff options
context:
space:
mode:
authorJoel Klinghed <the_jk@spawned.biz>2024-09-25 21:12:24 +0200
committerJoel Klinghed <the_jk@spawned.biz>2024-09-25 21:12:24 +0200
commit28a55fdc69e31490a4086ecae8cc687f40ba0b94 (patch)
tree9bde6e49eb091f912e8a9f8b2853d87f6a932d27 /libs/sftp/src/main/java/org/the_jk
parent07d35782b377a8b98cf8dbbb5734d3f2514bccd5 (diff)
Add libs:sftp
sftp implementation using libssh2 and openssl
Diffstat (limited to 'libs/sftp/src/main/java/org/the_jk')
-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
8 files changed, 860 insertions, 0 deletions
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, ""))
+ }
+}