From 0e8a87f2ad2bd40d34996374aebb6dff070e4b5f Mon Sep 17 00:00:00 2001 From: Lena Date: Sun, 16 Aug 2026 00:00:00 +0000 Subject: app: harden backup execution Validate persisted state and destination boundaries, make interruption and mirror deletion explicit, and keep scheduled work singular. --- app/src/main/AndroidManifest.xml | 8 +- app/src/main/java/invalid/lena/rsend/App.kt | 11 +- app/src/main/java/invalid/lena/rsend/Config.kt | 420 +++++++++++++++++---- .../java/invalid/lena/rsend/FolderEditActivity.kt | 76 +++- .../invalid/lena/rsend/FolderPickerActivity.kt | 66 +++- .../main/java/invalid/lena/rsend/FolderRules.kt | 108 ++++++ app/src/main/java/invalid/lena/rsend/KeyVault.kt | 19 +- app/src/main/java/invalid/lena/rsend/Keys.kt | 131 +++++-- app/src/main/java/invalid/lena/rsend/LastSync.kt | 37 +- .../main/java/invalid/lena/rsend/LogActivity.kt | 26 +- .../main/java/invalid/lena/rsend/MainActivity.kt | 181 +++++++-- app/src/main/java/invalid/lena/rsend/Native.kt | 44 ++- .../main/java/invalid/lena/rsend/RemoteActivity.kt | 201 +++++++--- .../main/java/invalid/lena/rsend/RsyncRunner.kt | 142 ++++++- .../java/invalid/lena/rsend/ScheduleActivity.kt | 26 +- app/src/main/java/invalid/lena/rsend/Scheduler.kt | 10 +- app/src/main/java/invalid/lena/rsend/SyncLog.kt | 106 +++++- app/src/main/java/invalid/lena/rsend/SyncWorker.kt | 211 +++++++---- app/src/main/res/layout/activity_folder.xml | 6 +- app/src/main/res/layout/activity_log.xml | 74 ++-- app/src/main/res/layout/activity_picker.xml | 51 +-- app/src/main/res/layout/activity_remote.xml | 4 + app/src/main/res/layout/activity_schedule.xml | 7 +- app/src/main/res/layout/app_toolbar.xml | 4 +- app/src/main/res/layout/item_dir.xml | 31 +- app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml | 6 - app/src/main/res/mipmap-anydpi/ic_launcher.xml | 6 + app/src/main/res/xml/backup_rules.xml | 25 ++ app/src/main/res/xml/backup_rules_legacy.xml | 12 + app/src/test/java/invalid/lena/rsend/ConfigTest.kt | 394 +++++++++++++++---- .../java/invalid/lena/rsend/RsyncRunnerTest.kt | 135 ++++++- .../test/java/invalid/lena/rsend/SchedulerTest.kt | 14 + .../test/java/invalid/lena/rsend/SyncLogTest.kt | 16 + 33 files changed, 2094 insertions(+), 514 deletions(-) create mode 100644 app/src/main/java/invalid/lena/rsend/FolderRules.kt delete mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/backup_rules_legacy.xml create mode 100644 app/src/test/java/invalid/lena/rsend/SchedulerTest.kt create mode 100644 app/src/test/java/invalid/lena/rsend/SyncLogTest.kt (limited to 'app') 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 @@ + + + android:theme="@style/Theme.Rsend" + android:usesCleartextTraffic="false"> 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() - 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() + 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() + 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(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(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) { + 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 = 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