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/src/test | |
| 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/src/test')
| -rw-r--r-- | app/src/test/java/invalid/lena/rsend/ConfigTest.kt | 394 | ||||
| -rw-r--r-- | app/src/test/java/invalid/lena/rsend/RsyncRunnerTest.kt | 135 | ||||
| -rw-r--r-- | app/src/test/java/invalid/lena/rsend/SchedulerTest.kt | 14 | ||||
| -rw-r--r-- | app/src/test/java/invalid/lena/rsend/SyncLogTest.kt | 16 |
4 files changed, 475 insertions, 84 deletions
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)) + } +} |