diff options
| author | Lena <lena@omega> | 2026-08-16 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-08-16 00:00:00 +0000 |
| commit | 0e8a87f2ad2bd40d34996374aebb6dff070e4b5f (patch) | |
| tree | 0d233593ac24181b58fea9c4a68d58ef070482f6 /app | |
| parent | beaa0c970d6c3538119b8a34a3f2f754b45e54cf (diff) | |
| download | rsend-0e8a87f2ad2bd40d34996374aebb6dff070e4b5f.tar.gz | |
app: harden backup execution
Validate persisted state and destination boundaries, make interruption
and mirror deletion explicit, and keep scheduled work singular.
Diffstat (limited to 'app')
32 files changed, 2088 insertions, 508 deletions
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2062a2a..4aeeb9b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,14 +10,19 @@ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> + <!-- WorkManager reschedules persisted periodic work after a reboot. --> + <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <application android:name=".App" android:allowBackup="false" + android:dataExtractionRules="@xml/backup_rules" + android:fullBackupContent="@xml/backup_rules_legacy" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" - android:theme="@style/Theme.Rsend"> + android:theme="@style/Theme.Rsend" + android:usesCleartextTraffic="false"> <activity android:name=".MainActivity" @@ -51,6 +56,7 @@ <!-- WorkManager runs the sync as a dataSync foreground service. --> <service android:name="androidx.work.impl.foreground.SystemForegroundService" + android:exported="false" android:foregroundServiceType="dataSync" tools:node="merge" /> </application> diff --git a/app/src/main/java/invalid/lena/rsend/App.kt b/app/src/main/java/invalid/lena/rsend/App.kt index 55ed66e..0251d7d 100644 --- a/app/src/main/java/invalid/lena/rsend/App.kt +++ b/app/src/main/java/invalid/lena/rsend/App.kt @@ -3,17 +3,18 @@ package invalid.lena.rsend import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager -import android.os.Build // App creates the notification channel used by the foreground sync service. class App : Application() { override fun onCreate() { super.onCreate() - if (Build.VERSION.SDK_INT >= 26) { - val ch = NotificationChannel(CHANNEL, "Sync", NotificationManager.IMPORTANCE_LOW) - getSystemService(NotificationManager::class.java).createNotificationChannel(ch) - } + val ch = NotificationChannel(CHANNEL, "Sync", NotificationManager.IMPORTANCE_LOW) + getSystemService(NotificationManager::class.java).createNotificationChannel(ch) + // Apply on every process start, including one launched by an old + // WorkManager request after an app upgrade. This retires stale unique + // names without waiting for the dashboard to be opened. + Scheduler.apply(this) } companion object { diff --git a/app/src/main/java/invalid/lena/rsend/Config.kt b/app/src/main/java/invalid/lena/rsend/Config.kt index 69f3869..25bf338 100644 --- a/app/src/main/java/invalid/lena/rsend/Config.kt +++ b/app/src/main/java/invalid/lena/rsend/Config.kt @@ -4,13 +4,16 @@ import android.content.Context import android.util.AtomicFile import org.json.JSONArray import org.json.JSONObject +import java.io.ByteArrayOutputStream import java.io.File import java.io.FileNotFoundException +import java.io.InputStream +import java.util.Base64 // Config is rsend's whole state: the named remote targets, the schedule, and // the folders to push. It is stored as plain JSON in app-private storage. -// config.json owns the host-key pins (Remote.hostKey); the known_hosts file -// rsh reads is regenerated from them before every sync. +// config.json owns the host-key pins (Remote.hostKey). The known_hosts file rsh +// reads is derived from the one remote used by the current connection. data class Remote( val name: String = "", @@ -22,6 +25,48 @@ data class Remote( fun pinned(): Boolean = hostKey.isNotEmpty() } +object RemoteRules { + private const val MAX_NAME_CHARS = 128 + private const val MAX_HOST_CHARS = 255 + private const val MAX_USER_CHARS = 128 + private const val MAX_HOST_KEY_CHARS = 16 * 1024 + private val user = Regex("[A-Za-z0-9._-]+") + private val hostKeyTypes = setOf( + "ssh-ed25519", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", + "ssh-rsa", + ) + + fun nameAllowed(value: String): Boolean = + value.isNotEmpty() && value.length <= MAX_NAME_CHARS && value.none { it.isISOControl() } + + fun userAllowed(value: String): Boolean = + value.length <= MAX_USER_CHARS && !value.startsWith('-') && user.matches(value) + + fun hostAllowed(value: String): Boolean = + value.isNotEmpty() && value.length <= MAX_HOST_CHARS && !value.startsWith('-') && value.all { + it in 'A'..'Z' || it in 'a'..'z' || it in '0'..'9' || + it == '.' || it == '-' || it == '_' || + it == ':' || it == '%' + } + + fun hostKeyAllowed(remote: Remote): Boolean { + if (remote.hostKey.isEmpty()) return true + if (remote.hostKey.length > MAX_HOST_KEY_CHARS) return false + val parts = remote.hostKey.split(' ') + if (parts.size != 3 || parts.any(String::isEmpty)) return false + val address = if (remote.port == 22) remote.host else "[${remote.host}]:${remote.port}" + if (parts[0] != address || parts[1] !in hostKeyTypes) return false + return try { + Base64.getDecoder().decode(parts[2]).isNotEmpty() + } catch (_: IllegalArgumentException) { + false + } + } +} + data class Schedule( val enabled: Boolean = false, val intervalMinutes: Int = 120, @@ -49,12 +94,13 @@ data class Config( ) { fun remote(name: String): Remote? = remotes.firstOrNull { it.name == name } - // syncReady reports whether a sync can do useful work: an identity key, - // at least one folder, and at least one complete, pinned remote. Folders - // pointing at a broken remote fail individually during the run. + // syncReady reports whether a run can do useful work: an identity key and + // at least one folder with a pinned target. Requiring every folder to be + // ready would let one half-configured folder, a remote added but not yet + // pinned, stop the backups that were already working. A folder whose + // remote is not pinned fails on its own and is named in the log. fun syncReady(keyExists: Boolean): Boolean = - keyExists && folders.isNotEmpty() && - remotes.any { it.host.isNotEmpty() && it.user.isNotEmpty() && it.pinned() } + keyExists && folders.any { remote(it.remoteName)?.pinned() == true } fun toJson(): JSONObject = JSONObject().apply { put("remotes", JSONArray().apply { @@ -89,48 +135,51 @@ data class Config( } companion object { + internal const val MAX_CONFIG_BYTES = 1024 * 1024 + private const val MAX_REMOTES = 64 + private const val MAX_FOLDERS = 256 fun file(ctx: Context): File = File(ctx.filesDir, "config.json") + private fun recoveryFile(ctx: Context): File = File(ctx.filesDir, "config-recovery.txt") + private fun legacyFile(ctx: Context): File = File(ctx.filesDir, "config.json.0.1") - // legacyShape reports whether o is a pre-0.2 config: no "remotes" array, - // but the single-remote "remote" object 0.1.x always wrote. - fun legacyShape(o: JSONObject): Boolean = - o.optJSONArray("remotes") == null && o.optJSONObject("remote") != null - + @Synchronized fun load(ctx: Context): Config { val f = file(ctx) - val migrated: Boolean - val c = try { - val text = AtomicFile(f).openRead().bufferedReader().use { it.readText() } - val o = JSONObject(text) - migrated = legacyShape(o) - // Only the legacy single-remote shape needs the old pin file. - var pin = "" - if (migrated) { - val kh = Keys.knownHosts(ctx) - if (kh.exists()) pin = kh.readText().trim() - } - fromJson(o, pin) + val text = try { + AtomicFile(f).openRead().use { readText(it, MAX_CONFIG_BYTES) } } catch (_: FileNotFoundException) { return Config() } catch (e: Exception) { - throw IllegalStateException("could not read ${f.name}: ${e.message}", e) + return recover(ctx, f, "read failed", e) + } + return try { + val o = JSONObject(text) + if (!legacyShape(o)) return fromJson(o) + // Retire the old shape on the first read rather than waiting + // for the user to edit something, and write it back so this + // path is never taken twice: toJson always emits "remotes", so + // legacyShape cannot match again. + val upgraded = upgradeLegacy(o, Keys.legacyPin(ctx)) + val migrated = fromJson(upgraded) + preserveLegacy(ctx, text, o, migrated) + save(ctx, migrated) + migrated + } catch (e: Exception) { + recover(ctx, f, "parse failed", e) } - // Retire the old shape on the first read rather than waiting for the - // user to edit something. Without this a working 0.1.x setup keeps - // its pre-0.2 config.json indefinitely, and deleting the fallback in - // a later release would silently strand it. Writing the new shape - // also makes the fallback unreachable from here on: toJson always - // emits "remotes", so legacyShape can never match again. - if (migrated) save(ctx, c) - return c } // AtomicFile keeps the previous complete config if a write is interrupted. + @Synchronized fun save(ctx: Context, c: Config) { + val json = c.toJson() + fromJson(json) + val bytes = json.toString(2).toByteArray(Charsets.UTF_8) + require(bytes.size <= MAX_CONFIG_BYTES) { "config exceeds $MAX_CONFIG_BYTES bytes" } val file = AtomicFile(file(ctx)) val out = file.startWrite() try { - out.write(c.toJson().toString(2).toByteArray()) + out.write(bytes) file.finishWrite(out) } catch (e: Exception) { file.failWrite(out) @@ -138,75 +187,288 @@ data class Config( } } - // fromJson also reads the pre-0.2 single-remote shape once: the old - // "remote" object becomes the sole entry, named after its host, every - // folder points at it (the folder's old "remote" field was the - // destination path), and legacyPin carries the known_hosts line over. - // Remove this fallback after a release or two. - fun fromJson(o: JSONObject, legacyPin: String = ""): Config { - val ra = o.optJSONArray("remotes") - val legacy = if (ra == null) o.optJSONObject("remote") else null - val legacyName = legacy?.optString("host") ?: "" + // legacyShape reports whether o is the pre-0.2 config 0.1.x wrote: one + // "remote" object instead of a "remotes" array. + fun legacyShape(o: JSONObject): Boolean = + !o.has("remotes") && o.optJSONObject("remote") != null - val remotes = ArrayList<Remote>() - if (ra != null) { - for (i in 0 until ra.length()) { - val ro = ra.getJSONObject(i) - remotes.add( - Remote( - name = ro.optString("name"), - host = ro.optString("host"), - port = ro.optInt("port", 22), - user = ro.optString("user"), - hostKey = ro.optString("hostKey"), - ) + // upgradeLegacy rewrites a 0.1.x config into the current shape so the + // strict parser validates it exactly like any other config. The single + // remote becomes the sole entry named after its host, every retained + // safe folder points at it, and the folder's old "remote" field was the + // destination path. pin is the known_hosts line 0.1.x kept in a separate + // file; it is carried over only if it still describes this host and port, so a + // pin that no longer matches costs one Test connection rather than the + // whole config. + // + // This is a deliberate, documented exception to the no-compatibility + // rule: 0.1.3 is the last published release, so it is what every + // upgrading user has, and the alternative is that all of them silently + // lose their remotes, pins and folders. Delete it in 0.4.0. + fun upgradeLegacy(o: JSONObject, pin: String): JSONObject { + val old = o.getJSONObject("remote") + val host = old.optString("host").trim().removeSurrounding("[", "]").trim() + val port = old.optInt("port", 22) + val user = old.optString("user") + val candidate = Remote( + name = host, + host = host, + port = port, + user = user, + hostKey = pin, + ) + val endpointAllowed = RemoteRules.nameAllowed(host) && + RemoteRules.hostAllowed(host) && RemoteRules.userAllowed(user) && port in 1..65535 + val remote = JSONObject() + .put("name", host) + .put("host", host) + .put("port", port) + .put("user", user) + .put("hostKey", "") + if (endpointAllowed && RemoteRules.hostKeyAllowed(candidate) && pin.isNotEmpty()) { + remote.put("hostKey", pin) + } + + val folders = JSONArray() + val accepted = ArrayList<Folder>() + val fa = o.optJSONArray("folders") ?: JSONArray() + for (i in 0 until fa.length()) { + if (!endpointAllowed || accepted.size >= MAX_FOLDERS) break + val f = fa.getJSONObject(i) + val oldExcludes = f.optJSONArray("excludes") ?: JSONArray() + if (oldExcludes.length() > FolderRules.MAX_EXCLUDES) continue + val excludes = JSONArray() + for (j in 0 until oldExcludes.length()) { + excludes.put(oldExcludes.getString(j)) + } + val folder = Folder( + name = f.optString("name"), + local = f.optString("local"), + remoteName = host, + remotePath = f.optString("remote"), + delete = f.optBoolean("delete", false), + excludes = (0 until excludes.length()).map(excludes::getString), + ) + val trial = Config( + remotes = if (endpointAllowed) listOf(candidate.copy(hostKey = "")) else emptyList(), + folders = accepted, + ) + if (FolderRules.nameAllowed(folder.name) && + FolderRules.localPathAllowed(folder.local) && + FolderRules.remotePathAllowed(folder.remotePath) && + FolderRules.excludesAllowed(folder.excludes) && + FolderRules.destinationConflict(trial, folder, -1) == null + ) { + accepted.add(folder) + folders.put( + JSONObject() + .put("name", folder.name) + .put("local", folder.local) + .put("remoteName", host) + .put("remotePath", folder.remotePath) + .put("delete", folder.delete) + .put("excludes", excludes), ) } - } else if (legacyName.isNotEmpty()) { + } + val oldSchedule = o.optJSONObject("schedule") ?: JSONObject() + val schedule = JSONObject() + .put("enabled", oldSchedule.optBoolean("enabled", false)) + .put( + "intervalMinutes", + oldSchedule.optInt("intervalMinutes", 120) + .coerceAtLeast(Schedule.MIN_INTERVAL_MINUTES), + ) + .put("wifiOnly", oldSchedule.optBoolean("wifiOnly", true)) + .put("requireCharging", oldSchedule.optBoolean("requireCharging", false)) + return JSONObject() + .put("remotes", if (endpointAllowed) JSONArray().put(remote) else JSONArray()) + .put("schedule", schedule) + .put("folders", folders) + } + + private fun preserveLegacy(ctx: Context, text: String, old: JSONObject, migrated: Config) { + atomicWrite(legacyFile(ctx), text.toByteArray(Charsets.UTF_8)) + val oldFolders = old.optJSONArray("folders")?.length() ?: 0 + val endpointOmitted = old.optJSONObject("remote")?.optString("host").orEmpty().isNotEmpty() && + migrated.remotes.isEmpty() + val omitted = oldFolders - migrated.folders.size + if (!endpointOmitted && omitted == 0) return + val what = buildList { + if (endpointOmitted) add("the invalid remote") + if (omitted > 0) add("$omitted unsafe folder mapping(s)") + }.joinToString(" and ") + val message = + "The 0.1.x configuration was upgraded with omissions: $what. " + + "The exact original was preserved as ${legacyFile(ctx).name}." + atomicWrite(recoveryFile(ctx), (message + "\n").toByteArray(Charsets.UTF_8)) + } + + fun fromJson(o: JSONObject): Config { + requireFields(o, setOf("remotes", "schedule", "folders")) + val ra = o.getJSONArray("remotes") + require(ra.length() <= MAX_REMOTES) { "too many remotes" } + val remotes = ArrayList<Remote>() + for (i in 0 until ra.length()) { + val ro = ra.getJSONObject(i) + requireFields(ro, setOf("name", "host", "port", "user", "hostKey")) remotes.add( Remote( - name = legacyName, - host = legacyName, - port = legacy!!.optInt("port", 22), - user = legacy.optString("user"), - hostKey = legacyPin, + name = string(ro, "name"), + host = string(ro, "host"), + port = integer(ro, "port"), + user = string(ro, "user"), + hostKey = string(ro, "hostKey"), ) ) } + require(remotes.map { it.name }.distinct().size == remotes.size) { "remote names must be unique" } + require(remotes.all { RemoteRules.nameAllowed(it.name) }) { "remote name is invalid" } + require(remotes.all { RemoteRules.hostAllowed(it.host) }) { "remote host is invalid" } + require(remotes.all { RemoteRules.userAllowed(it.user) }) { "remote user is invalid" } + require(remotes.all { it.port in 1..65535 }) { "remote port must be between 1 and 65535" } + require(remotes.all(RemoteRules::hostKeyAllowed)) { "remote host key is invalid" } - val s = o.optJSONObject("schedule") ?: JSONObject() - val fa = o.optJSONArray("folders") ?: JSONArray() + val s = o.getJSONObject("schedule") + requireFields(s, setOf("enabled", "intervalMinutes", "wifiOnly", "requireCharging")) + val interval = integer(s, "intervalMinutes") + require(interval >= Schedule.MIN_INTERVAL_MINUTES) { + "schedule interval must be at least ${Schedule.MIN_INTERVAL_MINUTES} minutes" + } + val fa = o.getJSONArray("folders") + require(fa.length() <= MAX_FOLDERS) { "too many folders" } val folders = ArrayList<Folder>(fa.length()) for (i in 0 until fa.length()) { val fo = fa.getJSONObject(i) - val ex = fo.optJSONArray("excludes") ?: JSONArray() + requireFields(fo, setOf("name", "local", "remoteName", "remotePath", "delete", "excludes")) + val ex = fo.getJSONArray("excludes") + require(ex.length() <= FolderRules.MAX_EXCLUDES) { "too many folder excludes" } val excludes = ArrayList<String>(ex.length()) - for (j in 0 until ex.length()) excludes.add(ex.getString(j)) + for (j in 0 until ex.length()) { + val value = ex.get(j) + require(value is String) { "folder exclude must be a string" } + excludes.add(value) + } folders.add( Folder( - name = fo.optString("name"), - local = fo.optString("local"), - // Gate on the legacy shape, not on the legacy host: a - // 0.1.x config with folders but no host yet still has - // real paths in the folders' old "remote" field. - remoteName = if (legacy != null) legacyName else fo.optString("remoteName"), - remotePath = if (legacy != null) fo.optString("remote") else fo.optString("remotePath"), - delete = fo.optBoolean("delete", false), + name = string(fo, "name"), + local = string(fo, "local"), + remoteName = string(fo, "remoteName"), + remotePath = string(fo, "remotePath"), + delete = bool(fo, "delete"), excludes = excludes, ) ) } - return Config( + require(folders.all { FolderRules.nameAllowed(it.name) }) { "folder name is invalid" } + require(folders.all { FolderRules.localPathAllowed(it.local) }) { "folder local path is invalid" } + require(folders.all { FolderRules.remotePathAllowed(it.remotePath) }) { "folder remote path is invalid" } + val remoteNames = remotes.map(Remote::name).toSet() + require(folders.all { it.remoteName in remoteNames }) { "folder refers to a missing remote" } + require(folders.all { f -> FolderRules.excludesAllowed(f.excludes) }) { "folder exclude is invalid" } + val config = Config( remotes = remotes, schedule = Schedule( - enabled = s.optBoolean("enabled", false), - intervalMinutes = s.optInt("intervalMinutes", 120) - .coerceAtLeast(Schedule.MIN_INTERVAL_MINUTES), - wifiOnly = s.optBoolean("wifiOnly", true), - requireCharging = s.optBoolean("requireCharging", false), + enabled = bool(s, "enabled"), + intervalMinutes = interval, + wifiOnly = bool(s, "wifiOnly"), + requireCharging = bool(s, "requireCharging"), ), folders = folders, ) + require(FolderRules.firstDestinationConflict(config) == null) { + "folder remote destinations conflict" + } + return config + } + + private fun requireFields(o: JSONObject, expected: Set<String>) { + val missing = expected.firstOrNull { !o.has(it) } + require(missing == null) { "missing config field: $missing" } + val unexpected = o.keys().asSequence().firstOrNull { it !in expected } + require(unexpected == null) { "unexpected config field: $unexpected" } + } + + private fun string(o: JSONObject, name: String): String { + val value = o.get(name) + require(value is String) { "$name must be a string" } + return value + } + + private fun integer(o: JSONObject, name: String): Int { + val value = o.get(name) + require(value is Int) { "$name must be an integer" } + return value + } + + private fun bool(o: JSONObject, name: String): Boolean { + val value = o.get(name) + require(value is Boolean) { "$name must be a boolean" } + return value + } + + fun recoveryMessage(ctx: Context): String { + val f = recoveryFile(ctx) + return try { + AtomicFile(f).openRead().use { readText(it, 4096) } + } catch (_: FileNotFoundException) { + "" + } + } + + fun clearRecoveryMessage(ctx: Context) { + AtomicFile(recoveryFile(ctx)).delete() + } + + private fun recover(ctx: Context, source: File, what: String, error: Exception): Config { + val broken = quarantine(source) + val detail = (error.message ?: error.javaClass.simpleName) + .replace('\n', ' ') + .replace('\r', ' ') + .take(300) + val message = + "Configuration $what and was reset. The original file was preserved as ${broken.name}. " + + "Reason: $detail" + atomicWrite(recoveryFile(ctx), (message + "\n").toByteArray()) + return Config() + } + + internal fun quarantine(source: File): File { + var n = 0 + var target: File + do { + val suffix = if (n == 0) ".broken" else ".broken.$n" + target = File(source.parentFile, source.name + suffix) + n++ + } while (target.exists()) + if (!source.renameTo(target)) { + throw IllegalStateException("could not preserve unreadable ${source.name}") + } + return target + } + + internal fun readText(input: InputStream, limit: Int): String { + val out = ByteArrayOutputStream(minOf(limit, 8192)) + val buf = ByteArray(4096) + while (true) { + val n = input.read(buf) + if (n < 0) break + if (out.size() + n > limit) throw IllegalArgumentException("file exceeds $limit bytes") + out.write(buf, 0, n) + } + return String(out.toByteArray(), Charsets.UTF_8) + } + + private fun atomicWrite(f: File, bytes: ByteArray) { + val file = AtomicFile(f) + val out = file.startWrite() + try { + out.write(bytes) + file.finishWrite(out) + } catch (e: Exception) { + file.failWrite(out) + throw e + } } } } diff --git a/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt b/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt index 14e04c9..5565bff 100644 --- a/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt @@ -8,10 +8,11 @@ import android.widget.ArrayAdapter import android.widget.Button import android.widget.EditText import android.widget.Spinner -import android.widget.Switch import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SwitchCompat import java.io.File // FolderEditActivity adds or edits one folder mapping. index < 0 means a new @@ -24,10 +25,10 @@ class FolderEditActivity : AppCompatActivity() { private lateinit var remoteName: Spinner private lateinit var remotePath: EditText private lateinit var excludes: EditText - private lateinit var delete: Switch + private lateinit var delete: SwitchCompat + private var mirrorConfirmed = false - // remoteNames[i] is the real name behind spinner position i; the label - // differs only for a reference to a remote that no longer exists. + // remoteNames[i] is the real name behind spinner position i. private var remoteNames: List<String> = emptyList() // The picker returns a real filesystem path; drop it into the local field and @@ -63,18 +64,10 @@ class FolderEditActivity : AppCompatActivity() { excludes.setText(f.excludes.joinToString(", ")) delete.isChecked = f.delete - // A reference to a missing remote (hand-edited config) stays selectable - // as "name (missing)" so Save cannot silently repoint the folder, which - // with mirror on would --delete into the wrong host. val names = cfg.remotes.map { it.name }.toMutableList() - val labels = names.toMutableList() - if (f.remoteName.isNotEmpty() && f.remoteName !in names) { - names.add(0, f.remoteName) - labels.add(0, "${f.remoteName} (missing)") - } remoteNames = names remoteName.adapter = - ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, labels) + ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, names) val sel = names.indexOf(f.remoteName) if (sel >= 0) remoteName.setSelection(sel) @@ -105,16 +98,67 @@ class FolderEditActivity : AppCompatActivity() { delete = delete.isChecked, excludes = excludes.text.toString().split(",").map { it.trim() }.filter { it.isNotEmpty() }, ) - // An empty path would rsync / (local) or the rrsync root (remote): - // refuse rather than sync the world. + // Neither filesystem root is a reasonable backup boundary. Require a + // local path below root and a named remote destination tree. if (f.local.isEmpty() || f.remotePath.isEmpty()) { Toast.makeText(this, "Set both local and remote paths first.", Toast.LENGTH_LONG).show() return false } - if (!File(f.local).exists()) { + if (!FolderRules.nameAllowed(f.name) || !FolderRules.remotePathAllowed(f.remotePath) || + !FolderRules.excludesAllowed(f.excludes) + ) { + Toast.makeText( + this, + "Names, remote paths, and excludes cannot contain control characters; " + + "use a named remote directory without :, ., .., or a bare ~.", + Toast.LENGTH_LONG, + ).show() + return false + } + if (!FolderRules.localPathAllowed(f.local)) { + Toast.makeText(this, "Local path must be an absolute path below the filesystem root.", Toast.LENGTH_LONG) + .show() + return false + } + val localFile = File(f.local) + if (localFile.exists() && !localFile.isDirectory) { + Toast.makeText(this, "Local path is not a directory.", Toast.LENGTH_LONG).show() + return false + } + if (localFile.exists() && !FolderRules.sourceReadable(f.local)) { + Toast.makeText(this, "Local directory cannot be read.", Toast.LENGTH_LONG).show() + return false + } + if (!localFile.exists()) { Toast.makeText(this, "Warning: local path does not exist yet.", Toast.LENGTH_LONG).show() } val cfg = Config.load(this) + val conflict = FolderRules.destinationConflict(cfg, f, index) + if (conflict != null) { + val label = conflict.name.ifEmpty { conflict.remotePath } + Toast.makeText( + this, + "Remote destination conflicts with folder \"$label\". Use separate trees and one path style per remote.", + Toast.LENGTH_LONG, + ).show() + return false + } + val oldMirror = cfg.folders.getOrNull(index)?.delete == true + if (f.delete && !oldMirror && !mirrorConfirmed) { + AlertDialog.Builder(this) + .setTitle("Enable exact mirror?") + .setMessage( + "Mirror follows rsync exactly: files absent locally are deleted remotely. " + + "An empty local folder empties the remote destination." + ) + .setPositiveButton("Enable mirror") { _, _ -> + mirrorConfirmed = true + if (saveFolder()) finish() + } + .setNegativeButton("Cancel", null) + .show() + return false + } val list = cfg.folders.toMutableList() if (index in list.indices) list[index] = f else list.add(f) Config.save(this, cfg.copy(folders = list)) diff --git a/app/src/main/java/invalid/lena/rsend/FolderPickerActivity.kt b/app/src/main/java/invalid/lena/rsend/FolderPickerActivity.kt index 097be3b..0e403e0 100644 --- a/app/src/main/java/invalid/lena/rsend/FolderPickerActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/FolderPickerActivity.kt @@ -9,6 +9,7 @@ import android.widget.TextView import androidx.activity.addCallback import androidx.appcompat.app.AppCompatActivity import java.io.File +import java.nio.file.Files // FolderPickerActivity browses the real filesystem and returns one directory's // absolute path in the "path" result extra. The app holds all-files access, so @@ -17,8 +18,13 @@ import java.io.File // above it; SD cards and other volumes are still reachable by typing the path. class FolderPickerActivity : AppCompatActivity() { + companion object { + private const val MAX_VISIBLE_DIRECTORIES = 500 + } + private lateinit var pathView: TextView private lateinit var list: android.widget.LinearLayout + private lateinit var use: Button private lateinit var root: File private lateinit var current: File @@ -31,15 +37,16 @@ class FolderPickerActivity : AppCompatActivity() { pathView = findViewById(R.id.currentPath) list = findViewById(R.id.list) - root = getSystemService(StorageManager::class.java).primaryStorageVolume.directory - ?: Environment.getExternalStorageDirectory() + root = (getSystemService(StorageManager::class.java).primaryStorageVolume.directory + ?: Environment.getExternalStorageDirectory()).canonicalFile // Start where the field already points, if that is a directory under the // root; otherwise at the root itself. - val start = intent.getStringExtra("start")?.let { File(it) } - current = if (start != null && start.isDirectory && underRoot(start)) start else root + val start = intent.getStringExtra("start")?.let { safeDirectory(File(it)) } + current = start ?: root - findViewById<Button>(R.id.use).setOnClickListener { + use = findViewById(R.id.use) + use.setOnClickListener { setResult(RESULT_OK, Intent().putExtra("path", current.absolutePath)) finish() } @@ -67,23 +74,58 @@ class FolderPickerActivity : AppCompatActivity() { addRow("..", current.parentFile!!) } - val dirs = current.listFiles { f -> f.isDirectory } - ?.sortedBy { it.name.lowercase() } ?: emptyList() + // null means the directory could not be read at all, which is a + // different thing from having no subfolders and must not read as an + // invitation to pick it. + val listed = try { + Files.newDirectoryStream(current.toPath()) { Files.isDirectory(it) }.use { stream -> + val found = stream.asSequence().map { it.toFile() }.mapNotNull(::safeDirectory) + .distinctBy { it.absolutePath }.take(MAX_VISIBLE_DIRECTORIES + 1).toList() + Listing( + found.take(MAX_VISIBLE_DIRECTORIES).sortedBy { it.name.lowercase() }, + found.size > MAX_VISIBLE_DIRECTORIES, + ) + } + } catch (_: Exception) { + null + } + use.isEnabled = listed != null + val dirs = listed?.directories ?: emptyList() for (d in dirs) addRow(d.name, d) - if (dirs.isEmpty()) { - list.addView(TextView(this).apply { - text = "No subfolders here. Tap \"Use this folder\" to pick it." - setTextAppearance(R.style.TextAppearance_Rsend_Caption) - }) + if (listed?.truncated == true) { + list.addView(message("More folders exist. Type a deeper path to reach one not shown.")) + } else if (dirs.isEmpty()) { + list.addView( + message( + if (listed == null) { + "Cannot read this folder. Check all-files access on the main screen." + } else { + "No subfolders here. Tap \"Use this folder\" to pick it." + } + ) + ) } } + private data class Listing(val directories: List<File>, val truncated: Boolean) + + private fun message(value: String): TextView = TextView(this).apply { + text = value + setTextAppearance(R.style.TextAppearance_Rsend_Caption) + } + // Anchored at a path separator so /storage/emulated/0-evil does not pass // for a root of /storage/emulated/0. private fun underRoot(f: File): Boolean = f.absolutePath == root.absolutePath || f.absolutePath.startsWith(root.absolutePath + "/") + private fun safeDirectory(file: File): File? = try { + file.canonicalFile.takeIf { it.isDirectory && underRoot(it) } + } catch (_: Exception) { + null + } + private fun addRow(label: String, dir: File) { val row = layoutInflater.inflate(R.layout.item_dir, list, false) row.findViewById<TextView>(R.id.dirName).text = label diff --git a/app/src/main/java/invalid/lena/rsend/FolderRules.kt b/app/src/main/java/invalid/lena/rsend/FolderRules.kt new file mode 100644 index 0000000..1aa07c9 --- /dev/null +++ b/app/src/main/java/invalid/lena/rsend/FolderRules.kt @@ -0,0 +1,108 @@ +package invalid.lena.rsend + +import java.io.File +import java.nio.file.Files + +// FolderRules holds the small set of path checks shared by the editor and the +// worker. Remote paths are compared lexically because their filesystem lives +// on another machine and cannot be resolved safely on the phone. +object FolderRules { + private const val MAX_NAME_CHARS = 256 + private const val MAX_PATH_CHARS = 4096 + private const val MAX_EXCLUDE_CHARS = 512 + internal const val MAX_EXCLUDES = 64 + + fun nameAllowed(value: String): Boolean = + value.length <= MAX_NAME_CHARS && value.none(Char::isISOControl) + + fun localPathAllowed(path: String): Boolean { + if (path.isEmpty() || path.length > MAX_PATH_CHARS || path.any(Char::isISOControl)) return false + val file = File(path) + if (!file.isAbsolute) return false + return try { + file.toPath().normalize() != File(File.separator).toPath() + } catch (_: Exception) { + false + } + } + + // Empty directories are readable sources. Opening a DirectoryStream checks + // access without allocating one String for every entry in a large media + // directory. The source itself must not be a symlink: rsync follows a + // command-line symlink when the source has a trailing slash. + fun sourceReadable(path: String): Boolean { + if (!localPathAllowed(path)) return false + val file = File(path) + return try { + if (!file.isDirectory || Files.isSymbolicLink(file.toPath())) return false + Files.newDirectoryStream(file.toPath()).use { true } + } catch (_: Exception) { + false + } + } + + fun remotePathAllowed(path: String): Boolean { + if (path.isEmpty() || path.length > MAX_PATH_CHARS || path.startsWith(':') || + path.any(Char::isISOControl) + ) return false + if (path.startsWith('~') && !path.startsWith("~/")) return false + if (path.split('/').any { it == "." || it == ".." }) return false + return remotePath(path).parts.isNotEmpty() + } + + fun excludeAllowed(pattern: String): Boolean = + pattern.isNotEmpty() && pattern.length <= MAX_EXCLUDE_CHARS && pattern.none(Char::isISOControl) + + fun excludesAllowed(patterns: List<String>): Boolean = + patterns.size <= MAX_EXCLUDES && patterns.all(::excludeAllowed) + + fun destinationConflict(cfg: Config, candidate: Folder, exceptIndex: Int): Folder? { + val candidateRemote = cfg.remote(candidate.remoteName) + return cfg.folders.withIndex().firstOrNull { (i, existing) -> + i != exceptIndex && + sameEndpoint(candidate.remoteName, candidateRemote, existing.remoteName, cfg.remote(existing.remoteName)) && + remotePathsConflict(candidate.remotePath, existing.remotePath) + }?.value + } + + fun firstDestinationConflict(cfg: Config): Pair<Folder, Folder>? { + for ((i, folder) in cfg.folders.withIndex()) { + val other = destinationConflict(cfg, folder, i) + if (other != null) return folder to other + } + return null + } + + // Absolute and home-relative destinations can resolve to the same tree, + // but the phone cannot prove how a remote account's home is laid out. Keep + // one style per endpoint and compare conservatively on case too. + internal fun remotePathsConflict(a: String, b: String): Boolean { + val left = remotePath(a) + val right = remotePath(b) + if (left.absolute != right.absolute) return true + val shorter = minOf(left.parts.size, right.parts.size) + return (0 until shorter).all { left.parts[it].equals(right.parts[it], ignoreCase = true) } + } + + private fun sameEndpoint(aName: String, a: Remote?, bName: String, b: Remote?): Boolean { + if (aName == bName) return true + if (a == null || b == null) return false + return a.user == b.user && a.port == b.port && a.host.equals(b.host, ignoreCase = true) + } + + private data class RemotePath(val absolute: Boolean, val parts: List<String>) + + private fun remotePath(path: String): RemotePath { + val absolute = path.startsWith("/") + var value = path + if (!absolute) { + value = when { + value == "~" -> "" + value.startsWith("~/") -> value.substring(2) + else -> value + } + } + val parts = value.split('/').filter(String::isNotEmpty) + return RemotePath(absolute, parts) + } +} diff --git a/app/src/main/java/invalid/lena/rsend/KeyVault.kt b/app/src/main/java/invalid/lena/rsend/KeyVault.kt index ecbc9cf..e0fe66f 100644 --- a/app/src/main/java/invalid/lena/rsend/KeyVault.kt +++ b/app/src/main/java/invalid/lena/rsend/KeyVault.kt @@ -18,9 +18,12 @@ object KeyVault { private const val IV_LEN = 12 private const val TAG_BITS = 128 - private fun secret(): SecretKey { + private fun existing(): SecretKey? { val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } + return (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.secretKey + } + + private fun create(): SecretKey { val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") gen.init( KeyGenParameterSpec.Builder( @@ -36,13 +39,21 @@ object KeyVault { fun encrypt(plain: ByteArray): ByteArray { val c = Cipher.getInstance(TRANSFORM) - c.init(Cipher.ENCRYPT_MODE, secret()) + c.init(Cipher.ENCRYPT_MODE, existing() ?: create()) return c.iv + c.doFinal(plain) } + // Decrypting must never mint a key: doing so would replace the one that + // wrapped the stored blob, making it undecryptable forever while looking + // like an ordinary cipher failure. Say what happened instead, so the user + // is told to import or generate the identity key again. fun decrypt(blob: ByteArray): ByteArray { + require(blob.size >= IV_LEN + TAG_BITS / 8) { "encrypted private key is truncated" } + val key = existing() ?: throw IllegalStateException( + "the device keystore entry for rsend is gone; generate or import the key again" + ) val c = Cipher.getInstance(TRANSFORM) - c.init(Cipher.DECRYPT_MODE, secret(), GCMParameterSpec(TAG_BITS, blob, 0, IV_LEN)) + c.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, blob, 0, IV_LEN)) return c.doFinal(blob, IV_LEN, blob.size - IV_LEN) } } diff --git a/app/src/main/java/invalid/lena/rsend/Keys.kt b/app/src/main/java/invalid/lena/rsend/Keys.kt index 5ba6d67..bd18080 100644 --- a/app/src/main/java/invalid/lena/rsend/Keys.kt +++ b/app/src/main/java/invalid/lena/rsend/Keys.kt @@ -2,7 +2,9 @@ package invalid.lena.rsend import android.content.Context import android.util.AtomicFile +import java.io.ByteArrayOutputStream import java.io.File +import java.io.FileNotFoundException // Keys manages the on-device ed25519 identity and the pinned host keys. The // private key is stored Keystore-encrypted (KeyVault) and only ever exists in @@ -10,21 +12,29 @@ import java.io.File object Keys { private fun keyEnc(ctx: Context): File = File(ctx.filesDir, "id_ed25519.enc") - private fun publicKey(ctx: Context): File = File(ctx.filesDir, "id_ed25519.pub") + private fun knownHosts(ctx: Context): File = File(ctx.filesDir, "known_hosts") const val MAX_KEY_BYTES = 64 * 1024 + private const val MAX_ENCRYPTED_KEY_BYTES = MAX_KEY_BYTES + 64 + private val keyLock = Any() - fun knownHosts(ctx: Context): File = File(ctx.filesDir, "known_hosts") - - fun exists(ctx: Context): Boolean = keyEnc(ctx).exists() + fun exists(ctx: Context): Boolean = synchronized(keyLock) { + try { + AtomicFile(keyEnc(ctx)).openRead().use { } + true + } catch (_: FileNotFoundException) { + false + } + } // generate creates a key pair via rsh -keygen (the plaintext key never // touches disk), stores it encrypted, and returns the public key in // authorized_keys format. Storage and validation reuse importKey. - fun generate(ctx: Context): String { - val r = Native.run(Native.rsh(ctx), listOf("-keygen")) - if (r.code != 0) throw IllegalStateException(r.output.trim().ifEmpty { "keygen failed" }) - importKey(ctx, r.output.toByteArray()) - return publicKeyText(ctx) + fun generate(ctx: Context): String = synchronized(keyLock) { + val result = Native.run(Native.rsh(ctx), listOf("-keygen")) + if (result.code != 0) { + throw IllegalStateException(result.output.trim().ifEmpty { "keygen failed" }) + } + validateAndStore(ctx, result.output.toByteArray(Charsets.UTF_8)) } // importKey stores a user-supplied private key, replacing any current one. @@ -32,47 +42,106 @@ object Keys { // same parser as the transport), so a key that imports here will also sync. // Throws IllegalArgumentException with rsh's message if the key is unusable // (wrong type, or passphrase-protected: rsend needs an unencrypted key). - fun importKey(ctx: Context, pem: ByteArray) { + fun importKey(ctx: Context, pem: ByteArray): String = synchronized(keyLock) { + validateAndStore(ctx, pem) + } + + private fun validateAndStore(ctx: Context, pem: ByteArray): String { require(pem.size <= MAX_KEY_BYTES) { "private key exceeds $MAX_KEY_BYTES bytes" } - val r = Native.run(Native.rsh(ctx), listOf("-pubkey"), mapOf("RSH_KEY_DATA" to String(pem))) + val r = Native.run( + Native.rsh(ctx), + listOf("-pubkey"), + mapOf("RSH_KEY_DATA" to String(pem, Charsets.UTF_8)), + ) if (r.code != 0) throw IllegalArgumentException(r.output.trim().ifEmpty { "invalid private key" }) atomicWrite(keyEnc(ctx), KeyVault.encrypt(pem)) - atomicWrite(publicKey(ctx), (r.output.trim() + "\n").toByteArray()) + return r.output.trim() } - fun publicKeyText(ctx: Context): String = - if (publicKey(ctx).exists()) publicKey(ctx).readText().trim() else "" + fun publicKeyText(ctx: Context): String = synchronized(keyLock) { + if (!exists(ctx)) return "" + val r = Native.run(Native.rsh(ctx), listOf("-pubkey"), keyEnvironment(ctx)) + if (r.code != 0) throw IllegalStateException(r.output.trim().ifEmpty { "could not read public key" }) + r.output.trim() + } - // env returns the RSH_* environment rsh needs. The decrypted key is passed - // by value so it never touches the filesystem. - fun env(ctx: Context, port: Int): Map<String, String> = mapOf( - "RSH_KEY_DATA" to String(KeyVault.decrypt(keyEnc(ctx).readBytes())), - "RSH_KNOWN_HOSTS" to knownHosts(ctx).absolutePath, - "RSH_PORT" to port.toString(), - ) + // env derives a one-pin known_hosts file for this connection. A shared file + // containing every remote's key would let one configured alias authorize a + // different alias of the same endpoint. + fun env(ctx: Context, remote: Remote): Map<String, String> = synchronized(keyLock) { + require(RemoteRules.hostKeyAllowed(remote)) { "remote pin is invalid" } + require(remote.pinned()) { "remote is not pinned" } + val hosts = knownHosts(ctx) + atomicWrite(hosts, (remote.hostKey.trim() + "\n").toByteArray(Charsets.UTF_8)) + keyEnvironment(ctx) + mapOf( + "RSH_KNOWN_HOSTS" to hosts.absolutePath, + "RSH_PORT" to remote.port.toString(), + ) + } + + // legacyPin returns the known_hosts line 0.1.x kept beside the config, back + // when one pin was all there was. Config reads it once while migrating a + // 0.1.x config; nothing else should, because this path is now a scratch + // file rewritten per connection. Delete with that migration in 0.4.0. + fun legacyPin(ctx: Context): String { + val f = knownHosts(ctx) + if (!f.exists() || f.length() > MAX_LEGACY_PIN_BYTES) return "" + return try { + f.readText(Charsets.UTF_8).lineSequence().firstOrNull { it.isNotBlank() }?.trim().orEmpty() + } catch (_: Exception) { + "" + } + } + + private const val MAX_LEGACY_PIN_BYTES = 64L * 1024 data class Scan(val ok: Boolean, val fingerprint: String, val line: String, val error: String) // scan connects (proving the key is installed), captures the host key, and // returns its fingerprint and the known_hosts line to pin. pin() writes it // once the user accepts. - fun scan(ctx: Context, remote: Remote): Scan { + fun scan(ctx: Context, remote: Remote): Scan = synchronized(keyLock) { + // Point the scan at this remote's own pin, so rsh offers the key type + // already pinned and an unchanged server reproduces its stored line. A + // config pinned by an older rsend holds whichever key that version + // negotiated, and re-scanning without this would return a different key + // type and look like a host-key change on an untouched server. + val scanHosts = File(ctx.cacheDir, "scan_known_hosts") + atomicWrite( + scanHosts, + if (remote.pinned()) (remote.hostKey + "\n").toByteArray(Charsets.UTF_8) else ByteArray(0), + ) val r = Native.run( Native.rsh(ctx), listOf("-scan", "${remote.user}@${remote.host}"), - env(ctx, remote.port), + keyEnvironment(ctx) + mapOf( + "RSH_KNOWN_HOSTS" to scanHosts.absolutePath, + "RSH_PORT" to remote.port.toString(), + ), ) - if (r.code != 0) return Scan(false, "", "", r.output.trim()) + if (r.code != 0) return@synchronized Scan(false, "", "", r.output.trim()) val lines = r.output.trim().lines() - if (lines.size < 2) return Scan(false, "", "", "unexpected scan output:\n${r.output}") - return Scan(true, lines[0], lines[1], "") + if (lines.size < 2) { + return@synchronized Scan(false, "", "", "unexpected scan output:\n${r.output}") + } + Scan(true, lines[0], lines[1], "") } - // writeKnownHosts regenerates the known_hosts file rsh verifies against. - // The pins live in config.json (Remote.hostKey); this file is derived - // state, rewritten from all pinned remotes before every sync. - fun writeKnownHosts(ctx: Context, lines: List<String>) { - atomicWrite(knownHosts(ctx), lines.joinToString("") { it + "\n" }.toByteArray()) + private fun keyEnvironment(ctx: Context): Map<String, String> = + mapOf("RSH_KEY_DATA" to String(KeyVault.decrypt(encryptedKey(ctx)), Charsets.UTF_8)) + + private fun encryptedKey(ctx: Context): ByteArray { + val out = ByteArrayOutputStream() + AtomicFile(keyEnc(ctx)).openRead().use { input -> + val buf = ByteArray(4096) + while (true) { + val n = input.read(buf) + if (n < 0) break + require(out.size() + n <= MAX_ENCRYPTED_KEY_BYTES) { "encrypted private key is too large" } + out.write(buf, 0, n) + } + } + return out.toByteArray() } // AtomicFile keeps the previous complete file if a write is interrupted. diff --git a/app/src/main/java/invalid/lena/rsend/LastSync.kt b/app/src/main/java/invalid/lena/rsend/LastSync.kt index 3f1d518..9179b19 100644 --- a/app/src/main/java/invalid/lena/rsend/LastSync.kt +++ b/app/src/main/java/invalid/lena/rsend/LastSync.kt @@ -1,21 +1,48 @@ package invalid.lena.rsend import android.content.Context +import android.util.AtomicFile import java.io.File +import java.io.FileNotFoundException import java.text.SimpleDateFormat import java.util.Date import java.util.Locale // LastSync records the outcome of the most recent run so the dashboard can show -// it without opening the log. +// it without opening the log. This small status is independent of the full log. object LastSync { + private const val MAX_STATUS_CHARS = 4_000 + private const val MAX_FILE_BYTES = 20 * 1024 + private fun file(ctx: Context): File = File(ctx.filesDir, "last-sync") - private val fmt = SimpleDateFormat("MM-dd HH:mm", Locale.US) - fun set(ctx: Context, ok: Boolean) { - file(ctx).writeText("${fmt.format(Date())} ${if (ok) "ok" else "FAILED"}") + @Synchronized + fun set(ctx: Context, status: String) { + val clean = buildString(minOf(status.length, MAX_STATUS_CHARS)) { + for (c in status) { + append(if (c.isISOControl()) ' ' else c) + if (length == MAX_STATUS_CHARS) break + } + } + val time = SimpleDateFormat("MM-dd HH:mm", Locale.US).format(Date()) + val file = AtomicFile(file(ctx)) + val out = file.startWrite() + try { + out.write("$time $clean".toByteArray(Charsets.UTF_8)) + file.finishWrite(out) + } catch (e: Exception) { + file.failWrite(out) + throw e + } } - fun get(ctx: Context): String = if (file(ctx).exists()) file(ctx).readText() else "never" + @Synchronized + fun get(ctx: Context): String = try { + AtomicFile(file(ctx)).openRead().use { Config.readText(it, MAX_FILE_BYTES) } + } catch (_: FileNotFoundException) { + "never" + } catch (e: Exception) { + "unavailable (${e.javaClass.simpleName})" + } } diff --git a/app/src/main/java/invalid/lena/rsend/LogActivity.kt b/app/src/main/java/invalid/lena/rsend/LogActivity.kt index c9cce94..13ad766 100644 --- a/app/src/main/java/invalid/lena/rsend/LogActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/LogActivity.kt @@ -4,11 +4,13 @@ import android.os.Bundle import android.widget.Button import android.widget.TextView import androidx.appcompat.app.AppCompatActivity +import kotlin.concurrent.thread // LogActivity shows the plain-text sync log with refresh and clear. class LogActivity : AppCompatActivity() { private lateinit var log: TextView + private var generation = 0 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -18,10 +20,7 @@ class LogActivity : AppCompatActivity() { fitSystemBars() log = findViewById(R.id.log) findViewById<Button>(R.id.refresh).setOnClickListener { load() } - findViewById<Button>(R.id.clear).setOnClickListener { - SyncLog(this).file.writeText("") - load() - } + findViewById<Button>(R.id.clear).setOnClickListener { load(clear = true) } } override fun onResume() { @@ -29,7 +28,22 @@ class LogActivity : AppCompatActivity() { load() } - private fun load() { - log.text = SyncLog(this).text().ifEmpty { "No log yet." } + // File I/O stays off the main thread. SyncLog caps displayed text at 64 KB + // so the unavoidable TextView assignment and layout remain bounded. + private fun load(clear: Boolean = false) { + val current = ++generation + thread { + val result = try { + val syncLog = SyncLog(this) + if (clear) syncLog.clear() + Result.success(syncLog.text().ifEmpty { "No log yet." }) + } catch (e: Exception) { + Result.failure(e) + } + runOnUiThread { + if (isFinishing || isDestroyed || current != generation) return@runOnUiThread + log.text = result.getOrElse { "Could not read log: ${it.message}" } + } + } } } diff --git a/app/src/main/java/invalid/lena/rsend/MainActivity.kt b/app/src/main/java/invalid/lena/rsend/MainActivity.kt index 2f300c9..875afdf 100644 --- a/app/src/main/java/invalid/lena/rsend/MainActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/MainActivity.kt @@ -1,10 +1,12 @@ package invalid.lena.rsend import android.Manifest +import android.content.ActivityNotFoundException import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent +import android.net.ConnectivityManager import android.content.pm.PackageManager import android.net.Uri import android.os.Build @@ -27,7 +29,6 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager import java.io.ByteArrayOutputStream -import java.util.concurrent.TimeUnit import kotlin.concurrent.thread // MainActivity is the dashboard: a status hero, a primary Sync action, grouped @@ -35,6 +36,10 @@ import kotlin.concurrent.thread // happens in RemoteActivity, ScheduleActivity, and FolderEditActivity. class MainActivity : AppCompatActivity() { + companion object { + private const val MANUAL_WORK_NAME = "sync-now" + } + private lateinit var lastSync: TextView private lateinit var keyValue: TextView private lateinit var scheduleValue: TextView @@ -50,6 +55,13 @@ class MainActivity : AppCompatActivity() { private lateinit var syncBar: ProgressBar private lateinit var syncStatus: TextView + // Last drawn schedule and the observed state of the periodic job. Observing + // it keeps the dashboard off the 500ms blocking WorkManager query that used + // to run on the main thread for every refresh. + private var schedule: Schedule = Schedule() + private var jobState: String = "none" + private var recoveryShown = false + private val notifPerm = registerForActivityResult(ActivityResultContracts.RequestPermission()) { refresh() } @@ -82,16 +94,23 @@ class MainActivity : AppCompatActivity() { btnSync.setOnClickListener { syncNow() } observeSync() + WorkManager.getInstance(this) + .getWorkInfosForUniqueWorkLiveData(Scheduler.NAME) + .observe(this) { infos -> + jobState = activeWork(infos)?.state?.name ?: "none" + renderSchedule(schedule) + } findViewById<LinearLayout>(R.id.rowKey).setOnClickListener { showKey() } findViewById<LinearLayout>(R.id.rowSchedule).setOnClickListener { startActivity(Intent(this, ScheduleActivity::class.java)) } findViewById<LinearLayout>(R.id.rowAllFiles).setOnClickListener { - startActivity( + openSettings( Intent( Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:$packageName"), - ) + ), + Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION), ) } findViewById<LinearLayout>(R.id.rowNotif).setOnClickListener { @@ -100,21 +119,23 @@ class MainActivity : AppCompatActivity() { } else { // No runtime permission to request (pre-13) or already granted: // open the app's notification settings so the row still acts. - startActivity( + openSettings( Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) .putExtra(Settings.EXTRA_APP_PACKAGE, packageName), + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName")), ) } } findViewById<LinearLayout>(R.id.rowBattery).setOnClickListener { if (batteryUnrestricted()) { - startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + openSettings(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) } else { - startActivity( + openSettings( Intent( Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:$packageName"), ), + Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS), ) } } @@ -140,8 +161,26 @@ class MainActivity : AppCompatActivity() { .show() return } + if (!allFiles()) { + Toast.makeText(this, "Grant all-files access first, or rsend cannot read your folders.", Toast.LENGTH_LONG) + .show() + return + } + // A manual sync honours the "unmetered only" setting, but as a check + // here rather than as a WorkManager constraint. A constrained request + // would sit ENQUEUED until the phone found wifi, and observeSync counts + // ENQUEUED as running, so the button would lock at "Syncing..." with no + // way to cancel it. Refuse now, with a reason the user can act on. + if (cfg.schedule.wifiOnly && getSystemService(ConnectivityManager::class.java).isActiveNetworkMetered) { + Toast.makeText( + this, + "This network is metered and the schedule is set to unmetered only. Connect to Wi-Fi, or turn that off under Schedule.", + Toast.LENGTH_LONG, + ).show() + return + } val req = OneTimeWorkRequestBuilder<SyncWorker>().build() - WorkManager.getInstance(this).enqueueUniqueWork("sync-now", ExistingWorkPolicy.KEEP, req) + WorkManager.getInstance(this).enqueueUniqueWork(MANUAL_WORK_NAME, ExistingWorkPolicy.KEEP, req) Toast.makeText(this, "Sync started.", Toast.LENGTH_SHORT).show() } @@ -150,17 +189,15 @@ class MainActivity : AppCompatActivity() { // fresh status (last sync) the moment it ends, without leaving the screen. private fun observeSync() { WorkManager.getInstance(this) - .getWorkInfosForUniqueWorkLiveData("sync-now") + .getWorkInfosForUniqueWorkLiveData(MANUAL_WORK_NAME) .observe(this) { infos -> - val info = infos.lastOrNull() - val running = info != null && - (info.state == WorkInfo.State.RUNNING || info.state == WorkInfo.State.ENQUEUED) - if (running) { + val active = activeWork(infos) + if (active != null) { syncBar.visibility = View.VISIBLE syncStatus.visibility = View.VISIBLE btnSync.isEnabled = false btnSync.text = "Syncing..." - val p = info!!.progress + val p = active.progress val folder = p.getString(SyncWorker.KEY_FOLDER) val i = p.getInt(SyncWorker.KEY_INDEX, 0) val n = p.getInt(SyncWorker.KEY_TOTAL, 0) @@ -176,10 +213,30 @@ class MainActivity : AppCompatActivity() { } } + // WorkManager does not promise query result order. Unique work should have + // one unfinished record; generation is a deterministic tie-breaker if an + // old database contains more than one. + private fun activeWork(infos: List<WorkInfo>): WorkInfo? = + infos.filterNot { it.state.isFinished }.maxByOrNull { it.generation } + override fun onResume() { super.onResume() Scheduler.apply(this) refresh() + showRecoveryNotice() + } + + private fun showRecoveryNotice() { + if (recoveryShown) return + val message = Config.recoveryMessage(this).trim() + if (message.isEmpty()) return + recoveryShown = true + AlertDialog.Builder(this) + .setTitle("Configuration recovered") + .setMessage(message) + .setPositiveButton("OK") { _, _ -> Config.clearRecoveryMessage(this) } + .setCancelable(false) + .show() } private fun allFiles() = Environment.isExternalStorageManager() @@ -190,25 +247,26 @@ class MainActivity : AppCompatActivity() { private fun batteryUnrestricted(): Boolean = getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(packageName) - // jobState reports the WorkManager state of the scheduled sync (ENQUEUED, - // RUNNING, none, ...) so the dashboard shows whether the scheduler is armed. - private fun jobState(): String = try { - WorkManager.getInstance(this) - .getWorkInfosForUniqueWork(Scheduler.NAME) - .get(500, TimeUnit.MILLISECONDS) - .firstOrNull()?.state?.name ?: "none" - } catch (e: Exception) { - "?" + private fun openSettings(intent: Intent, fallback: Intent? = null) { + try { + startActivity(intent) + } catch (_: ActivityNotFoundException) { + if (fallback == null) { + Toast.makeText(this, "This settings screen is not available on this device.", Toast.LENGTH_LONG).show() + } else { + try { + startActivity(fallback) + } catch (_: ActivityNotFoundException) { + Toast.makeText(this, "This settings screen is not available on this device.", Toast.LENGTH_LONG) + .show() + } + } + } } - private fun refresh() { - val cfg = Config.load(this) - - lastSync.text = "Last sync: ${LastSync.get(this)}" - - keyValue.text = if (Keys.exists(this)) "Generated, tap to view" else "Not created, tap to create" - - val s = cfg.schedule + // renderSchedule draws the schedule row from the saved settings plus the + // observed WorkManager state, so neither has to block to fetch the other. + private fun renderSchedule(s: Schedule) { val sched = if (s.enabled) { "Every ${s.intervalMinutes}m" + (if (s.wifiOnly) ", unmetered" else "") + @@ -216,7 +274,18 @@ class MainActivity : AppCompatActivity() { } else { "Off" } - scheduleValue.text = "$sched, job ${jobState()}" + scheduleValue.text = "$sched, job $jobState" + } + + private fun refresh() { + val cfg = Config.load(this) + + lastSync.text = "Last sync: ${LastSync.get(this)}" + + keyValue.text = if (Keys.exists(this)) "Generated, tap to view" else "Not created, tap to create" + + schedule = cfg.schedule + renderSchedule(cfg.schedule) setPerm(dotAllFiles, valAllFiles, allFiles(), "Granted", "Tap to grant") setPerm(dotNotif, valNotif, notif(), "Granted", "Tap to grant") @@ -289,9 +358,35 @@ class MainActivity : AppCompatActivity() { // showKey displays the public key (only the key) and offers to copy it, // generate a fresh key, or import the user's own private key. private fun showKey() { + if (!Keys.exists(this)) { + showKeyDialog("") + return + } + thread { + val result = try { + Result.success(Keys.publicKeyText(this)) + } catch (e: Exception) { + Result.failure(e) + } + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + result.fold( + onSuccess = { showKeyDialog(it) }, + onFailure = { + AlertDialog.Builder(this) + .setTitle("Key unavailable") + .setMessage(it.message ?: "could not read public key") + .setPositiveButton("OK", null) + .show() + }, + ) + } + } + } + + private fun showKeyDialog(pub: String) { val view = layoutInflater.inflate(R.layout.dialog_key, null) val keyText = view.findViewById<TextView>(R.id.keyText) - val pub = Keys.publicKeyText(this) keyText.text = pub.ifEmpty { "No key yet. Generate or import one below." } val dialog = AlertDialog.Builder(this) @@ -313,11 +408,27 @@ class MainActivity : AppCompatActivity() { } view.findViewById<Button>(R.id.btnImportKey).setOnClickListener { dialog.dismiss() - openKeyDoc.launch(arrayOf("*/*")) + chooseKeyToImport() } dialog.show() } + private fun chooseKeyToImport() { + if (!Keys.exists(this)) { + openKeyDoc.launch(arrayOf("*/*")) + return + } + AlertDialog.Builder(this) + .setTitle("Replace identity key?") + .setMessage( + "Importing a key replaces the current one. The imported public key must be present in every " + + "server's authorized_keys file." + ) + .setPositiveButton("Choose key") { _, _ -> openKeyDoc.launch(arrayOf("*/*")) } + .setNegativeButton("Cancel", null) + .show() + } + // confirmGenerate warns before replacing an existing key, then generates a // new one off the main thread and shows it so the user can copy it. private fun confirmGenerate() { @@ -359,8 +470,8 @@ class MainActivity : AppCompatActivity() { } // importKeyFrom reads the chosen private-key file and stores it, replacing - // the current key. rsh validates the key first; an unusable key (wrong type - // or passphrase-protected) is reported rather than saved. + // the current key. rsh accepts only unencrypted Ed25519 keys, using the + // same parser as the transport, and reports any other file without saving. private fun importKeyFrom(uri: Uri) { thread { val error = try { diff --git a/app/src/main/java/invalid/lena/rsend/Native.kt b/app/src/main/java/invalid/lena/rsend/Native.kt index 7f7b5ba..fcc42d7 100644 --- a/app/src/main/java/invalid/lena/rsend/Native.kt +++ b/app/src/main/java/invalid/lena/rsend/Native.kt @@ -1,7 +1,9 @@ package invalid.lena.rsend import android.content.Context +import java.io.ByteArrayOutputStream import java.io.File +import java.io.InputStream import java.util.concurrent.TimeUnit // Native locates and runs the executables shipped inside the APK as lib*.so. @@ -16,17 +18,53 @@ object Native { data class Result(val code: Int, val output: String) // Blocking; call off the main thread. stderr is merged into stdout. + // + // The output is drained on its own thread while the process runs. Waiting + // first would deadlock as soon as the child filled the pipe buffer, and the + // only symptom would be the 45s timeout. fun run(bin: File, args: List<String>, env: Map<String, String> = emptyMap()): Result { val pb = ProcessBuilder(listOf(bin.absolutePath) + args).redirectErrorStream(true) pb.environment().putAll(env) val p = pb.start() + var output = "" + val drain = Thread { + try { + p.inputStream.use { output = boundedOutput(it) } + } catch (_: Exception) { + // The pipe closes when the process is killed; whatever was read + // before that is still worth returning. + } + } + drain.start() if (!p.waitFor(45, TimeUnit.SECONDS)) { p.destroyForcibly() p.waitFor() + p.inputStream.close() + drain.join(5_000) throw IllegalStateException("${bin.name} timed out") } - val out = p.inputStream.bufferedReader().use { it.readText() } - val code = p.exitValue() - return Result(code, out) + drain.join(5_000) + if (drain.isAlive) { + p.inputStream.close() + drain.join(5_000) + } + if (drain.isAlive) throw IllegalStateException("${bin.name} output reader did not stop") + return Result(p.exitValue(), output) + } + + internal fun boundedOutput(input: InputStream): String { + val out = ByteArrayOutputStream() + val buf = ByteArray(4096) + var truncated = false + while (true) { + val n = input.read(buf) + if (n < 0) break + val room = MAX_OUTPUT_BYTES - out.size() + if (room > 0) out.write(buf, 0, minOf(room, n)) + if (n > room) truncated = true + } + return String(out.toByteArray(), Charsets.UTF_8) + if (truncated) "\n[output truncated]\n" else "" } + + private const val MAX_OUTPUT_BYTES = 64 * 1024 } diff --git a/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt b/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt index 0d77e6c..994e233 100644 --- a/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt @@ -1,12 +1,14 @@ package invalid.lena.rsend import android.os.Bundle +import android.util.Base64 import android.view.View import android.widget.Button import android.widget.EditText import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import java.security.MessageDigest import kotlin.concurrent.thread // RemoteActivity adds or edits one named remote and runs Test connection, @@ -33,7 +35,7 @@ class RemoteActivity : AppCompatActivity() { user = findViewById(R.id.user) status = findViewById(R.id.status) - index = intent.getIntExtra("index", -1) + index = savedInstanceState?.getInt("index") ?: intent.getIntExtra("index", -1) val cfg = Config.load(this) if (index in cfg.remotes.indices) { val r = cfg.remotes[index] @@ -52,9 +54,14 @@ class RemoteActivity : AppCompatActivity() { del.setOnClickListener { if (remove()) finish() } } + override fun onSaveInstanceState(outState: Bundle) { + outState.putInt("index", index) + super.onSaveInstanceState(outState) + } + private fun current(): Remote? { val n = name.text.toString().trim() - if (n.isEmpty()) { + if (!RemoteRules.nameAllowed(n)) { status.text = "Set a name; folders pick their remote by it." return null } @@ -63,18 +70,29 @@ class RemoteActivity : AppCompatActivity() { status.text = "Port must be between 1 and 65535." return null } - return Remote( - name = n, - host = host.text.toString().trim(), - port = p, - user = user.text.toString().trim(), - ) + // Unbracket before validating, or a host of "[]" passes the emptiness + // check and is then stored empty. An IPv6 literal is kept bare: rsh and + // rsync each add the brackets their own syntax needs, and a stored + // bracketed form would be bracketed twice. + val h = host.text.toString().trim().removeSurrounding("[", "]").trim() + val u = user.text.toString().trim() + // Checked here rather than only in test(), so Save and Test reject the + // same incomplete form. + if (!RemoteRules.hostAllowed(h)) { + status.text = "Host must be a hostname or IP address without brackets." + return null + } + if (!RemoteRules.userAllowed(u)) { + status.text = "User may contain letters, digits, dot, underscore, and hyphen." + return null + } + return Remote(name = n, host = h, port = p, user = u) } - // save validates, keeps the pin only while host and port are unchanged, - // renames folder references along with the remote, and persists. Returns - // the saved entry's index (a new remote is appended), or -1 on a - // validation failure. + // save validates, takes the pin the caller just accepted or carries the + // stored one over while host and port are unchanged, renames folder + // references along with the remote, and persists. Returns the saved entry's + // index (a new remote is appended), or -1 on a validation failure. private fun save(remote: Remote? = current()): Int { if (remote == null) return -1 val cfg = Config.load(this) @@ -83,8 +101,11 @@ class RemoteActivity : AppCompatActivity() { return -1 } val old = cfg.remotes.getOrNull(index) - val hostKey = - if (old != null && old.host == remote.host && old.port == remote.port) old.hostKey else "" + val hostKey = when { + remote.hostKey.isNotEmpty() -> remote.hostKey + old != null && old.host == remote.host && old.port == remote.port -> old.hostKey + else -> "" + } val entry = remote.copy(hostKey = hostKey) val remotes = cfg.remotes.toMutableList() val at = if (index in remotes.indices) { @@ -100,7 +121,17 @@ class RemoteActivity : AppCompatActivity() { if (it.remoteName == old.name) it.copy(remoteName = entry.name) else it } } - Config.save(this, cfg.copy(remotes = remotes, folders = folders)) + val next = cfg.copy(remotes = remotes, folders = folders) + val conflict = FolderRules.firstDestinationConflict(next) + if (conflict != null) { + val (a, b) = conflict + status.text = + "This endpoint would make folder \"${a.name.ifEmpty { a.remotePath }}\" conflict with " + + "folder \"${b.name.ifEmpty { b.remotePath }}\"." + return -1 + } + Config.save(this, next) + Scheduler.apply(this) return at } @@ -115,57 +146,113 @@ class RemoteActivity : AppCompatActivity() { return false } Config.save(this, cfg.copy(remotes = cfg.remotes.filterIndexed { i, _ -> i != index })) + Scheduler.apply(this) return true } + // test scans the form's remote without writing anything. Persisting first + // would let a failed or cancelled test clear the pin of a working remote + // the user never saved, which also silently disarms the periodic job. private fun test() { val r = current() ?: return - val saved = save(r) - if (saved < 0) return - // Testing a new remote adopts the appended entry, so Pin and a second - // Save update it instead of appending again. - index = saved - when { - !Keys.exists(this) -> - status.text = "No key yet. Generate one on the main screen and add it to the server first." - r.host.isEmpty() || r.user.isEmpty() -> - status.text = "Set host and user first." - else -> { - status.text = "Connecting to ${r.user}@${r.host}:${r.port} ..." - thread { - val scan = try { - Keys.scan(this, r) - } catch (e: Exception) { - Keys.Scan(false, "", "", e.message ?: "connection failed") - } - runOnUiThread { - if (isFinishing || isDestroyed) return@runOnUiThread - if (!scan.ok) { - status.text = "Connection failed:\n${scan.error}" - } else { - AlertDialog.Builder(this) - .setTitle("Verify host key") - .setMessage("Fingerprint:\n${scan.fingerprint}\n\nPin this host?") - .setPositiveButton("Pin") { _, _ -> - pin(scan.line) - status.text = "Host key pinned. Connection OK." - } - .setNegativeButton("Cancel", null) - .show() - } - } + val cfg = Config.load(this) + if (cfg.remotes.withIndex().any { (i, existing) -> i != index && existing.name == r.name }) { + status.text = "A remote named \"${r.name}\" already exists." + return + } + if (!Keys.exists(this)) { + status.text = "No key yet. Generate one on the main screen and add it to the server first." + return + } + // Hand the scan the pin this remote already has, so rsh offers the + // pinned key type and an untouched server reproduces its stored line. + // Scanning the bare form instead would negotiate whatever the server + // prefers and report a key change that never happened. + val pinned = storedPin(r) + status.text = "Connecting to ${r.user}@${r.host}:${r.port} ..." + thread { + val scan = try { + Keys.scan(this, r.copy(hostKey = pinned)) + } catch (e: Exception) { + Keys.Scan(false, "", "", e.message ?: "connection failed") + } + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + if (!scan.ok) { + status.text = "Connection failed:\n${scan.error}" + } else { + confirmPin(r, pinned, scan) } } } } - // pin re-loads the config before writing: the scan ran in the background - // and the dialog may have sat open for a while. - private fun pin(line: String) { - val cfg = Config.load(this) - val remotes = cfg.remotes.toMutableList() - if (index !in remotes.indices) return - remotes[index] = remotes[index].copy(hostKey = line) - Config.save(this, cfg.copy(remotes = remotes)) + // storedPin is the pin already saved for this remote, and only while host + // and port still match the form: a pin belongs to one host and port. + private fun storedPin(r: Remote): String { + val old = Config.load(this).remotes.getOrNull(index) ?: return "" + return if (old.host == r.host && old.port == r.port) old.hostKey else "" + } + + // confirmPin is the one security decision the user makes, so a key that + // differs from the stored pin must not look like a first-time pin. + private fun confirmPin(r: Remote, pinned: String, scan: Keys.Scan) { + if (pinned == scan.line) { + status.text = "Connection OK. Host key unchanged." + return + } + val b = AlertDialog.Builder(this).setNegativeButton("Cancel", null) + if (pinned.isEmpty()) { + b.setTitle("Verify host key") + .setMessage( + "Fingerprint:\n${scan.fingerprint}\n\n" + + "Check it on the server with:\n" + + "ssh-keygen -lf ${hostKeyFile(scan.fingerprint)}\n\nPin this host?" + ) + .setPositiveButton("Pin") { _, _ -> savePinned(r, scan.line) } + } else { + b.setTitle("HOST KEY CHANGED") + .setMessage( + "This host presents a different key from the one rsend pinned.\n\n" + + "Pinned:\n${fingerprintOf(pinned)}\n\n" + + "Presented:\n${scan.fingerprint}\n\n" + + "This is what an intercepted connection looks like. Only replace " + + "the pin if you rebuilt the server or rotated its host key." + ) + .setPositiveButton("Replace pin") { _, _ -> savePinned(r, scan.line) } + } + b.show() + } + + // savePinned writes the form and the accepted key together: until the user + // accepts a key, nothing about this remote is persisted. + private fun savePinned(r: Remote, line: String) { + val at = save(r.copy(hostKey = line)) + if (at < 0) return + index = at + intent.putExtra("index", at) + status.text = "Host key pinned. Connection OK." + } + + private fun hostKeyFile(fingerprint: String): String = when (fingerprint.substringBefore(' ')) { + "ssh-ed25519" -> "/etc/ssh/ssh_host_ed25519_key.pub" + "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521" -> + "/etc/ssh/ssh_host_ecdsa_key.pub" + "ssh-rsa", "rsa-sha2-256", "rsa-sha2-512" -> "/etc/ssh/ssh_host_rsa_key.pub" + else -> "/etc/ssh/ssh_host_key.pub" + } + + // fingerprintOf renders a stored known_hosts line the way rsh -scan and + // ssh-keygen -lf do, so the two fingerprints in the warning are comparable. + private fun fingerprintOf(line: String): String { + val parts = line.trim().split(" ") + if (parts.size < 3) return "(unreadable pin)" + return try { + val raw = Base64.decode(parts[2], Base64.DEFAULT) + val sha = MessageDigest.getInstance("SHA-256").digest(raw) + parts[1] + " SHA256:" + Base64.encodeToString(sha, Base64.NO_PADDING or Base64.NO_WRAP) + } catch (e: Exception) { + "(unreadable pin)" + } } } diff --git a/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt b/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt index c4c0536..f807288 100644 --- a/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt +++ b/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt @@ -6,6 +6,8 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.io.ByteArrayOutputStream +import java.io.InputStream import java.util.concurrent.TimeUnit // RsyncRunner builds and runs the rsync invocation for one folder, streaming @@ -13,6 +15,44 @@ import java.util.concurrent.TimeUnit // passes key, known_hosts, and port to rsh through the environment. object RsyncRunner { + // rsh prints this when it cannot reach the host at all. rsync only ever + // reports its own exit 12 for a remote shell that died, which does not say + // why, so the reason is read off rsh's merged stderr instead. + private const val UNREACHABLE = "rsh: unreachable:" + + // Outcome carries rsync's exit code plus whether the failure was the host + // being unreachable, which the caller uses to skip the rest of that remote. + // deleted is what a mirror removed on the server. A mirror is allowed to + // empty its destination, so the count is not a brake; it is how the run + // says what it did, loudly enough to be noticed the same day. + data class Outcome(val code: Int, val unreachable: Boolean, val deleted: Long = 0) + + // rsync exit 24 is "some files vanished before they could be transferred". + // For an additive push that is routine on a live phone: the camera, + // WhatsApp and the media scanner all move files while a sync runs. + // + // For a mirror it is not routine. rsync reports the same 24 when a whole + // source *directory* vanishes mid-run, and the receiver still deletes that + // directory's contents on the server, so calling 24 a success would record + // a run that destroyed remote data as "ok". + private const val EXIT_VANISHED = 24 + + fun succeeded(code: Int, mirror: Boolean): Boolean = + code == 0 || (code == EXIT_VANISHED && !mirror) + + // partialDir keeps a half-transferred file out of the destination name. + // Relative, so rsync excludes it automatically and --delete leaves it alone. + const val PARTIAL_DIR = ".rsend-partial" + + // "<operation> <path>": %o is send, recv or del. + const val OUT_FORMAT = "%o %n" + + // Transfers are counted rather than logged one line each. Deletions and + // everything else go to the log verbatim. + private const val SENT = "send " + private const val DELETED = "del. " + internal const val MAX_OUTPUT_LINE_BYTES = 16 * 1024 + // args builds the rsync argument vector for one folder. Flags are tuned for // media backup: recursive, preserve mtimes, resume partial files, and skip // the ownership and permission bits that mean nothing across Android and a @@ -22,10 +62,34 @@ object RsyncRunner { // older than 3.2.3 (notably stock macOS) reject it. rsync creates the // final component of the destination path on its own; deeper missing // parents need a one-time mkdir on the server (see README). + // + // --out-format labels every line with the operation: "del. <path>" for a + // deletion, "send <path>" for a transfer. That is what makes the log an + // audit trail for mirrors, and it costs nothing in compatibility: rsync + // forwards it to the remote as --log-format, which every rsync back to the + // 2.6 series understands. --info=del would be tidier still, but rsync + // forwards --info verbatim and remotes older than 3.1.0 reject it, the same + // trap --mkpath fell into. + // + // Labelling also keeps filenames out of column 0, where an attacker-chosen + // name could otherwise forge rsh's "unreachable" marker. runFolder counts + // the "send" lines rather than logging each one, so a first sync of a large + // library cannot flood the capped log and rotate the deletions out of it. fun args(rsh: String, remote: Remote, f: Folder): List<String> { + require(RemoteRules.hostAllowed(remote.host) && RemoteRules.userAllowed(remote.user)) { + "invalid remote endpoint" + } + require(FolderRules.localPathAllowed(f.local)) { "invalid local path" } + require(FolderRules.remotePathAllowed(f.remotePath)) { "invalid remote path" } + require(FolderRules.excludesAllowed(f.excludes)) { "invalid exclude" } val a = ArrayList<String>() a.add("-rt") - a.add("--partial") + a.add("--out-format=$OUT_FORMAT") + // Without a partial dir an interrupted transfer renames the truncated + // temp file over the destination name, destroying a complete remote + // copy. Interruption is routine here: the worker is killed when a + // constraint is lost or the execution limit is hit. + a.add("--partial-dir=$PARTIAL_DIR") // Abort rather than hang if the network stalls for 5 minutes. a.add("--timeout=300") a.add("--no-perms") @@ -35,35 +99,99 @@ object RsyncRunner { a.add("-e") a.add(rsh) for (e in f.excludes) a.add("--exclude=$e") - if (f.delete) a.add("--delete") + // --delete-after, not --delete. Deleting during the transfer removes the + // server's copy of a file before its replacement has arrived, and this + // worker is routinely killed mid-run, so a rename on the phone plus one + // interruption leaves the server with neither copy. Deleting only after + // everything is transferred keeps the old copy until the new one is + // safely there. It is as old as rsync, so no remote is excluded. + if (f.delete) a.add("--delete-after") a.add(withSlash(f.local)) - a.add("${remote.user}@${remote.host}:${withSlash(f.remotePath)}") + a.add("${remote.user}@${hostArg(remote.host)}:${withSlash(f.remotePath)}") return a } private fun withSlash(p: String): String = if (p.endsWith("/")) p else "$p/" + // An IPv6 literal must be bracketed in rsync's USER@HOST:PATH, or rsync + // splits the destination on the first colon of the address. + private fun hostArg(h: String): String = if (h.contains(":")) "[$h]" else h + // runFolder execs rsync for one folder, appending every output line to the // log, and returns rsync's exit code. Blocks the calling thread (run it on // an IO dispatcher). If the caller is cancelled (worker stopped, schedule // replaced, constraints lost) the watchdog kills rsync rather than leave it // running detached; killing it also closes its pipes, which unblocks the // log reader below. destroy() on an already-exited process is a no-op. - suspend fun runFolder(ctx: Context, remote: Remote, f: Folder, log: SyncLog): Int = coroutineScope { + suspend fun runFolder(ctx: Context, remote: Remote, f: Folder, log: SyncLog): Outcome = coroutineScope { val cmd = listOf(Native.rsync(ctx).absolutePath) + args(Native.rsh(ctx).absolutePath, remote, f) log.line("rsync ${cmd.drop(1).joinToString(" ")}") val pb = ProcessBuilder(cmd).redirectErrorStream(true) - pb.environment().putAll(Keys.env(ctx, remote.port)) + pb.environment().putAll(Keys.env(ctx, remote)) val p = pb.start() val watchdog = launch(Dispatchers.IO) { try { awaitCancellation() } finally { terminate(p) } } try { - p.inputStream.bufferedReader().forEachLine { log.line(it) } - p.waitFor() + val t = tally(p.inputStream) { log.line(it) } + if (t.sent > 0) log.line("transferred ${t.sent} item(s)") + if (t.deleted > 0) log.line("deleted ${t.deleted} item(s) on the server") + Outcome(p.waitFor(), t.unreachable, t.deleted) } finally { watchdog.cancelAndJoin() } } + // A hostile or broken remote can print an arbitrarily long line. Keep + // draining its pipe so rsync cannot deadlock, but retain only a bounded + // prefix for the log. + internal data class Tally(val sent: Long, val deleted: Long, val unreachable: Boolean) + + // tally reads rsync's merged output, counts what it did, and passes + // everything worth keeping to log. Separated from the process so it can be + // tested against real rsync output without spawning one. It streams: a + // first sync of a large library must not be buffered to be counted. + internal fun tally(input: InputStream, log: (String) -> Unit): Tally { + var sent = 0L + var deleted = 0L + var unreachable = false + boundedLines(input) { + if (it.startsWith(UNREACHABLE)) unreachable = true + if (it.startsWith(DELETED)) deleted++ + // One line per transferred file would bury the deletions and the + // errors, and on a first sync would outgrow the whole log. + if (it.startsWith(SENT)) sent++ else log(it) + } + return Tally(sent, deleted, unreachable) + } + + internal fun boundedLines(input: InputStream, emit: (String) -> Unit) { + val line = ByteArrayOutputStream() + val buf = ByteArray(4096) + var truncated = false + while (true) { + val n = input.read(buf) + if (n < 0) break + for (i in 0 until n) { + val b = buf[i].toInt() and 0xff + if (b == '\n'.code) { + emitLine(line, truncated, emit) + line.reset() + truncated = false + } else if (line.size() < MAX_OUTPUT_LINE_BYTES) { + line.write(b) + } else { + truncated = true + } + } + } + if (line.size() > 0 || truncated) emitLine(line, truncated, emit) + } + + private fun emitLine(line: ByteArrayOutputStream, truncated: Boolean, emit: (String) -> Unit) { + val value = String(line.toByteArray(), Charsets.UTF_8).removeSuffix("\r") + + if (truncated) " [truncated]" else "" + emit(value) + } + private fun terminate(p: Process) { p.destroy() if (!p.waitFor(5, TimeUnit.SECONDS)) { diff --git a/app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt b/app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt index 31ec6b4..b3c3060 100644 --- a/app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt @@ -3,17 +3,18 @@ package invalid.lena.rsend import android.os.Bundle import android.widget.Button import android.widget.EditText -import android.widget.Switch +import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SwitchCompat // ScheduleActivity edits the periodic sync settings and (re)applies them to // WorkManager on save. class ScheduleActivity : AppCompatActivity() { - private lateinit var enabled: Switch + private lateinit var enabled: SwitchCompat private lateinit var interval: EditText - private lateinit var wifiOnly: Switch - private lateinit var charging: Switch + private lateinit var wifiOnly: SwitchCompat + private lateinit var charging: SwitchCompat override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -32,19 +33,28 @@ class ScheduleActivity : AppCompatActivity() { wifiOnly.isChecked = s.wifiOnly charging.isChecked = s.requireCharging - findViewById<Button>(R.id.save).setOnClickListener { save(); finish() } + findViewById<Button>(R.id.save).setOnClickListener { if (save()) finish() } } - private fun save() { + private fun save(): Boolean { + val minutes = interval.text.toString().toIntOrNull() + if (minutes == null || minutes < Schedule.MIN_INTERVAL_MINUTES) { + Toast.makeText( + this, + "Interval must be at least ${Schedule.MIN_INTERVAL_MINUTES} minutes.", + Toast.LENGTH_LONG, + ).show() + return false + } val cfg = Config.load(this) val s = Schedule( enabled = enabled.isChecked, - intervalMinutes = interval.text.toString().toIntOrNull() - ?.coerceAtLeast(Schedule.MIN_INTERVAL_MINUTES) ?: 120, + intervalMinutes = minutes, wifiOnly = wifiOnly.isChecked, requireCharging = charging.isChecked, ) Config.save(this, cfg.copy(schedule = s)) Scheduler.apply(this) + return true } } diff --git a/app/src/main/java/invalid/lena/rsend/Scheduler.kt b/app/src/main/java/invalid/lena/rsend/Scheduler.kt index 4b0e687..c35fb42 100644 --- a/app/src/main/java/invalid/lena/rsend/Scheduler.kt +++ b/app/src/main/java/invalid/lena/rsend/Scheduler.kt @@ -11,15 +11,21 @@ import java.util.concurrent.TimeUnit // Scheduler keeps one periodic sync request aligned with the saved config. object Scheduler { - // Versioned so it can never collide with the retired one-time chain's - // unique work name. + // Keep this distinct from the retired pre-0.1.3 one-time chain. WorkManager + // persists unique names across app upgrades, so changing it creates a + // second schedule and leaves the first one invisible to the UI. const val NAME = "periodic-sync-v2" + private const val RETIRED_NAME = "periodic-sync" fun apply(ctx: Context) { val cfg = Config.load(ctx) val s = cfg.schedule val wm = WorkManager.getInstance(ctx) + // Idempotently retire both the old one-time chain and any periodic job + // created by the unreleased name regression. + wm.cancelUniqueWork(RETIRED_NAME) + if (!s.enabled || !cfg.syncReady(Keys.exists(ctx))) { wm.cancelUniqueWork(NAME) return diff --git a/app/src/main/java/invalid/lena/rsend/SyncLog.kt b/app/src/main/java/invalid/lena/rsend/SyncLog.kt index 44c4d46..f0db905 100644 --- a/app/src/main/java/invalid/lena/rsend/SyncLog.kt +++ b/app/src/main/java/invalid/lena/rsend/SyncLog.kt @@ -2,6 +2,8 @@ package invalid.lena.rsend import android.content.Context import java.io.File +import java.io.FileOutputStream +import java.io.RandomAccessFile import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -11,16 +13,112 @@ import java.util.Locale class SyncLog(ctx: Context) { val file: File = File(ctx.filesDir, "sync.log") + private val previous: File = File(ctx.filesDir, "sync.log.1") private val fmt = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US) fun line(s: String) { - file.appendText("${fmt.format(Date())} $s\n") + synchronized(lock) { + val clean = cleanLine(s) + val entry = "${fmt.format(Date())} $clean\n".toByteArray(Charsets.UTF_8) + if (file.exists() && file.length() + entry.size > MAX_BYTES) rotate() + FileOutputStream(file, true).use { it.write(entry) } + } } - // Truncate once the log grows past the cap so it cannot fill storage. + // Called once at the start of a run; line() also enforces the cap as the + // run goes, so a single long run cannot grow without bound. fun rotateIfBig() { - if (file.exists() && file.length() > 512 * 1024) file.writeText("") + synchronized(lock) { + if (file.exists() && file.length() >= MAX_BYTES) rotate() + } } - fun text(): String = if (file.exists()) file.readText() else "" + // Roll over rather than truncate. Truncating destroyed every earlier run, + // and a run that outgrew the cap destroyed its own output, including the + // record of what it had just deleted on the server. + private fun rotate() { + if (previous.exists() && !previous.delete()) { + throw IllegalStateException("could not remove ${previous.name}") + } + if (file.exists() && !file.renameTo(previous)) { + throw IllegalStateException("could not rotate ${file.name}") + } + } + + // Both halves, oldest first. Showing only the current file would hide + // everything a rotation moved aside, which is where a long run's earliest + // and most interesting lines end up. + fun text(): String { + synchronized(lock) { + val current = tail(file, DISPLAY_BYTES) + val left = DISPLAY_BYTES - current.bytes.size + val old = if (left > 0) tail(previous, left) else Tail(ByteArray(0), previous.exists()) + val omitted = current.omitted || old.omitted + val bytes = old.bytes + current.bytes + val text = String(skipUtf8Continuation(bytes), Charsets.UTF_8) + return if (omitted) "[older log omitted]\n$text" else text + } + } + + // Clear both halves, or Clear would leave the rotated one on screen. + fun clear() { + synchronized(lock) { + if (previous.exists() && !previous.delete()) { + throw IllegalStateException("could not remove ${previous.name}") + } + FileOutputStream(file, false).use { } + } + } + + companion object { + private const val MAX_BYTES = 512L * 1024 + private const val DISPLAY_BYTES = 64 * 1024 + private const val MAX_LINE_CHARS = 4_000 + private val lock = Any() + + internal fun cleanLine(value: String): String = buildString(minOf(value.length, MAX_LINE_CHARS + 20)) { + for (c in value) { + when (c) { + '\n' -> append("\\n") + '\r' -> append("\\r") + else -> if (unsafeDisplayCharacter(c)) { + append("\\u") + append(c.code.toString(16).padStart(4, '0')) + } else { + append(c) + } + } + if (length >= MAX_LINE_CHARS) { + append(" [truncated]") + break + } + } + } + + private fun unsafeDisplayCharacter(c: Char): Boolean { + val type = Character.getType(c) + return c.isISOControl() || type == Character.FORMAT.toInt() || + type == Character.LINE_SEPARATOR.toInt() || type == Character.PARAGRAPH_SEPARATOR.toInt() + } + } + + private data class Tail(val bytes: ByteArray, val omitted: Boolean) + + private fun tail(source: File, limit: Int): Tail { + if (!source.exists() || limit <= 0) return Tail(ByteArray(0), source.exists() && source.length() > 0) + val length = source.length() + val count = minOf(length, limit.toLong()).toInt() + val bytes = ByteArray(count) + RandomAccessFile(source, "r").use { + it.seek(length - count) + it.readFully(bytes) + } + return Tail(bytes, length > count) + } + + private fun skipUtf8Continuation(bytes: ByteArray): ByteArray { + var first = 0 + while (first < bytes.size && bytes[first].toInt() and 0xc0 == 0x80) first++ + return if (first == 0) bytes else bytes.copyOfRange(first, bytes.size) + } } diff --git a/app/src/main/java/invalid/lena/rsend/SyncWorker.kt b/app/src/main/java/invalid/lena/rsend/SyncWorker.kt index b8c9a9d..6bc2b2c 100644 --- a/app/src/main/java/invalid/lena/rsend/SyncWorker.kt +++ b/app/src/main/java/invalid/lena/rsend/SyncWorker.kt @@ -3,7 +3,8 @@ package invalid.lena.rsend import android.app.Notification import android.content.Context import android.content.pm.ServiceInfo -import android.os.Build +import android.net.ConnectivityManager +import android.os.Environment import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.work.CoroutineWorker @@ -30,69 +31,123 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, } override suspend fun doWork(): Result = syncMutex.withLock { - withContext(Dispatchers.IO) { - val ctx = applicationContext - val cfg = Config.load(ctx) - if (!cfg.syncReady(Keys.exists(ctx))) { - SyncLog(ctx).line("sync skipped: key, folders, or a pinned remote missing") - return@withContext Result.failure() - } - - val log = SyncLog(ctx) - log.rotateIfBig() - log.line("sync started (${cfg.folders.size} folders)") - // known_hosts is derived from the config's pins; regenerate it here, - // under the sync mutex, before the first rsync reads it. - Keys.writeKnownHosts(ctx, cfg.remotes.map { it.hostKey }.filter { it.isNotEmpty() }) - // A foreground service lets long syncs survive, but Android 12+ forbids - // starting one when the periodic job fires in the background. Try it, and - // fall back to a plain background job when it is not allowed. - try { - setForeground(foregroundInfo()) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - log.line("foreground unavailable, running in background: ${e.javaClass.simpleName}: ${e.message}") - } + val ctx = applicationContext + try { + withContext(Dispatchers.IO) { + val cfg = Config.load(ctx) + // A refused run must be as visible as a failed one. These return + // before the loop that would otherwise record the outcome, so + // without this the dashboard keeps showing the last success while + // nothing has been backed up since. + if (!cfg.syncReady(Keys.exists(ctx))) { + return@withContext refuse(ctx, "key, folders, or a pinned remote missing") + } + // All-files access is the only storage permission rsend declares. + // Without it configured storage can be unreadable. Refuse the run + // before rsync sees a partial view of the source tree. + if (!Environment.isExternalStorageManager()) { + return@withContext refuse(ctx, "all-files access not granted") + } - var ok = true - val total = cfg.folders.size - cfg.folders.forEachIndexed { i, f -> - val label = f.name.ifEmpty { f.local } - // Publish progress the dashboard observes (current folder, i of n). - setProgress(workDataOf(KEY_FOLDER to label, KEY_INDEX to i + 1, KEY_TOTAL to total)) - log.line("=== $label -> ${f.remoteName} ===") - val remote = cfg.remote(f.remoteName) - // A broken folder fails alone; the others still sync. - // Guard against a hand-edited config: an empty path would rsync /. - val code = if (f.local.isEmpty() || f.remotePath.isEmpty()) { - log.line("error: local or remote path not set") - 1 - } else if (remote == null) { - log.line("error: remote \"${f.remoteName}\" not found") - 1 - } else if (remote.host.isEmpty() || remote.user.isEmpty()) { - log.line("error: remote \"${f.remoteName}\" has no host or user") - 1 - } else if (!remote.pinned()) { - log.line("error: remote \"${f.remoteName}\" not pinned, run Test connection") - 1 - } else try { - RsyncRunner.runFolder(ctx, remote, f, log) + val log = SyncLog(ctx) + log.rotateIfBig() + log.line("sync started (${cfg.folders.size} folders)") + // A foreground service lets long syncs survive, but Android 12+ forbids + // starting one when the periodic job fires in the background. Try it, and + // fall back to a plain background job when it is not allowed. + try { + setForeground(foregroundInfo()) } catch (e: CancellationException) { - log.line("cancelled") throw e } catch (e: Exception) { - log.line("error: ${e.javaClass.simpleName}: ${e.message}") - 1 + // Worth shouting about: a background job is subject to the + // execution limit, so a large first sync can be cut short every + // period. rsync resumes, but the user should know why progress + // is slow and that the battery exemption fixes it. + log.line("WARNING: foreground service refused (${e.javaClass.simpleName}: ${e.message})") + log.line("WARNING: running as a background job, which the system may stop early;") + log.line("WARNING: grant Battery > unrestricted so long syncs can finish") } - log.line("exit=$code") - if (code != 0) ok = false + + var ok = true + val total = cfg.folders.size + // Folder labels that failed, so the dashboard identifies the work + // that did not complete instead of only saying the run failed. + val failed = LinkedHashSet<String>() + // Remotes that turned out to be unreachable during this run. A + // dropped connect costs the full dial timeout, so pay it once per + // remote instead of once per folder pointing at it. + val unreachable = HashSet<String>() + // Total removed on the server across the run. + var deleted = 0L + for ((i, f) in cfg.folders.withIndex()) { + val label = f.name.ifEmpty { f.local } + // Re-check per folder: a run that began on wifi can lose it and + // would otherwise push the rest of a photo library over mobile + // data. Stop rather than carry on against the user's setting. + if (cfg.schedule.wifiOnly && metered(ctx)) { + log.line("stopping: network became metered and the schedule is unmetered only") + ok = false + failed.add("metered network") + break + } + // Publish progress the dashboard observes (current folder, i of n). + setProgress(workDataOf(KEY_FOLDER to label.take(256), KEY_INDEX to i + 1, KEY_TOTAL to total)) + log.line("=== $label -> ${f.remoteName} ===") + // Config parsing guarantees the remote exists; being pinned is + // a separate thing the user can still be part-way through. + val remote = checkNotNull(cfg.remote(f.remoteName)) + val code = if (!remote.pinned()) { + log.line("error: remote \"${f.remoteName}\" not pinned, run Test connection") + 1 + } else if (unreachable.contains(f.remoteName)) { + log.line("error: remote \"${f.remoteName}\" was unreachable earlier this run, skipped") + 1 + } else if (!FolderRules.sourceReadable(f.local)) { + log.line("error: local source \"${f.local}\" is not a readable directory") + 1 + } else try { + val r = RsyncRunner.runFolder(ctx, remote, f, log) + if (r.unreachable) unreachable.add(f.remoteName) + deleted += r.deleted + r.code + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.line("error: ${e.javaClass.simpleName}: ${e.message}") + 1 + } + log.line("exit=$code") + if (!RsyncRunner.succeeded(code, f.delete)) { + ok = false + failed.add(label) + } + } + log.line("sync finished ok=$ok") + // A mirror may legitimately empty its destination, so deletions are + // reported rather than prevented. Saying so on the dashboard, and + // notifying when a run deleted more than it sent, is what turns a + // surprising mass deletion into something noticed the same day. + val summary = if (deleted > 0) " ($deleted deleted)" else "" + LastSync.set(ctx, if (ok) "ok$summary" else "FAILED: ${failed.joinToString(", ")}$summary") + if (!ok) { + notifyError(ctx) + } else if (deleted > 0) { + notifyDeleted(ctx, deleted) + } + if (ok) Result.success() else Result.failure() } - log.line("sync finished ok=$ok") - LastSync.set(ctx, ok) - if (!ok) notifyError(ctx) - if (ok) Result.success() else Result.failure() + } catch (e: CancellationException) { + // Cancellation can arrive at any suspension point, including + // between folders or while publishing progress. Record it once at + // the worker boundary instead of only around the rsync process. + try { + SyncLog(ctx).line("cancelled") + LastSync.set(ctx, "interrupted") + } catch (recordError: Exception) { + e.addSuppressed(recordError) + } + throw e } } @@ -110,6 +165,40 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, } } + // notifyDeleted reports a successful mirror that removed files on the + // server. Mirror semantics make that correct, but a user who did not mean + // to empty a folder should hear about it while the local copy may still + // exist, not months later. + private fun notifyDeleted(ctx: Context, deleted: Long) { + val n = NotificationCompat.Builder(ctx, App.CHANNEL) + .setContentTitle("rsend") + .setContentText("Sync ok. Mirror deleted $deleted item(s) on the server.") + .setSmallIcon(R.drawable.ic_notification) + .setAutoCancel(true) + .build() + try { + NotificationManagerCompat.from(ctx).notify(3, n) + } catch (_: SecurityException) { + // POST_NOTIFICATIONS not granted; the log still records the count. + } + } + + // refuse records a run that never started, so the dashboard and the + // notification say so instead of leaving the last success on screen. + private fun refuse(ctx: Context, why: String): Result { + SyncLog(ctx).line("sync skipped: $why") + LastSync.set(ctx, "SKIPPED: $why") + notifyError(ctx) + return Result.failure() + } + + // metered reports whether the current network costs money, so a run can + // stop when the user asked for unmetered only. The periodic job has a + // WorkManager constraint, but a manual run has none by design, and either + // can lose wifi part way through a long sync. + private fun metered(ctx: Context): Boolean = + ctx.getSystemService(ConnectivityManager::class.java).isActiveNetworkMetered + private fun foregroundInfo(): ForegroundInfo { val n: Notification = NotificationCompat.Builder(applicationContext, App.CHANNEL) .setContentTitle("rsend") @@ -117,10 +206,6 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, .setSmallIcon(R.drawable.ic_notification) .setOngoing(true) .build() - return if (Build.VERSION.SDK_INT >= 29) { - ForegroundInfo(1, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) - } else { - ForegroundInfo(1, n) - } + return ForegroundInfo(1, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) } } diff --git a/app/src/main/res/layout/activity_folder.xml b/app/src/main/res/layout/activity_folder.xml index a67af55..40ad432 100644 --- a/app/src/main/res/layout/activity_folder.xml +++ b/app/src/main/res/layout/activity_folder.xml @@ -36,6 +36,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" + android:importantForAutofill="no" android:inputType="text" android:hint="DCIM" /> @@ -59,6 +60,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" + android:importantForAutofill="no" android:inputType="textUri" android:hint="/storage/emulated/0/DCIM" /> @@ -97,6 +99,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" + android:importantForAutofill="no" android:inputType="textUri" android:hint="DCIM" /> @@ -113,10 +116,11 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" + android:importantForAutofill="no" android:inputType="text" android:hint=".thumbnails/, .trashed/" /> - <Switch + <androidx.appcompat.widget.SwitchCompat android:id="@+id/delete" android:layout_width="match_parent" android:layout_height="wrap_content" diff --git a/app/src/main/res/layout/activity_log.xml b/app/src/main/res/layout/activity_log.xml index af15ec2..a44f657 100644 --- a/app/src/main/res/layout/activity_log.xml +++ b/app/src/main/res/layout/activity_log.xml @@ -7,50 +7,52 @@ <include layout="@layout/app_toolbar" /> - <LinearLayout + <RelativeLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" - android:orientation="vertical" android:padding="@dimen/space_m"> - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="horizontal" - android:layout_marginBottom="@dimen/space_m"> - - <Button - android:id="@+id/refresh" - style="@style/Widget.Rsend.Button.Secondary" - android:layout_width="0dp" + <LinearLayout + android:id="@+id/logActions" + android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_weight="1" - android:layout_marginEnd="@dimen/space_s" - android:text="Refresh" /> + android:layout_alignParentTop="true" + android:layout_marginBottom="@dimen/space_m" + android:orientation="horizontal"> - <Button - android:id="@+id/clear" - style="@style/Widget.Rsend.Button.Secondary" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_weight="1" - android:text="Clear" /> - </LinearLayout> + <Button + android:id="@+id/refresh" + style="@style/Widget.Rsend.Button.Secondary" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginEnd="@dimen/space_s" + android:layout_weight="1" + android:text="Refresh" /> - <ScrollView - style="@style/Widget.Rsend.Card" - android:layout_width="match_parent" - android:layout_height="0dp" - android:layout_weight="1"> + <Button + android:id="@+id/clear" + style="@style/Widget.Rsend.Button.Secondary" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Clear" /> + </LinearLayout> - <TextView - android:id="@+id/log" + <ScrollView + style="@style/Widget.Rsend.Card" android:layout_width="match_parent" - android:layout_height="wrap_content" - android:textAppearance="@style/TextAppearance.Rsend.Mono" - android:textSize="11sp" - android:textIsSelectable="true" /> - </ScrollView> - </LinearLayout> + android:layout_height="match_parent" + android:layout_alignParentBottom="true" + android:layout_below="@id/logActions"> + + <TextView + android:id="@+id/log" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:textAppearance="@style/TextAppearance.Rsend.Mono" + android:textIsSelectable="true" + android:textSize="11sp" /> + </ScrollView> + </RelativeLayout> </LinearLayout> diff --git a/app/src/main/res/layout/activity_picker.xml b/app/src/main/res/layout/activity_picker.xml index db4a044..39d8d94 100644 --- a/app/src/main/res/layout/activity_picker.xml +++ b/app/src/main/res/layout/activity_picker.xml @@ -7,11 +7,10 @@ <include layout="@layout/app_toolbar" /> - <LinearLayout + <RelativeLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" - android:orientation="vertical" android:padding="@dimen/space_m"> <TextView @@ -19,43 +18,47 @@ style="@style/Widget.Rsend.Card" android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_alignParentTop="true" android:textAppearance="@style/TextAppearance.Rsend.Mono" android:textSize="12sp" /> - <ScrollView - android:layout_width="match_parent" - android:layout_height="0dp" - android:layout_weight="1"> - - <LinearLayout - android:id="@+id/list" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical" /> - </ScrollView> - <LinearLayout + android:id="@+id/pickerActions" android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="horizontal" - android:layout_marginTop="@dimen/space_m"> + android:layout_alignParentBottom="true" + android:layout_marginTop="@dimen/space_m" + android:orientation="horizontal"> <Button - android:id="@+id/use" - style="@style/Widget.Rsend.Button.Primary" + android:id="@+id/cancel" + style="@style/Widget.Rsend.Button.Secondary" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_weight="1" android:layout_marginEnd="@dimen/space_s" - android:text="Use this folder" /> + android:layout_weight="1" + android:text="Cancel" /> <Button - android:id="@+id/cancel" - style="@style/Widget.Rsend.Button.Secondary" + android:id="@+id/use" + style="@style/Widget.Rsend.Button.Primary" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" - android:text="Cancel" /> + android:text="Use this folder" /> </LinearLayout> - </LinearLayout> + + <ScrollView + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_above="@id/pickerActions" + android:layout_below="@id/currentPath"> + + <LinearLayout + android:id="@+id/list" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" /> + </ScrollView> + </RelativeLayout> </LinearLayout> diff --git a/app/src/main/res/layout/activity_remote.xml b/app/src/main/res/layout/activity_remote.xml index ab7690c..fa89385 100644 --- a/app/src/main/res/layout/activity_remote.xml +++ b/app/src/main/res/layout/activity_remote.xml @@ -36,6 +36,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" + android:importantForAutofill="no" android:inputType="text" android:hint="nas" /> @@ -52,6 +53,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" + android:importantForAutofill="no" android:inputType="textUri" android:hint="home.example.org" /> @@ -68,6 +70,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" + android:importantForAutofill="no" android:inputType="number" android:hint="22" /> @@ -84,6 +87,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" + android:importantForAutofill="no" android:inputType="text" android:hint="backup" /> </LinearLayout> diff --git a/app/src/main/res/layout/activity_schedule.xml b/app/src/main/res/layout/activity_schedule.xml index d06ef1b..a02edd3 100644 --- a/app/src/main/res/layout/activity_schedule.xml +++ b/app/src/main/res/layout/activity_schedule.xml @@ -24,7 +24,7 @@ android:layout_height="wrap_content" android:orientation="vertical"> - <Switch + <androidx.appcompat.widget.SwitchCompat android:id="@+id/enabled" android:layout_width="match_parent" android:layout_height="wrap_content" @@ -44,10 +44,11 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" + android:importantForAutofill="no" android:inputType="number" android:hint="120" /> - <Switch + <androidx.appcompat.widget.SwitchCompat android:id="@+id/wifiOnly" android:layout_width="match_parent" android:layout_height="wrap_content" @@ -55,7 +56,7 @@ android:textAppearance="@style/TextAppearance.Rsend.Body" android:text="Unmetered network only" /> - <Switch + <androidx.appcompat.widget.SwitchCompat android:id="@+id/charging" android:layout_width="match_parent" android:layout_height="wrap_content" diff --git a/app/src/main/res/layout/app_toolbar.xml b/app/src/main/res/layout/app_toolbar.xml index 12eeb47..a6c18b8 100644 --- a/app/src/main/res/layout/app_toolbar.xml +++ b/app/src/main/res/layout/app_toolbar.xml @@ -3,9 +3,11 @@ Toolbar; the dark overlay gives the title light text on the dark bar. --> <androidx.appcompat.widget.Toolbar xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:background="@color/bar" android:elevation="4dp" - android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar" /> + android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar" + tools:ignore="Overdraw" /> diff --git a/app/src/main/res/layout/item_dir.xml b/app/src/main/res/layout/item_dir.xml index 651224c..4af38e3 100644 --- a/app/src/main/res/layout/item_dir.xml +++ b/app/src/main/res/layout/item_dir.xml @@ -1,27 +1,16 @@ <?xml version="1.0" encoding="utf-8"?> -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" +<TextView xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/dirName" style="@style/Widget.Rsend.Card" android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="horizontal" - android:gravity="center_vertical" android:clickable="true" + android:drawablePadding="@dimen/space_m" + android:ellipsize="end" android:focusable="true" - android:foreground="?attr/selectableItemBackground"> - - <ImageView - android:layout_width="24dp" - android:layout_height="24dp" - android:layout_marginEnd="@dimen/space_m" - android:src="@drawable/ic_folder" - android:contentDescription="@null" /> - - <TextView - android:id="@+id/dirName" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_weight="1" - android:maxLines="1" - android:ellipsize="end" - android:textAppearance="@style/TextAppearance.Rsend.Title" /> -</LinearLayout> + android:foreground="?attr/selectableItemBackground" + android:gravity="center_vertical" + android:maxLines="1" + android:textAppearance="@style/TextAppearance.Rsend.Title" + app:drawableStartCompat="@drawable/ic_folder" /> diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml index b3e26b4..b3e26b4 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..b853a8d --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<data-extraction-rules> + <cloud-backup> + <exclude domain="root" path="." /> + <exclude domain="file" path="." /> + <exclude domain="database" path="." /> + <exclude domain="sharedpref" path="." /> + <exclude domain="external" path="." /> + <exclude domain="device_root" path="." /> + <exclude domain="device_file" path="." /> + <exclude domain="device_database" path="." /> + <exclude domain="device_sharedpref" path="." /> + </cloud-backup> + <device-transfer> + <exclude domain="root" path="." /> + <exclude domain="file" path="." /> + <exclude domain="database" path="." /> + <exclude domain="sharedpref" path="." /> + <exclude domain="external" path="." /> + <exclude domain="device_root" path="." /> + <exclude domain="device_file" path="." /> + <exclude domain="device_database" path="." /> + <exclude domain="device_sharedpref" path="." /> + </device-transfer> +</data-extraction-rules> diff --git a/app/src/main/res/xml/backup_rules_legacy.xml b/app/src/main/res/xml/backup_rules_legacy.xml new file mode 100644 index 0000000..49fc980 --- /dev/null +++ b/app/src/main/res/xml/backup_rules_legacy.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<full-backup-content> + <exclude domain="root" path="." /> + <exclude domain="file" path="." /> + <exclude domain="database" path="." /> + <exclude domain="sharedpref" path="." /> + <exclude domain="external" path="." /> + <exclude domain="device_root" path="." /> + <exclude domain="device_file" path="." /> + <exclude domain="device_database" path="." /> + <exclude domain="device_sharedpref" path="." /> +</full-backup-content> diff --git a/app/src/test/java/invalid/lena/rsend/ConfigTest.kt b/app/src/test/java/invalid/lena/rsend/ConfigTest.kt index 3449a8c..6b6dfdc 100644 --- a/app/src/test/java/invalid/lena/rsend/ConfigTest.kt +++ b/app/src/test/java/invalid/lena/rsend/ConfigTest.kt @@ -5,14 +5,147 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import java.io.File +import java.io.ByteArrayInputStream +import java.nio.file.Files class ConfigTest { + // Exactly what 0.1.3 wrote: one "remote" object, and each folder carrying + // its destination in a "remote" field. 0.1.3 is the last published + // release, so this is what every upgrading user has on disk. + private fun legacyJson(host: String = "home.example.net"): JSONObject = + JSONObject( + """ + { + "remote": {"host": "$host", "port": 2222, "user": "backup"}, + "schedule": {"enabled": true, "intervalMinutes": 120, + "wifiOnly": true, "requireCharging": false}, + "folders": [ + {"name": "DCIM", "local": "/storage/emulated/0/DCIM", + "remote": "phone/DCIM", "delete": false, "excludes": [".thumbnails/"]} + ] + } + """.trimIndent() + ) + + private val legacyPin = + "[home.example.net]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBIbNeHUrulCimZ9t3gfTxVGB067yehRny9XwwL24o8i" + + @Test + fun legacyShapeIsRecognised() { + assertTrue(Config.legacyShape(legacyJson())) + // A current config, and an empty one, are not legacy. + assertFalse(Config.legacyShape(Config().toJson())) + assertFalse(Config.legacyShape(JSONObject("""{"remotes": [], "schedule": {}, "folders": []}"""))) + } + + // The upgrade must produce something the strict parser accepts unchanged: + // that is the whole point of converting before validating rather than + // teaching the parser a second shape. + @Test + fun legacyConfigUpgradesAndStillValidates() { + val c = Config.fromJson(Config.upgradeLegacy(legacyJson(), legacyPin)) + + assertEquals(1, c.remotes.size) + val r = c.remotes[0] + assertEquals("home.example.net", r.name) + assertEquals("home.example.net", r.host) + assertEquals(2222, r.port) + assertEquals("backup", r.user) + assertEquals(legacyPin, r.hostKey) + + assertEquals(1, c.folders.size) + val f = c.folders[0] + assertEquals("/storage/emulated/0/DCIM", f.local) + // The old "remote" field was the destination path, not a remote name. + assertEquals("phone/DCIM", f.remotePath) + assertEquals("home.example.net", f.remoteName) + assertEquals(listOf(".thumbnails/"), f.excludes) + assertTrue(c.schedule.enabled) + assertEquals(120, c.schedule.intervalMinutes) + } + + @Test + fun legacyScheduleBelowWorkManagerMinimumIsClamped() { + val json = legacyJson() + json.getJSONObject("schedule").put("intervalMinutes", 5) + + val c = Config.fromJson(Config.upgradeLegacy(json, legacyPin)) + + assertEquals(Schedule.MIN_INTERVAL_MINUTES, c.schedule.intervalMinutes) + } + + @Test + fun legacyUpgradeKeepsSafeMappingsAndOmitsUnsafeOnes() { + val json = legacyJson() + val folders = json.getJSONArray("folders") + folders.put( + JSONObject() + .put("name", "unsafe") + .put("local", "/storage/emulated/0") + .put("remote", "..") + .put("delete", true) + .put("excludes", org.json.JSONArray()), + ) + + val c = Config.fromJson(Config.upgradeLegacy(json, legacyPin)) + + assertEquals(listOf("DCIM"), c.folders.map(Folder::name)) + } + + @Test + fun unfinishedLegacySetupStillMigratesToAValidConfig() { + val json = legacyJson(host = "") + json.getJSONObject("remote").put("user", "") + + val c = Config.fromJson(Config.upgradeLegacy(json, "")) + + assertTrue(c.remotes.isEmpty()) + assertTrue(c.folders.isEmpty()) + assertTrue(c.schedule.enabled) + } + + @Test + fun legacyBracketedIpv6HostIsNormalised() { + val json = legacyJson(host = "[2001:db8::1]") + + val c = Config.fromJson(Config.upgradeLegacy(json, "")) + + assertEquals("2001:db8::1", c.remotes.single().host) + assertEquals("2001:db8::1", c.folders.single().remoteName) + } + + // Losing one stale pin costs a Test connection. Rejecting the config would + // cost every remote, folder and pin the user had. + @Test + fun aPinThatNoLongerMatchesIsDroppedNotFatal() { + val wrongHost = "[other.example.net]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBIbNeHUrulCimZ9t3gfTxVGB067yehRny9XwwL24o8i" + val c = Config.fromJson(Config.upgradeLegacy(legacyJson(), wrongHost)) + assertEquals("", c.remotes[0].hostKey) + assertEquals(1, c.folders.size) + } + + @Test + fun legacyConfigWithNoPinFileStillUpgrades() { + val c = Config.fromJson(Config.upgradeLegacy(legacyJson(), "")) + assertEquals("", c.remotes[0].hostKey) + assertEquals("home.example.net", c.folders[0].remoteName) + } + + // Once rewritten, toJson always emits "remotes", so the migration can never + // run a second time on the same file. + @Test + fun migratingTwiceIsNotPossible() { + val migrated = Config.fromJson(Config.upgradeLegacy(legacyJson(), legacyPin)) + assertFalse(Config.legacyShape(migrated.toJson())) + } + @Test fun roundTrip() { val c = Config( remotes = listOf( - Remote("nas", "home", 2222, "backup", "home ssh-ed25519 AAAA"), + Remote("nas", "home", 2222, "backup", "[home]:2222 ssh-ed25519 AAAA"), Remote("reader", "kobo.lan", 22, "sync"), ), schedule = Schedule(enabled = true, intervalMinutes = 30, wifiOnly = false, requireCharging = true), @@ -25,113 +158,216 @@ class ConfigTest { } @Test - fun defaultsForEmptyObject() { - assertEquals(Config(), Config.fromJson(JSONObject("{}"))) + fun currentConfigRequiresEveryField() { + try { + Config.fromJson(JSONObject("{}")) + throw AssertionError("empty config accepted") + } catch (_: IllegalArgumentException) { + } } @Test - fun scheduleUsesWorkManagerMinimum() { - val json = JSONObject("""{"schedule":{"intervalMinutes":1}}""") - assertEquals(Schedule.MIN_INTERVAL_MINUTES, Config.fromJson(json).schedule.intervalMinutes) + fun scheduleBelowWorkManagerMinimumIsRejected() { + val json = Config().toJson() + json.getJSONObject("schedule").put("intervalMinutes", 1) + try { + Config.fromJson(json) + throw AssertionError("invalid interval accepted") + } catch (_: IllegalArgumentException) { + } } @Test - fun readsLegacySingleRemoteShape() { - val json = JSONObject( - """ - { - "remote": {"host": "home", "port": 2222, "user": "backup"}, - "folders": [ - {"name": "DCIM", "local": "/d", "remote": "/b/DCIM", "delete": true, "excludes": [".x/"]} - ] - } - """ + fun syncReadyNeedsKeyFolderAndPinnedRemote() { + val pinned = Remote("nas", "home", 22, "backup", "line") + val folder = Folder("d", "/d", "nas", "/b") + assertTrue(Config(remotes = listOf(pinned), folders = listOf(folder)).syncReady(keyExists = true)) + assertFalse(Config(remotes = listOf(pinned), folders = listOf(folder)).syncReady(keyExists = false)) + assertFalse(Config(remotes = listOf(pinned)).syncReady(keyExists = true)) + assertFalse( + Config(remotes = listOf(pinned.copy(hostKey = "")), folders = listOf(folder)) + .syncReady(keyExists = true) ) - val c = Config.fromJson(json, legacyPin = "home ssh-ed25519 AAAA") - assertEquals(listOf(Remote("home", "home", 2222, "backup", "home ssh-ed25519 AAAA")), c.remotes) - assertEquals( - listOf(Folder("DCIM", "/d", "home", "/b/DCIM", true, listOf(".x/"))), - c.folders, + // A remote added but not yet pinned must not stop the folders that + // already work: the run proceeds, and the half-configured folder fails + // on its own and is named in the log. + assertTrue( + Config( + remotes = listOf(pinned, pinned.copy(name = "new", hostKey = "")), + folders = listOf(folder, folder.copy(remoteName = "new")), + ).syncReady(keyExists = true) ) } @Test - fun legacyShapeWithoutHostStaysEmpty() { - val json = JSONObject("""{"remote": {"host": "", "port": 22, "user": ""}}""") - assertEquals(emptyList<Remote>(), Config.fromJson(json, legacyPin = "unused").remotes) + fun emptyDirectoryIsAReadableSource() { + val dir = Files.createTempDirectory("rsend-empty").toFile() + val link = File(dir.parentFile, "${dir.name}-link") + try { + assertTrue(FolderRules.sourceReadable(dir.absolutePath)) + assertFalse(FolderRules.sourceReadable(File(dir, "missing").absolutePath)) + assertFalse(FolderRules.sourceReadable(File.separator)) + assertFalse(FolderRules.sourceReadable("relative")) + Files.createSymbolicLink(link.toPath(), dir.toPath()) + assertFalse(FolderRules.sourceReadable(link.absolutePath)) + } finally { + link.delete() + assertTrue(dir.delete()) + } } - // A 0.1.x config always wrote a "remote" object, even before the host was - // filled in. Such a config still has folders with real destination paths; - // migrating must keep them rather than drop them on the floor. @Test - fun legacyShapeWithoutHostKeepsFolderPaths() { - val json = JSONObject( - """ - { - "remote": {"host": "", "port": 22, "user": ""}, - "folders": [ - {"name": "DCIM", "local": "/d", "remote": "/b/DCIM", "delete": false, "excludes": []} - ] - } - """ - ) - val c = Config.fromJson(json) - assertEquals(listOf(Folder("DCIM", "/d", "", "/b/DCIM", false, emptyList())), c.folders) + fun remotePathConflictIsConservative() { + assertTrue(FolderRules.remotePathsConflict("backup", "backup/DCIM")) + assertTrue(FolderRules.remotePathsConflict("~/backup", "backup/DCIM")) + assertTrue(FolderRules.remotePathsConflict("/srv", "/srv/backup")) + assertTrue(FolderRules.remotePathsConflict("Photos", "photos/2026")) + assertTrue(FolderRules.remotePathsConflict("/backup", "backup")) + assertFalse(FolderRules.remotePathsConflict("backup-a", "backup-b")) } - // The migration must run exactly once. load() writes the converted config - // back, and the written shape must no longer look legacy, or a later read - // would migrate an already-migrated config and re-apply the legacy field - // mapping to fields that no longer carry those meanings. @Test - fun migratingTwiceIsNotPossible() { - val legacy = JSONObject( - """ - { - "remote": {"host": "home", "port": 2222, "user": "backup"}, - "folders": [{"name": "DCIM", "local": "/d", "remote": "/b/DCIM", "delete": true, "excludes": []}] - } - """ + fun remotePathCannotSelectTheRsyncDaemonProtocol() { + assertFalse(FolderRules.remotePathAllowed(":module")) + assertFalse(FolderRules.remotePathAllowed("path\u001b")) + for (path in listOf("/", ".", "..", "~", "~/", "../backup", "backup/../other", "~other/backup")) { + assertFalse("unsafe path accepted: $path", FolderRules.remotePathAllowed(path)) + } + assertFalse(FolderRules.excludeAllowed("pattern\tvalue")) + assertTrue(FolderRules.remotePathAllowed("/srv/backup")) + assertTrue(FolderRules.remotePathAllowed("~/backup")) + assertTrue(FolderRules.remotePathAllowed("backup path;literal\$dollar'quote")) + } + + @Test + fun destinationConflictUsesTheActualEndpoint() { + val cfg = Config( + remotes = listOf( + Remote("nas", "NAS.example", 22, "backup", "pin"), + Remote("alias", "nas.example", 22, "backup", "pin"), + Remote("other-user", "nas.example", 22, "reader", "pin"), + ), + folders = listOf(Folder("photos", "/photos", "nas", "archive/photos")), + ) + assertEquals( + cfg.folders[0], + FolderRules.destinationConflict( + cfg, + Folder("camera", "/camera", "alias", "archive/photos/camera"), + -1, + ), + ) + assertEquals( + null, + FolderRules.destinationConflict( + cfg, + Folder("reader", "/reader", "other-user", "archive/photos"), + -1, + ), ) - assertTrue(Config.legacyShape(legacy)) + } - val once = Config.fromJson(legacy, legacyPin = "home ssh-ed25519 AAAA") - val written = once.toJson() - assertFalse(Config.legacyShape(written)) - assertEquals(once, Config.fromJson(written)) + @Test + fun quarantineNeverOverwritesAnEarlierRecovery() { + val dir = Files.createTempDirectory("rsend-config").toFile() + try { + val source = File(dir, "config.json") + source.writeText("first") + assertEquals("config.json.broken", Config.quarantine(source).name) + source.writeText("second") + assertEquals("config.json.broken.1", Config.quarantine(source).name) + assertEquals("first", File(dir, "config.json.broken").readText()) + assertEquals("second", File(dir, "config.json.broken.1").readText()) + } finally { + dir.deleteRecursively() + } } @Test - fun freshAndCurrentConfigsAreNotLegacy() { - assertFalse(Config.legacyShape(JSONObject("{}"))) - assertFalse(Config.legacyShape(Config().toJson())) - assertFalse(Config.legacyShape(JSONObject("""{"remotes": [], "remote": {"host": "x"}}"""))) + fun invalidEndpointFieldsAreRejected() { + assertTrue(RemoteRules.hostAllowed("2001:db8::1%wlan0")) + assertTrue(RemoteRules.userAllowed("backup-user")) + assertFalse(RemoteRules.nameAllowed("n".repeat(129))) + assertFalse(RemoteRules.hostAllowed("h".repeat(256))) + assertFalse(RemoteRules.userAllowed("u".repeat(129))) + assertFalse(RemoteRules.hostAllowed("höst.example")) + assertFalse(RemoteRules.hostAllowed("host;command")) + assertFalse(RemoteRules.hostAllowed("-option")) + assertFalse(RemoteRules.userAllowed("user name")) + assertFalse(RemoteRules.userAllowed("-option")) + } + + @Test + fun hostPinMustBelongToTheConfiguredEndpoint() { + val pin = "home ssh-ed25519 AAAA" + assertTrue(RemoteRules.hostKeyAllowed(Remote("nas", "home", 22, "backup", pin))) + assertTrue(RemoteRules.hostKeyAllowed(Remote("nas", "home", 22, "backup", ""))) + assertFalse(RemoteRules.hostKeyAllowed(Remote("nas", "other", 22, "backup", pin))) + assertFalse(RemoteRules.hostKeyAllowed(Remote("nas", "home", 22, "backup", "$pin comment"))) + assertFalse(RemoteRules.hostKeyAllowed(Remote("nas", "home", 22, "backup", "home ssh-dss AAAA"))) + assertFalse(RemoteRules.hostKeyAllowed(Remote("nas", "home", 22, "backup", "home ssh-ed25519 not-base64"))) } - // An empty remotes array is still the new shape: a 0.2.x user who deleted - // their only remote must not be dragged back through the migration. @Test - fun emptyRemotesArrayIsNotLegacy() { + fun configRejectsCoercedAndUnknownFields() { + val badRemotes = Config().toJson().put("remotes", "not an array") + val badInterval = Config().toJson().apply { + getJSONObject("schedule").put("intervalMinutes", "120") + } + val badFolders = Config().toJson().put("folders", false) + val unknown = Config().toJson().put("unexpected", true) + val missing = Config().toJson().apply { getJSONObject("schedule").remove("wifiOnly") } + val controlName = Config( + remotes = listOf(Remote("nas", "nas", 22, "backup")), + folders = listOf(Folder("bad\nname", "/a", "nas", "backup")), + ).toJson() + for (json in listOf(badRemotes, badInterval, badFolders, unknown, missing, controlName)) { + try { + Config.fromJson(json) + throw AssertionError("malformed config accepted: $json") + } catch (_: Exception) { + } + } + } + + @Test + fun pathsAndExcludeVectorsAreBounded() { + assertFalse(FolderRules.localPathAllowed("/" + "a".repeat(4096))) + assertFalse(FolderRules.remotePathAllowed("a".repeat(4097))) + assertFalse(FolderRules.nameAllowed("n".repeat(257))) + assertFalse(FolderRules.excludeAllowed("x".repeat(513))) + assertFalse(FolderRules.excludesAllowed(List(65) { "x" })) + assertTrue(FolderRules.excludesAllowed(List(64) { "x" })) + } + + @Test + fun configRejectsOverlappingDestinations() { val json = JSONObject( - """{"remotes": [], "folders": [{"name": "DCIM", "local": "/d", "remotePath": "/b", "remoteName": "gone"}]}""" + """ + { + "remotes": [{"name":"nas","host":"nas","port":22,"user":"backup","hostKey":""}], + "schedule": {"enabled":false,"intervalMinutes":120,"wifiOnly":true,"requireCharging":false}, + "folders": [ + {"name":"a","local":"/a","remoteName":"nas","remotePath":"backup","delete":false,"excludes":[]}, + {"name":"b","local":"/b","remoteName":"nas","remotePath":"backup/b","delete":false,"excludes":[]} + ] + } + """ ) - assertFalse(Config.legacyShape(json)) - val c = Config.fromJson(json, legacyPin = "should be ignored") - assertEquals(emptyList<Remote>(), c.remotes) - assertEquals(listOf(Folder("DCIM", "/d", "gone", "/b", false, emptyList())), c.folders) + try { + Config.fromJson(json) + throw AssertionError("overlapping destinations accepted") + } catch (_: IllegalArgumentException) { + } } @Test - fun syncReadyNeedsKeyFolderAndPinnedRemote() { - val pinned = Remote("nas", "home", 22, "backup", "line") - val folder = Folder("d", "/d", "nas", "/b") - assertTrue(Config(remotes = listOf(pinned), folders = listOf(folder)).syncReady(keyExists = true)) - assertFalse(Config(remotes = listOf(pinned), folders = listOf(folder)).syncReady(keyExists = false)) - assertFalse(Config(remotes = listOf(pinned)).syncReady(keyExists = true)) - assertFalse( - Config(remotes = listOf(pinned.copy(hostKey = "")), folders = listOf(folder)) - .syncReady(keyExists = true) - ) + fun configInputIsBounded() { + val oversized = ByteArray(Config.MAX_CONFIG_BYTES + 1) + try { + Config.readText(ByteArrayInputStream(oversized), Config.MAX_CONFIG_BYTES) + throw AssertionError("oversized config accepted") + } catch (_: IllegalArgumentException) { + } } } diff --git a/app/src/test/java/invalid/lena/rsend/RsyncRunnerTest.kt b/app/src/test/java/invalid/lena/rsend/RsyncRunnerTest.kt index 9729f62..b2cf906 100644 --- a/app/src/test/java/invalid/lena/rsend/RsyncRunnerTest.kt +++ b/app/src/test/java/invalid/lena/rsend/RsyncRunnerTest.kt @@ -1,6 +1,8 @@ package invalid.lena.rsend +import java.io.ByteArrayInputStream import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -8,12 +10,14 @@ class RsyncRunnerTest { private val remote = Remote("nas", "host", 22, "user") + // The exact vector, because this command line is the whole data-safety + // surface of the app: any change to it should be a deliberate edit here. @Test fun basicArgs() { val a = RsyncRunner.args("RSH", remote, Folder(name = "n", local = "/a", remoteName = "nas", remotePath = "/b")) assertEquals( listOf( - "-rt", "--partial", "--timeout=300", + "-rt", "--out-format=%o %n", "--partial-dir=.rsend-partial", "--timeout=300", "--no-perms", "--no-owner", "--no-group", "--omit-dir-times", "-e", "RSH", "/a/", "user@host:/b/", ), @@ -21,16 +25,44 @@ class RsyncRunnerTest { ) } + // A bare --partial renames the truncated temp file over the destination + // name, so an interrupted sync destroys a complete remote copy. @Test - fun mirrorAddsDelete() { + fun partialsStayOutOfTheDestinationName() { + val a = RsyncRunner.args("RSH", remote, Folder(local = "/a", remotePath = "/b")) + assertTrue(a.contains("--partial-dir=${RsyncRunner.PARTIAL_DIR}")) + assertFalse(a.contains("--partial")) + } + + // Every line is labelled with its operation, so deletions can be logged and + // transfers counted, and no filename ever sits at column 0 where it could + // forge rsh's unreachable marker. --info would be tidier but rsync forwards + // it to the remote and pre-3.1.0 rsyncs reject it; --out-format is sent as + // --log-format, which every rsync understands. + @Test + fun outputIsLabelledWithTheOperation() { + val a = RsyncRunner.args("RSH", remote, Folder(local = "/a", remotePath = "/b", delete = true)) + assertTrue(a.contains("--out-format=%o %n")) + assertFalse(a.contains("-v")) + assertFalse(a.any { it.startsWith("--info") }) + } + + // Deleting during the transfer removes the server's copy before its + // replacement arrives, and this worker is routinely killed mid-run, so a + // rename on the phone plus one interruption would leave neither copy. + @Test + fun mirrorDeletesOnlyAfterEverythingIsTransferred() { val a = RsyncRunner.args("RSH", remote, Folder(local = "/a", remotePath = "/b", delete = true)) - assertTrue(a.contains("--delete")) + assertTrue(a.contains("--delete-after")) + assertFalse(a.contains("--delete")) } @Test fun additiveOmitsDelete() { - val a = RsyncRunner.args("RSH", remote, Folder(local = "/a", remotePath = "/b", delete = false)) - assertTrue(!a.contains("--delete")) + assertFalse( + RsyncRunner.args("RSH", remote, Folder(local = "/a", remotePath = "/b", delete = false)) + .any { it.startsWith("--delete") } + ) } @Test @@ -40,10 +72,103 @@ class RsyncRunnerTest { assertTrue(a.contains("--exclude=.y")) } + // rsync splits USER@HOST:PATH on the first colon, so an IPv6 literal has to + // be bracketed or the address is cut in half. + @Test + fun ipv6DestinationIsBracketed() { + val v6 = Remote("nas", "2001:db8::1", 22, "user") + val a = RsyncRunner.args("RSH", v6, Folder(local = "/a", remotePath = "/b")) + assertTrue(a.contains("user@[2001:db8::1]:/b/")) + // A hostname must not gain brackets. + assertTrue(RsyncRunner.args("RSH", remote, Folder(local = "/a", remotePath = "/b")) + .contains("user@host:/b/")) + } + @Test fun trailingSlashIdempotent() { val a = RsyncRunner.args("RSH", remote, Folder(local = "/a/", remotePath = "/b/")) assertTrue(a.contains("/a/")) assertTrue(a.contains("user@host:/b/")) } + + @Test(expected = IllegalArgumentException::class) + fun daemonStyleRemotePathIsRejectedAtExecution() { + RsyncRunner.args("RSH", remote, Folder(local = "/a", remotePath = ":module")) + } + + // Exit 24 is "some files vanished before they could be transferred", which + // on a live phone is routine for an additive push. For a mirror it is not: + // rsync reports 24 when a source directory vanishes mid-run, and deletes + // that directory's contents on the server, so it must not read as success. + @Test + fun vanishedSourceFilesAreNotAFailureForAdditive() { + assertTrue(RsyncRunner.succeeded(0, mirror = false)) + assertTrue(RsyncRunner.succeeded(24, mirror = false)) + assertFalse(RsyncRunner.succeeded(23, mirror = false)) + assertFalse(RsyncRunner.succeeded(12, mirror = false)) + assertFalse(RsyncRunner.succeeded(1, mirror = false)) + } + + @Test + fun vanishedSourceFilesFailAMirror() { + assertTrue(RsyncRunner.succeeded(0, mirror = true)) + assertFalse(RsyncRunner.succeeded(24, mirror = true)) + assertFalse(RsyncRunner.succeeded(23, mirror = true)) + } + + @Test + fun outputLinesAreBoundedWithoutBlockingTheNextLine() { + val hostile = ByteArray(RsyncRunner.MAX_OUTPUT_LINE_BYTES * 8) { 'x'.code.toByte() } + val input = ByteArrayInputStream(hostile + "\nsend next\n".toByteArray()) + val lines = ArrayList<String>() + RsyncRunner.boundedLines(input, lines::add) + assertEquals(2, lines.size) + assertTrue(lines[0].endsWith("[truncated]")) + assertTrue(lines[0].length <= RsyncRunner.MAX_OUTPUT_LINE_BYTES + 20) + assertEquals("send next", lines[1]) + } + + // Real rsync --out-format='%o %n' output. A mirror may legitimately empty + // its destination, so deletions are counted and reported rather than + // blocked. Transfers are counted but not logged one line each; everything + // else, including errors and rsh's marker, reaches the log verbatim. + @Test + fun deletionsAreCountedAndKeptInTheLog() { + val out = """ + send DCIM/Camera/IMG_0001.jpg + del. DCIM/Camera/IMG_9998.jpg + send DCIM/Camera/IMG_0002.jpg + del. DCIM/Camera/IMG_9999.jpg + del. DCIM/Camera/old/ + rsync: some error worth keeping + """.trimIndent() + "\n" + val logged = ArrayList<String>() + val t = RsyncRunner.tally(ByteArrayInputStream(out.toByteArray()), logged::add) + + assertEquals(2L, t.sent) + assertEquals(3L, t.deleted) + assertFalse(t.unreachable) + // Deletions and errors stay readable; transfers do not flood the log. + assertTrue(logged.none { it.startsWith("send ") }) + assertEquals(3, logged.count { it.startsWith("del. ") }) + assertTrue(logged.contains("rsync: some error worth keeping")) + } + + @Test + fun unreachableMarkerIsDetected() { + val out = "rsh: unreachable: dial tcp 10.0.0.1:22: i/o timeout\n" + val t = RsyncRunner.tally(ByteArrayInputStream(out.toByteArray())) {} + assertTrue(t.unreachable) + assertEquals(0L, t.deleted) + } + + @Test + fun nativeOutputIsBoundedWhileInputIsFullyDrained() { + val hostile = ByteArray(1024 * 1024) { 'z'.code.toByte() } + val input = ByteArrayInputStream(hostile) + val output = Native.boundedOutput(input) + assertTrue(output.length < hostile.size) + assertTrue(output.endsWith("[output truncated]\n")) + assertEquals(0, input.available()) + } } diff --git a/app/src/test/java/invalid/lena/rsend/SchedulerTest.kt b/app/src/test/java/invalid/lena/rsend/SchedulerTest.kt new file mode 100644 index 0000000..baffc7d --- /dev/null +++ b/app/src/test/java/invalid/lena/rsend/SchedulerTest.kt @@ -0,0 +1,14 @@ +package invalid.lena.rsend + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SchedulerTest { + + // This name is persisted in WorkManager's database across app upgrades. + // Changing it leaves the installed schedule running and creates another. + @Test + fun periodicWorkNameRemainsVersioned() { + assertEquals("periodic-sync-v2", Scheduler.NAME) + } +} diff --git a/app/src/test/java/invalid/lena/rsend/SyncLogTest.kt b/app/src/test/java/invalid/lena/rsend/SyncLogTest.kt new file mode 100644 index 0000000..edc6c99 --- /dev/null +++ b/app/src/test/java/invalid/lena/rsend/SyncLogTest.kt @@ -0,0 +1,16 @@ +package invalid.lena.rsend + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class SyncLogTest { + + @Test + fun controlCharactersCannotForgeLogLinesOrTerminalOutput() { + val clean = SyncLog.cleanLine("name\nnext\t\u001b[31m\u007f\u2028\u202e") + + assertEquals("name\\nnext\\u0009\\u001b[31m\\u007f\\u2028\\u202e", clean) + assertFalse(clean.any(Char::isISOControl)) + } +} |