aboutsummaryrefslogtreecommitdiff
path: root/app/src/test/java/invalid
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-16 00:00:00 +0000
committerLena <lena@omega>2026-08-16 00:00:00 +0000
commit0e8a87f2ad2bd40d34996374aebb6dff070e4b5f (patch)
tree0d233593ac24181b58fea9c4a68d58ef070482f6 /app/src/test/java/invalid
parentbeaa0c970d6c3538119b8a34a3f2f754b45e54cf (diff)
downloadrsend-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/java/invalid')
-rw-r--r--app/src/test/java/invalid/lena/rsend/ConfigTest.kt394
-rw-r--r--app/src/test/java/invalid/lena/rsend/RsyncRunnerTest.kt135
-rw-r--r--app/src/test/java/invalid/lena/rsend/SchedulerTest.kt14
-rw-r--r--app/src/test/java/invalid/lena/rsend/SyncLogTest.kt16
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))
+ }
+}