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. --- app/src/main/java/invalid/lena/rsend/Rsync.kt | 202 +++++++++++++++++++++ .../main/java/invalid/lena/rsend/RsyncRunner.kt | 202 --------------------- app/src/main/java/invalid/lena/rsend/SyncWorker.kt | 4 +- .../java/invalid/lena/rsend/RsyncRunnerTest.kt | 174 ------------------ app/src/test/java/invalid/lena/rsend/RsyncTest.kt | 174 ++++++++++++++++++ 5 files changed, 378 insertions(+), 378 deletions(-) create mode 100644 app/src/main/java/invalid/lena/rsend/Rsync.kt delete mode 100644 app/src/main/java/invalid/lena/rsend/RsyncRunner.kt 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') diff --git a/app/src/main/java/invalid/lena/rsend/Rsync.kt b/app/src/main/java/invalid/lena/rsend/Rsync.kt new file mode 100644 index 0000000..5fc73c8 --- /dev/null +++ b/app/src/main/java/invalid/lena/rsend/Rsync.kt @@ -0,0 +1,202 @@ +package invalid.lena.rsend + +import android.content.Context +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.util.concurrent.TimeUnit + +// Rsync builds and runs the rsync invocation for one folder, streaming its +// output into the log. It uses the bundled rsync and rsh binaries and passes +// key, known_hosts, and port to rsh through the environment. +object Rsync { + + // rsh prints this when it cannot reach the host at all. rsync only ever + // reports its own exit 12 for a remote shell that died, which does not say + // why, so the reason is read off rsh's merged stderr instead. + private const val UNREACHABLE = "rsh: unreachable:" + + // Outcome carries rsync's exit code plus whether the failure was the host + // being unreachable, which the caller uses to skip the rest of that remote. + // deleted is what a mirror removed on the server. A mirror is allowed to + // empty its destination, so the count is not a brake; it is how the run + // says what it did, loudly enough to be noticed the same day. + data class Outcome(val code: Int, val unreachable: Boolean, val deleted: Long = 0) + + // rsync exit 24 is "some files vanished before they could be transferred". + // For an additive push that is routine on a live phone: the camera, + // WhatsApp and the media scanner all move files while a sync runs. + // + // For a mirror it is not routine. rsync reports the same 24 when a whole + // source *directory* vanishes mid-run, and the receiver still deletes that + // directory's contents on the server, so calling 24 a success would record + // a run that destroyed remote data as "ok". + private const val EXIT_VANISHED = 24 + + fun succeeded(code: Int, mirror: Boolean): Boolean = + code == 0 || (code == EXIT_VANISHED && !mirror) + + // partialDir keeps a half-transferred file out of the destination name. + // Relative, so rsync excludes it automatically and --delete leaves it alone. + const val PARTIAL_DIR = ".rsend-partial" + + // " ": %o is send, recv or del. + const val OUT_FORMAT = "%o %n" + + // Transfers are counted rather than logged one line each. Deletions and + // everything else go to the log verbatim. + private const val SENT = "send " + private const val DELETED = "del. " + internal const val MAX_OUTPUT_LINE_BYTES = 16 * 1024 + + // args builds the rsync argument vector for one folder. Flags are tuned for + // media backup: recursive, preserve mtimes, resume partial files, and skip + // the ownership and permission bits that mean nothing across Android and a + // server. + // + // No --mkpath: rsync forwards it to the remote when sending, and remotes + // older than 3.2.3 (notably stock macOS) reject it. rsync creates the + // final component of the destination path on its own; deeper missing + // parents need a one-time mkdir on the server (see README). + // + // --out-format labels every line with the operation: "del. " for a + // deletion, "send " for a transfer. That is what makes the log an + // audit trail for mirrors, and it costs nothing in compatibility: rsync + // forwards it to the remote as --log-format, which every rsync back to the + // 2.6 series understands. --info=del would be tidier still, but rsync + // forwards --info verbatim and remotes older than 3.1.0 reject it, the same + // trap --mkpath fell into. + // + // Labelling also keeps filenames out of column 0, where an attacker-chosen + // name could otherwise forge rsh's "unreachable" marker. runFolder counts + // the "send" lines rather than logging each one, so a first sync of a large + // library cannot flood the capped log and rotate the deletions out of it. + fun args(rsh: String, remote: Remote, f: Folder): List { + require(RemoteRules.hostAllowed(remote.host) && RemoteRules.userAllowed(remote.user)) { + "invalid remote endpoint" + } + require(FolderRules.localPathAllowed(f.local)) { "invalid local path" } + require(FolderRules.remotePathAllowed(f.remotePath)) { "invalid remote path" } + require(FolderRules.excludesAllowed(f.excludes)) { "invalid exclude" } + val a = ArrayList() + a.add("-rt") + a.add("--out-format=$OUT_FORMAT") + // Without a partial dir an interrupted transfer renames the truncated + // temp file over the destination name, destroying a complete remote + // copy. Interruption is routine here: the worker is killed when a + // constraint is lost or the execution limit is hit. + a.add("--partial-dir=$PARTIAL_DIR") + // Abort rather than hang if the network stalls for 5 minutes. + a.add("--timeout=300") + a.add("--no-perms") + a.add("--no-owner") + a.add("--no-group") + a.add("--omit-dir-times") + a.add("-e") + a.add(rsh) + for (e in f.excludes) a.add("--exclude=$e") + // --delete-after, not --delete. Deleting during the transfer removes the + // server's copy of a file before its replacement has arrived, and this + // worker is routinely killed mid-run, so a rename on the phone plus one + // interruption leaves the server with neither copy. Deleting only after + // everything is transferred keeps the old copy until the new one is + // safely there. It is as old as rsync, so no remote is excluded. + if (f.delete) a.add("--delete-after") + a.add(withSlash(f.local)) + a.add("${remote.user}@${hostArg(remote.host)}:${withSlash(f.remotePath)}") + return a + } + + private fun withSlash(p: String): String = if (p.endsWith("/")) p else "$p/" + + // An IPv6 literal must be bracketed in rsync's USER@HOST:PATH, or rsync + // splits the destination on the first colon of the address. + private fun hostArg(h: String): String = if (h.contains(":")) "[$h]" else h + + // runFolder execs rsync for one folder, appending every output line to the + // log, and returns rsync's exit code. Blocks the calling thread (run it on + // an IO dispatcher). If the caller is cancelled (worker stopped, schedule + // replaced, constraints lost) the watchdog kills rsync rather than leave it + // running detached; killing it also closes its pipes, which unblocks the + // log reader below. destroy() on an already-exited process is a no-op. + suspend fun runFolder(ctx: Context, remote: Remote, f: Folder, log: SyncLog): Outcome = coroutineScope { + val cmd = listOf(Native.rsync(ctx).absolutePath) + args(Native.rsh(ctx).absolutePath, remote, f) + log.line("rsync ${cmd.drop(1).joinToString(" ")}") + val pb = ProcessBuilder(cmd).redirectErrorStream(true) + pb.environment().putAll(Keys.env(ctx, remote)) + val p = pb.start() + val watchdog = launch(Dispatchers.IO) { try { awaitCancellation() } finally { terminate(p) } } + try { + val t = tally(p.inputStream) { log.line(it) } + if (t.sent > 0) log.line("transferred ${t.sent} item(s)") + if (t.deleted > 0) log.line("deleted ${t.deleted} item(s) on the server") + Outcome(p.waitFor(), t.unreachable, t.deleted) + } finally { + watchdog.cancelAndJoin() + } + } + + // A hostile or broken remote can print an arbitrarily long line. Keep + // draining its pipe so rsync cannot deadlock, but retain only a bounded + // prefix for the log. + internal data class Tally(val sent: Long, val deleted: Long, val unreachable: Boolean) + + // tally reads rsync's merged output, counts what it did, and passes + // everything worth keeping to log. Separated from the process so it can be + // tested against real rsync output without spawning one. It streams: a + // first sync of a large library must not be buffered to be counted. + internal fun tally(input: InputStream, log: (String) -> Unit): Tally { + var sent = 0L + var deleted = 0L + var unreachable = false + boundedLines(input) { + if (it.startsWith(UNREACHABLE)) unreachable = true + if (it.startsWith(DELETED)) deleted++ + // One line per transferred file would bury the deletions and the + // errors, and on a first sync would outgrow the whole log. + if (it.startsWith(SENT)) sent++ else log(it) + } + return Tally(sent, deleted, unreachable) + } + + internal fun boundedLines(input: InputStream, emit: (String) -> Unit) { + val line = ByteArrayOutputStream() + val buf = ByteArray(4096) + var truncated = false + while (true) { + val n = input.read(buf) + if (n < 0) break + for (i in 0 until n) { + val b = buf[i].toInt() and 0xff + if (b == '\n'.code) { + emitLine(line, truncated, emit) + line.reset() + truncated = false + } else if (line.size() < MAX_OUTPUT_LINE_BYTES) { + line.write(b) + } else { + truncated = true + } + } + } + if (line.size() > 0 || truncated) emitLine(line, truncated, emit) + } + + private fun emitLine(line: ByteArrayOutputStream, truncated: Boolean, emit: (String) -> Unit) { + val value = String(line.toByteArray(), Charsets.UTF_8).removeSuffix("\r") + + if (truncated) " [truncated]" else "" + emit(value) + } + + private fun terminate(p: Process) { + p.destroy() + if (!p.waitFor(5, TimeUnit.SECONDS)) { + p.destroyForcibly() + p.waitFor() + } + } +} diff --git a/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt b/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt deleted file mode 100644 index f807288..0000000 --- a/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt +++ /dev/null @@ -1,202 +0,0 @@ -package invalid.lena.rsend - -import android.content.Context -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import java.io.ByteArrayOutputStream -import java.io.InputStream -import java.util.concurrent.TimeUnit - -// RsyncRunner builds and runs the rsync invocation for one folder, streaming -// rsync's output into the log. It uses the bundled rsync and rsh binaries and -// passes key, known_hosts, and port to rsh through the environment. -object RsyncRunner { - - // rsh prints this when it cannot reach the host at all. rsync only ever - // reports its own exit 12 for a remote shell that died, which does not say - // why, so the reason is read off rsh's merged stderr instead. - private const val UNREACHABLE = "rsh: unreachable:" - - // Outcome carries rsync's exit code plus whether the failure was the host - // being unreachable, which the caller uses to skip the rest of that remote. - // deleted is what a mirror removed on the server. A mirror is allowed to - // empty its destination, so the count is not a brake; it is how the run - // says what it did, loudly enough to be noticed the same day. - data class Outcome(val code: Int, val unreachable: Boolean, val deleted: Long = 0) - - // rsync exit 24 is "some files vanished before they could be transferred". - // For an additive push that is routine on a live phone: the camera, - // WhatsApp and the media scanner all move files while a sync runs. - // - // For a mirror it is not routine. rsync reports the same 24 when a whole - // source *directory* vanishes mid-run, and the receiver still deletes that - // directory's contents on the server, so calling 24 a success would record - // a run that destroyed remote data as "ok". - private const val EXIT_VANISHED = 24 - - fun succeeded(code: Int, mirror: Boolean): Boolean = - code == 0 || (code == EXIT_VANISHED && !mirror) - - // partialDir keeps a half-transferred file out of the destination name. - // Relative, so rsync excludes it automatically and --delete leaves it alone. - const val PARTIAL_DIR = ".rsend-partial" - - // " ": %o is send, recv or del. - const val OUT_FORMAT = "%o %n" - - // Transfers are counted rather than logged one line each. Deletions and - // everything else go to the log verbatim. - private const val SENT = "send " - private const val DELETED = "del. " - internal const val MAX_OUTPUT_LINE_BYTES = 16 * 1024 - - // args builds the rsync argument vector for one folder. Flags are tuned for - // media backup: recursive, preserve mtimes, resume partial files, and skip - // the ownership and permission bits that mean nothing across Android and a - // server. - // - // No --mkpath: rsync forwards it to the remote when sending, and remotes - // older than 3.2.3 (notably stock macOS) reject it. rsync creates the - // final component of the destination path on its own; deeper missing - // parents need a one-time mkdir on the server (see README). - // - // --out-format labels every line with the operation: "del. " for a - // deletion, "send " for a transfer. That is what makes the log an - // audit trail for mirrors, and it costs nothing in compatibility: rsync - // forwards it to the remote as --log-format, which every rsync back to the - // 2.6 series understands. --info=del would be tidier still, but rsync - // forwards --info verbatim and remotes older than 3.1.0 reject it, the same - // trap --mkpath fell into. - // - // Labelling also keeps filenames out of column 0, where an attacker-chosen - // name could otherwise forge rsh's "unreachable" marker. runFolder counts - // the "send" lines rather than logging each one, so a first sync of a large - // library cannot flood the capped log and rotate the deletions out of it. - fun args(rsh: String, remote: Remote, f: Folder): List { - require(RemoteRules.hostAllowed(remote.host) && RemoteRules.userAllowed(remote.user)) { - "invalid remote endpoint" - } - require(FolderRules.localPathAllowed(f.local)) { "invalid local path" } - require(FolderRules.remotePathAllowed(f.remotePath)) { "invalid remote path" } - require(FolderRules.excludesAllowed(f.excludes)) { "invalid exclude" } - val a = ArrayList() - a.add("-rt") - a.add("--out-format=$OUT_FORMAT") - // Without a partial dir an interrupted transfer renames the truncated - // temp file over the destination name, destroying a complete remote - // copy. Interruption is routine here: the worker is killed when a - // constraint is lost or the execution limit is hit. - a.add("--partial-dir=$PARTIAL_DIR") - // Abort rather than hang if the network stalls for 5 minutes. - a.add("--timeout=300") - a.add("--no-perms") - a.add("--no-owner") - a.add("--no-group") - a.add("--omit-dir-times") - a.add("-e") - a.add(rsh) - for (e in f.excludes) a.add("--exclude=$e") - // --delete-after, not --delete. Deleting during the transfer removes the - // server's copy of a file before its replacement has arrived, and this - // worker is routinely killed mid-run, so a rename on the phone plus one - // interruption leaves the server with neither copy. Deleting only after - // everything is transferred keeps the old copy until the new one is - // safely there. It is as old as rsync, so no remote is excluded. - if (f.delete) a.add("--delete-after") - a.add(withSlash(f.local)) - a.add("${remote.user}@${hostArg(remote.host)}:${withSlash(f.remotePath)}") - return a - } - - private fun withSlash(p: String): String = if (p.endsWith("/")) p else "$p/" - - // An IPv6 literal must be bracketed in rsync's USER@HOST:PATH, or rsync - // splits the destination on the first colon of the address. - private fun hostArg(h: String): String = if (h.contains(":")) "[$h]" else h - - // runFolder execs rsync for one folder, appending every output line to the - // log, and returns rsync's exit code. Blocks the calling thread (run it on - // an IO dispatcher). If the caller is cancelled (worker stopped, schedule - // replaced, constraints lost) the watchdog kills rsync rather than leave it - // running detached; killing it also closes its pipes, which unblocks the - // log reader below. destroy() on an already-exited process is a no-op. - suspend fun runFolder(ctx: Context, remote: Remote, f: Folder, log: SyncLog): Outcome = coroutineScope { - val cmd = listOf(Native.rsync(ctx).absolutePath) + args(Native.rsh(ctx).absolutePath, remote, f) - log.line("rsync ${cmd.drop(1).joinToString(" ")}") - val pb = ProcessBuilder(cmd).redirectErrorStream(true) - pb.environment().putAll(Keys.env(ctx, remote)) - val p = pb.start() - val watchdog = launch(Dispatchers.IO) { try { awaitCancellation() } finally { terminate(p) } } - try { - val t = tally(p.inputStream) { log.line(it) } - if (t.sent > 0) log.line("transferred ${t.sent} item(s)") - if (t.deleted > 0) log.line("deleted ${t.deleted} item(s) on the server") - Outcome(p.waitFor(), t.unreachable, t.deleted) - } finally { - watchdog.cancelAndJoin() - } - } - - // A hostile or broken remote can print an arbitrarily long line. Keep - // draining its pipe so rsync cannot deadlock, but retain only a bounded - // prefix for the log. - internal data class Tally(val sent: Long, val deleted: Long, val unreachable: Boolean) - - // tally reads rsync's merged output, counts what it did, and passes - // everything worth keeping to log. Separated from the process so it can be - // tested against real rsync output without spawning one. It streams: a - // first sync of a large library must not be buffered to be counted. - internal fun tally(input: InputStream, log: (String) -> Unit): Tally { - var sent = 0L - var deleted = 0L - var unreachable = false - boundedLines(input) { - if (it.startsWith(UNREACHABLE)) unreachable = true - if (it.startsWith(DELETED)) deleted++ - // One line per transferred file would bury the deletions and the - // errors, and on a first sync would outgrow the whole log. - if (it.startsWith(SENT)) sent++ else log(it) - } - return Tally(sent, deleted, unreachable) - } - - internal fun boundedLines(input: InputStream, emit: (String) -> Unit) { - val line = ByteArrayOutputStream() - val buf = ByteArray(4096) - var truncated = false - while (true) { - val n = input.read(buf) - if (n < 0) break - for (i in 0 until n) { - val b = buf[i].toInt() and 0xff - if (b == '\n'.code) { - emitLine(line, truncated, emit) - line.reset() - truncated = false - } else if (line.size() < MAX_OUTPUT_LINE_BYTES) { - line.write(b) - } else { - truncated = true - } - } - } - if (line.size() > 0 || truncated) emitLine(line, truncated, emit) - } - - private fun emitLine(line: ByteArrayOutputStream, truncated: Boolean, emit: (String) -> Unit) { - val value = String(line.toByteArray(), Charsets.UTF_8).removeSuffix("\r") + - if (truncated) " [truncated]" else "" - emit(value) - } - - private fun terminate(p: Process) { - p.destroy() - if (!p.waitFor(5, TimeUnit.SECONDS)) { - p.destroyForcibly() - p.waitFor() - } - } -} diff --git a/app/src/main/java/invalid/lena/rsend/SyncWorker.kt b/app/src/main/java/invalid/lena/rsend/SyncWorker.kt index 6bc2b2c..4f619dc 100644 --- a/app/src/main/java/invalid/lena/rsend/SyncWorker.kt +++ b/app/src/main/java/invalid/lena/rsend/SyncWorker.kt @@ -107,7 +107,7 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, log.line("error: local source \"${f.local}\" is not a readable directory") 1 } else try { - val r = RsyncRunner.runFolder(ctx, remote, f, log) + val r = Rsync.runFolder(ctx, remote, f, log) if (r.unreachable) unreachable.add(f.remoteName) deleted += r.deleted r.code @@ -118,7 +118,7 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, 1 } log.line("exit=$code") - if (!RsyncRunner.succeeded(code, f.delete)) { + if (!Rsync.succeeded(code, f.delete)) { ok = false failed.add(label) } 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