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/sftp/src/main/java | |
| parent | 07d35782b377a8b98cf8dbbb5734d3f2514bccd5 (diff) | |
Add libs:sftp
sftp implementation using libssh2 and openssl
Diffstat (limited to 'libs/sftp/src/main/java')
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, "")) + } +} |
