aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-23 00:00:00 +0000
committerLena <lena@omega>2026-08-23 00:00:00 +0000
commite8c6c4ebb3d76ee280043bb66cf499584c591310 (patch)
treeb3ba91a370140a74f689d1d27155fc3830a278a8 /app
parent82c21c0177c162902985ab6edf762ef7e4e3d099 (diff)
downloadrsend-e8c6c4ebb3d76ee280043bb66cf499584c591310.tar.gz
app: report config limits instead of crashing
The remote and folder counts and the serialized config size are enforced only inside save, which throws. Every other rule an editor can break is reported in the form, so a config that outgrew a limit crashed the activity instead of saying so. overLimit answers before the entry is added, and the editors report the answer. hostKeyAllowed reconstructs the known_hosts address that rsh writes with knownhosts.Normalize, and nothing recorded or tested that coupling. Normalize leaves the host bare on port 22 for IPv6 literals too, so bracketing them here looks right and would reject every IPv6 pin.
Diffstat (limited to 'app')
-rw-r--r--app/src/main/java/invalid/lena/rsend/Config.kt16
-rw-r--r--app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt8
-rw-r--r--app/src/main/java/invalid/lena/rsend/RemoteActivity.kt5
-rw-r--r--app/src/test/java/invalid/lena/rsend/ConfigTest.kt57
4 files changed, 85 insertions, 1 deletions
diff --git a/app/src/main/java/invalid/lena/rsend/Config.kt b/app/src/main/java/invalid/lena/rsend/Config.kt
index 25bf338..a38fbde 100644
--- a/app/src/main/java/invalid/lena/rsend/Config.kt
+++ b/app/src/main/java/invalid/lena/rsend/Config.kt
@@ -57,6 +57,9 @@ object RemoteRules {
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
+ // Must be exactly the address rsh writes, which is knownhosts.Normalize:
+ // the bare host on port 22, including an IPv6 literal, "[host]:port"
+ // otherwise. A different form here silently stops matching the pin.
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 {
@@ -187,6 +190,19 @@ data class Config(
}
}
+ // overLimit reports why c cannot be persisted, or null. These are the
+ // only save failures a form cannot rule out on its own, so the editors
+ // ask before adding an entry and report the answer. Every other rule
+ // fromJson enforces is already checked field by field; one firing there
+ // is a bug and must throw.
+ fun overLimit(c: Config): String? = when {
+ c.remotes.size > MAX_REMOTES -> "Too many remotes; the limit is $MAX_REMOTES."
+ c.folders.size > MAX_FOLDERS -> "Too many folders; the limit is $MAX_FOLDERS."
+ c.toJson().toString(2).toByteArray(Charsets.UTF_8).size > MAX_CONFIG_BYTES ->
+ "Configuration is too large; the limit is $MAX_CONFIG_BYTES bytes."
+ else -> null
+ }
+
// 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 =
diff --git a/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt b/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt
index 5565bff..47961c1 100644
--- a/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt
+++ b/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt
@@ -161,7 +161,13 @@ class FolderEditActivity : AppCompatActivity() {
}
val list = cfg.folders.toMutableList()
if (index in list.indices) list[index] = f else list.add(f)
- Config.save(this, cfg.copy(folders = list))
+ val next = cfg.copy(folders = list)
+ val limit = Config.overLimit(next)
+ if (limit != null) {
+ Toast.makeText(this, limit, Toast.LENGTH_LONG).show()
+ return false
+ }
+ Config.save(this, next)
return true
}
diff --git a/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt b/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt
index 994e233..83f499d 100644
--- a/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt
+++ b/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt
@@ -130,6 +130,11 @@ class RemoteActivity : AppCompatActivity() {
"folder \"${b.name.ifEmpty { b.remotePath }}\"."
return -1
}
+ val limit = Config.overLimit(next)
+ if (limit != null) {
+ status.text = limit
+ return -1
+ }
Config.save(this, next)
Scheduler.apply(this)
return at
diff --git a/app/src/test/java/invalid/lena/rsend/ConfigTest.kt b/app/src/test/java/invalid/lena/rsend/ConfigTest.kt
index 6b6dfdc..e289afd 100644
--- a/app/src/test/java/invalid/lena/rsend/ConfigTest.kt
+++ b/app/src/test/java/invalid/lena/rsend/ConfigTest.kt
@@ -308,6 +308,30 @@ class ConfigTest {
assertFalse(RemoteRules.hostKeyAllowed(Remote("nas", "home", 22, "backup", "home ssh-ed25519 not-base64")))
}
+ // The address field of a pin must be exactly what rsh writes, which is
+ // knownhosts.Normalize: the bare host on port 22 (an IPv6 literal included),
+ // "[host]:port" otherwise. Bracketing IPv6 on port 22 looks right and would
+ // make every IPv6 pin fail to validate.
+ @Test
+ fun hostPinAddressMatchesTheKnownHostsForm() {
+ val key = "ssh-ed25519 AAAA"
+ val cases = listOf(
+ "home.example.net" to 22,
+ "home.example.net" to 2222,
+ "2001:db8::1" to 22,
+ "2001:db8::1" to 2222,
+ "fe80::1%wlan0" to 22,
+ )
+ for ((host, port) in cases) {
+ val address = if (port == 22) host else "[$host]:$port"
+ assertTrue(
+ "$host:$port",
+ RemoteRules.hostKeyAllowed(Remote("nas", host, port, "backup", "$address $key")),
+ )
+ }
+ assertFalse(RemoteRules.hostKeyAllowed(Remote("nas", "2001:db8::1", 22, "backup", "[2001:db8::1] $key")))
+ }
+
@Test
fun configRejectsCoercedAndUnknownFields() {
val badRemotes = Config().toJson().put("remotes", "not an array")
@@ -370,4 +394,37 @@ class ConfigTest {
} catch (_: IllegalArgumentException) {
}
}
+
+ // The editors ask before adding an entry, because these three limits are
+ // the ones a form cannot see. Reporting them is what keeps save from
+ // throwing at a user who has done nothing wrong.
+ @Test
+ fun limitsAreReportedRatherThanThrown() {
+ val remote = Remote("nas", "nas", 22, "backup", "nas ssh-ed25519 AAAA")
+ val folder = Folder("f", "/a", "nas", "backup")
+ assertEquals(null, Config.overLimit(Config(remotes = listOf(remote), folders = listOf(folder))))
+
+ val remotes = (1..65).map { remote.copy(name = "nas$it", hostKey = "") }
+ assertTrue(Config.overLimit(Config(remotes = remotes))!!.contains("Too many remotes"))
+
+ val folders = (1..257).map { folder.copy(name = "f$it", remotePath = "backup/$it") }
+ assertTrue(
+ Config.overLimit(Config(remotes = listOf(remote), folders = folders))!!
+ .contains("Too many folders")
+ )
+
+ // Field limits alone allow far more than the file limit: 40 folders of
+ // maximum-length excludes already exceed it.
+ val fat = (1..40).map {
+ folder.copy(
+ name = "f$it",
+ remotePath = "backup/$it",
+ excludes = List(FolderRules.MAX_EXCLUDES) { i -> "x".repeat(512 - "$i".length) + i },
+ )
+ }
+ assertTrue(
+ Config.overLimit(Config(remotes = listOf(remote), folders = fat))!!
+ .contains("too large")
+ )
+ }
}