From dc0338f8c0c9e74b277169d812c432f7ea4888e3 Mon Sep 17 00:00:00 2001 From: Lena Date: Sun, 23 Aug 2026 00:00:00 +0000 Subject: app: rename RsyncRunner to Rsync Runner names the architecture rather than the thing. The object is the rsync invocation. --- .../java/invalid/lena/rsend/RsyncRunnerTest.kt | 174 --------------------- app/src/test/java/invalid/lena/rsend/RsyncTest.kt | 174 +++++++++++++++++++++ 2 files changed, 174 insertions(+), 174 deletions(-) delete mode 100644 app/src/test/java/invalid/lena/rsend/RsyncRunnerTest.kt create mode 100644 app/src/test/java/invalid/lena/rsend/RsyncTest.kt (limited to 'app/src/test/java/invalid') diff --git a/app/src/test/java/invalid/lena/rsend/RsyncRunnerTest.kt b/app/src/test/java/invalid/lena/rsend/RsyncRunnerTest.kt deleted file mode 100644 index b2cf906..0000000 --- a/app/src/test/java/invalid/lena/rsend/RsyncRunnerTest.kt +++ /dev/null @@ -1,174 +0,0 @@ -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 - -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", "--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/", - ), - a, - ) - } - - // A bare --partial renames the truncated temp file over the destination - // name, so an interrupted sync destroys a complete remote copy. - @Test - 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-after")) - assertFalse(a.contains("--delete")) - } - - @Test - fun additiveOmitsDelete() { - assertFalse( - RsyncRunner.args("RSH", remote, Folder(local = "/a", remotePath = "/b", delete = false)) - .any { it.startsWith("--delete") } - ) - } - - @Test - fun excludesBecomeFlags() { - val a = RsyncRunner.args("RSH", remote, Folder(local = "/a", remotePath = "/b", excludes = listOf(".x/", ".y"))) - assertTrue(a.contains("--exclude=.x/")) - 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() - 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() - 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/RsyncTest.kt b/app/src/test/java/invalid/lena/rsend/RsyncTest.kt new file mode 100644 index 0000000..3ca8dcc --- /dev/null +++ b/app/src/test/java/invalid/lena/rsend/RsyncTest.kt @@ -0,0 +1,174 @@ +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 + +class RsyncTest { + + 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 = Rsync.args("RSH", remote, Folder(name = "n", local = "/a", remoteName = "nas", remotePath = "/b")) + assertEquals( + listOf( + "-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/", + ), + a, + ) + } + + // A bare --partial renames the truncated temp file over the destination + // name, so an interrupted sync destroys a complete remote copy. + @Test + fun partialsStayOutOfTheDestinationName() { + val a = Rsync.args("RSH", remote, Folder(local = "/a", remotePath = "/b")) + assertTrue(a.contains("--partial-dir=${Rsync.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 = Rsync.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 = Rsync.args("RSH", remote, Folder(local = "/a", remotePath = "/b", delete = true)) + assertTrue(a.contains("--delete-after")) + assertFalse(a.contains("--delete")) + } + + @Test + fun additiveOmitsDelete() { + assertFalse( + Rsync.args("RSH", remote, Folder(local = "/a", remotePath = "/b", delete = false)) + .any { it.startsWith("--delete") } + ) + } + + @Test + fun excludesBecomeFlags() { + val a = Rsync.args("RSH", remote, Folder(local = "/a", remotePath = "/b", excludes = listOf(".x/", ".y"))) + assertTrue(a.contains("--exclude=.x/")) + 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 = Rsync.args("RSH", v6, Folder(local = "/a", remotePath = "/b")) + assertTrue(a.contains("user@[2001:db8::1]:/b/")) + // A hostname must not gain brackets. + assertTrue(Rsync.args("RSH", remote, Folder(local = "/a", remotePath = "/b")) + .contains("user@host:/b/")) + } + + @Test + fun trailingSlashIdempotent() { + val a = Rsync.args("RSH", remote, Folder(local = "/a/", remotePath = "/b/")) + assertTrue(a.contains("/a/")) + assertTrue(a.contains("user@host:/b/")) + } + + @Test(expected = IllegalArgumentException::class) + fun daemonStyleRemotePathIsRejectedAtExecution() { + Rsync.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(Rsync.succeeded(0, mirror = false)) + assertTrue(Rsync.succeeded(24, mirror = false)) + assertFalse(Rsync.succeeded(23, mirror = false)) + assertFalse(Rsync.succeeded(12, mirror = false)) + assertFalse(Rsync.succeeded(1, mirror = false)) + } + + @Test + fun vanishedSourceFilesFailAMirror() { + assertTrue(Rsync.succeeded(0, mirror = true)) + assertFalse(Rsync.succeeded(24, mirror = true)) + assertFalse(Rsync.succeeded(23, mirror = true)) + } + + @Test + fun outputLinesAreBoundedWithoutBlockingTheNextLine() { + val hostile = ByteArray(Rsync.MAX_OUTPUT_LINE_BYTES * 8) { 'x'.code.toByte() } + val input = ByteArrayInputStream(hostile + "\nsend next\n".toByteArray()) + val lines = ArrayList() + Rsync.boundedLines(input, lines::add) + assertEquals(2, lines.size) + assertTrue(lines[0].endsWith("[truncated]")) + assertTrue(lines[0].length <= Rsync.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() + val t = Rsync.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 = Rsync.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()) + } +} -- cgit v1.2.3