diff options
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") + ) + } } |