aboutsummaryrefslogtreecommitdiff
path: root/app
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
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')
-rw-r--r--app/src/main/AndroidManifest.xml8
-rw-r--r--app/src/main/java/invalid/lena/rsend/App.kt11
-rw-r--r--app/src/main/java/invalid/lena/rsend/Config.kt420
-rw-r--r--app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt76
-rw-r--r--app/src/main/java/invalid/lena/rsend/FolderPickerActivity.kt66
-rw-r--r--app/src/main/java/invalid/lena/rsend/FolderRules.kt108
-rw-r--r--app/src/main/java/invalid/lena/rsend/KeyVault.kt19
-rw-r--r--app/src/main/java/invalid/lena/rsend/Keys.kt131
-rw-r--r--app/src/main/java/invalid/lena/rsend/LastSync.kt37
-rw-r--r--app/src/main/java/invalid/lena/rsend/LogActivity.kt26
-rw-r--r--app/src/main/java/invalid/lena/rsend/MainActivity.kt181
-rw-r--r--app/src/main/java/invalid/lena/rsend/Native.kt44
-rw-r--r--app/src/main/java/invalid/lena/rsend/RemoteActivity.kt201
-rw-r--r--app/src/main/java/invalid/lena/rsend/RsyncRunner.kt142
-rw-r--r--app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt26
-rw-r--r--app/src/main/java/invalid/lena/rsend/Scheduler.kt10
-rw-r--r--app/src/main/java/invalid/lena/rsend/SyncLog.kt106
-rw-r--r--app/src/main/java/invalid/lena/rsend/SyncWorker.kt211
-rw-r--r--app/src/main/res/layout/activity_folder.xml6
-rw-r--r--app/src/main/res/layout/activity_log.xml74
-rw-r--r--app/src/main/res/layout/activity_picker.xml51
-rw-r--r--app/src/main/res/layout/activity_remote.xml4
-rw-r--r--app/src/main/res/layout/activity_schedule.xml7
-rw-r--r--app/src/main/res/layout/app_toolbar.xml4
-rw-r--r--app/src/main/res/layout/item_dir.xml31
-rw-r--r--app/src/main/res/mipmap-anydpi/ic_launcher.xml (renamed from app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml)0
-rw-r--r--app/src/main/res/xml/backup_rules.xml25
-rw-r--r--app/src/main/res/xml/backup_rules_legacy.xml12
-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
32 files changed, 2088 insertions, 508 deletions
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2062a2a..4aeeb9b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,14 +10,19 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
+ <!-- WorkManager reschedules persisted periodic work after a reboot. -->
+ <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".App"
android:allowBackup="false"
+ android:dataExtractionRules="@xml/backup_rules"
+ android:fullBackupContent="@xml/backup_rules_legacy"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
- android:theme="@style/Theme.Rsend">
+ android:theme="@style/Theme.Rsend"
+ android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
@@ -51,6 +56,7 @@
<!-- WorkManager runs the sync as a dataSync foreground service. -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
+ android:exported="false"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application>
diff --git a/app/src/main/java/invalid/lena/rsend/App.kt b/app/src/main/java/invalid/lena/rsend/App.kt
index 55ed66e..0251d7d 100644
--- a/app/src/main/java/invalid/lena/rsend/App.kt
+++ b/app/src/main/java/invalid/lena/rsend/App.kt
@@ -3,17 +3,18 @@ package invalid.lena.rsend
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
-import android.os.Build
// App creates the notification channel used by the foreground sync service.
class App : Application() {
override fun onCreate() {
super.onCreate()
- if (Build.VERSION.SDK_INT >= 26) {
- val ch = NotificationChannel(CHANNEL, "Sync", NotificationManager.IMPORTANCE_LOW)
- getSystemService(NotificationManager::class.java).createNotificationChannel(ch)
- }
+ val ch = NotificationChannel(CHANNEL, "Sync", NotificationManager.IMPORTANCE_LOW)
+ getSystemService(NotificationManager::class.java).createNotificationChannel(ch)
+ // Apply on every process start, including one launched by an old
+ // WorkManager request after an app upgrade. This retires stale unique
+ // names without waiting for the dashboard to be opened.
+ Scheduler.apply(this)
}
companion object {
diff --git a/app/src/main/java/invalid/lena/rsend/Config.kt b/app/src/main/java/invalid/lena/rsend/Config.kt
index 69f3869..25bf338 100644
--- a/app/src/main/java/invalid/lena/rsend/Config.kt
+++ b/app/src/main/java/invalid/lena/rsend/Config.kt
@@ -4,13 +4,16 @@ import android.content.Context
import android.util.AtomicFile
import org.json.JSONArray
import org.json.JSONObject
+import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileNotFoundException
+import java.io.InputStream
+import java.util.Base64
// Config is rsend's whole state: the named remote targets, the schedule, and
// the folders to push. It is stored as plain JSON in app-private storage.
-// config.json owns the host-key pins (Remote.hostKey); the known_hosts file
-// rsh reads is regenerated from them before every sync.
+// config.json owns the host-key pins (Remote.hostKey). The known_hosts file rsh
+// reads is derived from the one remote used by the current connection.
data class Remote(
val name: String = "",
@@ -22,6 +25,48 @@ data class Remote(
fun pinned(): Boolean = hostKey.isNotEmpty()
}
+object RemoteRules {
+ private const val MAX_NAME_CHARS = 128
+ private const val MAX_HOST_CHARS = 255
+ private const val MAX_USER_CHARS = 128
+ private const val MAX_HOST_KEY_CHARS = 16 * 1024
+ private val user = Regex("[A-Za-z0-9._-]+")
+ private val hostKeyTypes = setOf(
+ "ssh-ed25519",
+ "ecdsa-sha2-nistp256",
+ "ecdsa-sha2-nistp384",
+ "ecdsa-sha2-nistp521",
+ "ssh-rsa",
+ )
+
+ fun nameAllowed(value: String): Boolean =
+ value.isNotEmpty() && value.length <= MAX_NAME_CHARS && value.none { it.isISOControl() }
+
+ fun userAllowed(value: String): Boolean =
+ value.length <= MAX_USER_CHARS && !value.startsWith('-') && user.matches(value)
+
+ fun hostAllowed(value: String): Boolean =
+ value.isNotEmpty() && value.length <= MAX_HOST_CHARS && !value.startsWith('-') && value.all {
+ it in 'A'..'Z' || it in 'a'..'z' || it in '0'..'9' ||
+ it == '.' || it == '-' || it == '_' ||
+ it == ':' || it == '%'
+ }
+
+ fun hostKeyAllowed(remote: Remote): Boolean {
+ if (remote.hostKey.isEmpty()) return true
+ if (remote.hostKey.length > MAX_HOST_KEY_CHARS) return false
+ val parts = remote.hostKey.split(' ')
+ if (parts.size != 3 || parts.any(String::isEmpty)) return false
+ val address = if (remote.port == 22) remote.host else "[${remote.host}]:${remote.port}"
+ if (parts[0] != address || parts[1] !in hostKeyTypes) return false
+ return try {
+ Base64.getDecoder().decode(parts[2]).isNotEmpty()
+ } catch (_: IllegalArgumentException) {
+ false
+ }
+ }
+}
+
data class Schedule(
val enabled: Boolean = false,
val intervalMinutes: Int = 120,
@@ -49,12 +94,13 @@ data class Config(
) {
fun remote(name: String): Remote? = remotes.firstOrNull { it.name == name }
- // syncReady reports whether a sync can do useful work: an identity key,
- // at least one folder, and at least one complete, pinned remote. Folders
- // pointing at a broken remote fail individually during the run.
+ // syncReady reports whether a run can do useful work: an identity key and
+ // at least one folder with a pinned target. Requiring every folder to be
+ // ready would let one half-configured folder, a remote added but not yet
+ // pinned, stop the backups that were already working. A folder whose
+ // remote is not pinned fails on its own and is named in the log.
fun syncReady(keyExists: Boolean): Boolean =
- keyExists && folders.isNotEmpty() &&
- remotes.any { it.host.isNotEmpty() && it.user.isNotEmpty() && it.pinned() }
+ keyExists && folders.any { remote(it.remoteName)?.pinned() == true }
fun toJson(): JSONObject = JSONObject().apply {
put("remotes", JSONArray().apply {
@@ -89,48 +135,51 @@ data class Config(
}
companion object {
+ internal const val MAX_CONFIG_BYTES = 1024 * 1024
+ private const val MAX_REMOTES = 64
+ private const val MAX_FOLDERS = 256
fun file(ctx: Context): File = File(ctx.filesDir, "config.json")
+ private fun recoveryFile(ctx: Context): File = File(ctx.filesDir, "config-recovery.txt")
+ private fun legacyFile(ctx: Context): File = File(ctx.filesDir, "config.json.0.1")
- // legacyShape reports whether o is a pre-0.2 config: no "remotes" array,
- // but the single-remote "remote" object 0.1.x always wrote.
- fun legacyShape(o: JSONObject): Boolean =
- o.optJSONArray("remotes") == null && o.optJSONObject("remote") != null
-
+ @Synchronized
fun load(ctx: Context): Config {
val f = file(ctx)
- val migrated: Boolean
- val c = try {
- val text = AtomicFile(f).openRead().bufferedReader().use { it.readText() }
- val o = JSONObject(text)
- migrated = legacyShape(o)
- // Only the legacy single-remote shape needs the old pin file.
- var pin = ""
- if (migrated) {
- val kh = Keys.knownHosts(ctx)
- if (kh.exists()) pin = kh.readText().trim()
- }
- fromJson(o, pin)
+ val text = try {
+ AtomicFile(f).openRead().use { readText(it, MAX_CONFIG_BYTES) }
} catch (_: FileNotFoundException) {
return Config()
} catch (e: Exception) {
- throw IllegalStateException("could not read ${f.name}: ${e.message}", e)
+ return recover(ctx, f, "read failed", e)
+ }
+ return try {
+ val o = JSONObject(text)
+ if (!legacyShape(o)) return fromJson(o)
+ // Retire the old shape on the first read rather than waiting
+ // for the user to edit something, and write it back so this
+ // path is never taken twice: toJson always emits "remotes", so
+ // legacyShape cannot match again.
+ val upgraded = upgradeLegacy(o, Keys.legacyPin(ctx))
+ val migrated = fromJson(upgraded)
+ preserveLegacy(ctx, text, o, migrated)
+ save(ctx, migrated)
+ migrated
+ } catch (e: Exception) {
+ recover(ctx, f, "parse failed", e)
}
- // Retire the old shape on the first read rather than waiting for the
- // user to edit something. Without this a working 0.1.x setup keeps
- // its pre-0.2 config.json indefinitely, and deleting the fallback in
- // a later release would silently strand it. Writing the new shape
- // also makes the fallback unreachable from here on: toJson always
- // emits "remotes", so legacyShape can never match again.
- if (migrated) save(ctx, c)
- return c
}
// AtomicFile keeps the previous complete config if a write is interrupted.
+ @Synchronized
fun save(ctx: Context, c: Config) {
+ val json = c.toJson()
+ fromJson(json)
+ val bytes = json.toString(2).toByteArray(Charsets.UTF_8)
+ require(bytes.size <= MAX_CONFIG_BYTES) { "config exceeds $MAX_CONFIG_BYTES bytes" }
val file = AtomicFile(file(ctx))
val out = file.startWrite()
try {
- out.write(c.toJson().toString(2).toByteArray())
+ out.write(bytes)
file.finishWrite(out)
} catch (e: Exception) {
file.failWrite(out)
@@ -138,75 +187,288 @@ data class Config(
}
}
- // fromJson also reads the pre-0.2 single-remote shape once: the old
- // "remote" object becomes the sole entry, named after its host, every
- // folder points at it (the folder's old "remote" field was the
- // destination path), and legacyPin carries the known_hosts line over.
- // Remove this fallback after a release or two.
- fun fromJson(o: JSONObject, legacyPin: String = ""): Config {
- val ra = o.optJSONArray("remotes")
- val legacy = if (ra == null) o.optJSONObject("remote") else null
- val legacyName = legacy?.optString("host") ?: ""
+ // legacyShape reports whether o is the pre-0.2 config 0.1.x wrote: one
+ // "remote" object instead of a "remotes" array.
+ fun legacyShape(o: JSONObject): Boolean =
+ !o.has("remotes") && o.optJSONObject("remote") != null
- val remotes = ArrayList<Remote>()
- if (ra != null) {
- for (i in 0 until ra.length()) {
- val ro = ra.getJSONObject(i)
- remotes.add(
- Remote(
- name = ro.optString("name"),
- host = ro.optString("host"),
- port = ro.optInt("port", 22),
- user = ro.optString("user"),
- hostKey = ro.optString("hostKey"),
- )
+ // upgradeLegacy rewrites a 0.1.x config into the current shape so the
+ // strict parser validates it exactly like any other config. The single
+ // remote becomes the sole entry named after its host, every retained
+ // safe folder points at it, and the folder's old "remote" field was the
+ // destination path. pin is the known_hosts line 0.1.x kept in a separate
+ // file; it is carried over only if it still describes this host and port, so a
+ // pin that no longer matches costs one Test connection rather than the
+ // whole config.
+ //
+ // This is a deliberate, documented exception to the no-compatibility
+ // rule: 0.1.3 is the last published release, so it is what every
+ // upgrading user has, and the alternative is that all of them silently
+ // lose their remotes, pins and folders. Delete it in 0.4.0.
+ fun upgradeLegacy(o: JSONObject, pin: String): JSONObject {
+ val old = o.getJSONObject("remote")
+ val host = old.optString("host").trim().removeSurrounding("[", "]").trim()
+ val port = old.optInt("port", 22)
+ val user = old.optString("user")
+ val candidate = Remote(
+ name = host,
+ host = host,
+ port = port,
+ user = user,
+ hostKey = pin,
+ )
+ val endpointAllowed = RemoteRules.nameAllowed(host) &&
+ RemoteRules.hostAllowed(host) && RemoteRules.userAllowed(user) && port in 1..65535
+ val remote = JSONObject()
+ .put("name", host)
+ .put("host", host)
+ .put("port", port)
+ .put("user", user)
+ .put("hostKey", "")
+ if (endpointAllowed && RemoteRules.hostKeyAllowed(candidate) && pin.isNotEmpty()) {
+ remote.put("hostKey", pin)
+ }
+
+ val folders = JSONArray()
+ val accepted = ArrayList<Folder>()
+ val fa = o.optJSONArray("folders") ?: JSONArray()
+ for (i in 0 until fa.length()) {
+ if (!endpointAllowed || accepted.size >= MAX_FOLDERS) break
+ val f = fa.getJSONObject(i)
+ val oldExcludes = f.optJSONArray("excludes") ?: JSONArray()
+ if (oldExcludes.length() > FolderRules.MAX_EXCLUDES) continue
+ val excludes = JSONArray()
+ for (j in 0 until oldExcludes.length()) {
+ excludes.put(oldExcludes.getString(j))
+ }
+ val folder = Folder(
+ name = f.optString("name"),
+ local = f.optString("local"),
+ remoteName = host,
+ remotePath = f.optString("remote"),
+ delete = f.optBoolean("delete", false),
+ excludes = (0 until excludes.length()).map(excludes::getString),
+ )
+ val trial = Config(
+ remotes = if (endpointAllowed) listOf(candidate.copy(hostKey = "")) else emptyList(),
+ folders = accepted,
+ )
+ if (FolderRules.nameAllowed(folder.name) &&
+ FolderRules.localPathAllowed(folder.local) &&
+ FolderRules.remotePathAllowed(folder.remotePath) &&
+ FolderRules.excludesAllowed(folder.excludes) &&
+ FolderRules.destinationConflict(trial, folder, -1) == null
+ ) {
+ accepted.add(folder)
+ folders.put(
+ JSONObject()
+ .put("name", folder.name)
+ .put("local", folder.local)
+ .put("remoteName", host)
+ .put("remotePath", folder.remotePath)
+ .put("delete", folder.delete)
+ .put("excludes", excludes),
)
}
- } else if (legacyName.isNotEmpty()) {
+ }
+ val oldSchedule = o.optJSONObject("schedule") ?: JSONObject()
+ val schedule = JSONObject()
+ .put("enabled", oldSchedule.optBoolean("enabled", false))
+ .put(
+ "intervalMinutes",
+ oldSchedule.optInt("intervalMinutes", 120)
+ .coerceAtLeast(Schedule.MIN_INTERVAL_MINUTES),
+ )
+ .put("wifiOnly", oldSchedule.optBoolean("wifiOnly", true))
+ .put("requireCharging", oldSchedule.optBoolean("requireCharging", false))
+ return JSONObject()
+ .put("remotes", if (endpointAllowed) JSONArray().put(remote) else JSONArray())
+ .put("schedule", schedule)
+ .put("folders", folders)
+ }
+
+ private fun preserveLegacy(ctx: Context, text: String, old: JSONObject, migrated: Config) {
+ atomicWrite(legacyFile(ctx), text.toByteArray(Charsets.UTF_8))
+ val oldFolders = old.optJSONArray("folders")?.length() ?: 0
+ val endpointOmitted = old.optJSONObject("remote")?.optString("host").orEmpty().isNotEmpty() &&
+ migrated.remotes.isEmpty()
+ val omitted = oldFolders - migrated.folders.size
+ if (!endpointOmitted && omitted == 0) return
+ val what = buildList {
+ if (endpointOmitted) add("the invalid remote")
+ if (omitted > 0) add("$omitted unsafe folder mapping(s)")
+ }.joinToString(" and ")
+ val message =
+ "The 0.1.x configuration was upgraded with omissions: $what. " +
+ "The exact original was preserved as ${legacyFile(ctx).name}."
+ atomicWrite(recoveryFile(ctx), (message + "\n").toByteArray(Charsets.UTF_8))
+ }
+
+ fun fromJson(o: JSONObject): Config {
+ requireFields(o, setOf("remotes", "schedule", "folders"))
+ val ra = o.getJSONArray("remotes")
+ require(ra.length() <= MAX_REMOTES) { "too many remotes" }
+ val remotes = ArrayList<Remote>()
+ for (i in 0 until ra.length()) {
+ val ro = ra.getJSONObject(i)
+ requireFields(ro, setOf("name", "host", "port", "user", "hostKey"))
remotes.add(
Remote(
- name = legacyName,
- host = legacyName,
- port = legacy!!.optInt("port", 22),
- user = legacy.optString("user"),
- hostKey = legacyPin,
+ name = string(ro, "name"),
+ host = string(ro, "host"),
+ port = integer(ro, "port"),
+ user = string(ro, "user"),
+ hostKey = string(ro, "hostKey"),
)
)
}
+ require(remotes.map { it.name }.distinct().size == remotes.size) { "remote names must be unique" }
+ require(remotes.all { RemoteRules.nameAllowed(it.name) }) { "remote name is invalid" }
+ require(remotes.all { RemoteRules.hostAllowed(it.host) }) { "remote host is invalid" }
+ require(remotes.all { RemoteRules.userAllowed(it.user) }) { "remote user is invalid" }
+ require(remotes.all { it.port in 1..65535 }) { "remote port must be between 1 and 65535" }
+ require(remotes.all(RemoteRules::hostKeyAllowed)) { "remote host key is invalid" }
- val s = o.optJSONObject("schedule") ?: JSONObject()
- val fa = o.optJSONArray("folders") ?: JSONArray()
+ val s = o.getJSONObject("schedule")
+ requireFields(s, setOf("enabled", "intervalMinutes", "wifiOnly", "requireCharging"))
+ val interval = integer(s, "intervalMinutes")
+ require(interval >= Schedule.MIN_INTERVAL_MINUTES) {
+ "schedule interval must be at least ${Schedule.MIN_INTERVAL_MINUTES} minutes"
+ }
+ val fa = o.getJSONArray("folders")
+ require(fa.length() <= MAX_FOLDERS) { "too many folders" }
val folders = ArrayList<Folder>(fa.length())
for (i in 0 until fa.length()) {
val fo = fa.getJSONObject(i)
- val ex = fo.optJSONArray("excludes") ?: JSONArray()
+ requireFields(fo, setOf("name", "local", "remoteName", "remotePath", "delete", "excludes"))
+ val ex = fo.getJSONArray("excludes")
+ require(ex.length() <= FolderRules.MAX_EXCLUDES) { "too many folder excludes" }
val excludes = ArrayList<String>(ex.length())
- for (j in 0 until ex.length()) excludes.add(ex.getString(j))
+ for (j in 0 until ex.length()) {
+ val value = ex.get(j)
+ require(value is String) { "folder exclude must be a string" }
+ excludes.add(value)
+ }
folders.add(
Folder(
- name = fo.optString("name"),
- local = fo.optString("local"),
- // Gate on the legacy shape, not on the legacy host: a
- // 0.1.x config with folders but no host yet still has
- // real paths in the folders' old "remote" field.
- remoteName = if (legacy != null) legacyName else fo.optString("remoteName"),
- remotePath = if (legacy != null) fo.optString("remote") else fo.optString("remotePath"),
- delete = fo.optBoolean("delete", false),
+ name = string(fo, "name"),
+ local = string(fo, "local"),
+ remoteName = string(fo, "remoteName"),
+ remotePath = string(fo, "remotePath"),
+ delete = bool(fo, "delete"),
excludes = excludes,
)
)
}
- return Config(
+ require(folders.all { FolderRules.nameAllowed(it.name) }) { "folder name is invalid" }
+ require(folders.all { FolderRules.localPathAllowed(it.local) }) { "folder local path is invalid" }
+ require(folders.all { FolderRules.remotePathAllowed(it.remotePath) }) { "folder remote path is invalid" }
+ val remoteNames = remotes.map(Remote::name).toSet()
+ require(folders.all { it.remoteName in remoteNames }) { "folder refers to a missing remote" }
+ require(folders.all { f -> FolderRules.excludesAllowed(f.excludes) }) { "folder exclude is invalid" }
+ val config = Config(
remotes = remotes,
schedule = Schedule(
- enabled = s.optBoolean("enabled", false),
- intervalMinutes = s.optInt("intervalMinutes", 120)
- .coerceAtLeast(Schedule.MIN_INTERVAL_MINUTES),
- wifiOnly = s.optBoolean("wifiOnly", true),
- requireCharging = s.optBoolean("requireCharging", false),
+ enabled = bool(s, "enabled"),
+ intervalMinutes = interval,
+ wifiOnly = bool(s, "wifiOnly"),
+ requireCharging = bool(s, "requireCharging"),
),
folders = folders,
)
+ require(FolderRules.firstDestinationConflict(config) == null) {
+ "folder remote destinations conflict"
+ }
+ return config
+ }
+
+ private fun requireFields(o: JSONObject, expected: Set<String>) {
+ val missing = expected.firstOrNull { !o.has(it) }
+ require(missing == null) { "missing config field: $missing" }
+ val unexpected = o.keys().asSequence().firstOrNull { it !in expected }
+ require(unexpected == null) { "unexpected config field: $unexpected" }
+ }
+
+ private fun string(o: JSONObject, name: String): String {
+ val value = o.get(name)
+ require(value is String) { "$name must be a string" }
+ return value
+ }
+
+ private fun integer(o: JSONObject, name: String): Int {
+ val value = o.get(name)
+ require(value is Int) { "$name must be an integer" }
+ return value
+ }
+
+ private fun bool(o: JSONObject, name: String): Boolean {
+ val value = o.get(name)
+ require(value is Boolean) { "$name must be a boolean" }
+ return value
+ }
+
+ fun recoveryMessage(ctx: Context): String {
+ val f = recoveryFile(ctx)
+ return try {
+ AtomicFile(f).openRead().use { readText(it, 4096) }
+ } catch (_: FileNotFoundException) {
+ ""
+ }
+ }
+
+ fun clearRecoveryMessage(ctx: Context) {
+ AtomicFile(recoveryFile(ctx)).delete()
+ }
+
+ private fun recover(ctx: Context, source: File, what: String, error: Exception): Config {
+ val broken = quarantine(source)
+ val detail = (error.message ?: error.javaClass.simpleName)
+ .replace('\n', ' ')
+ .replace('\r', ' ')
+ .take(300)
+ val message =
+ "Configuration $what and was reset. The original file was preserved as ${broken.name}. " +
+ "Reason: $detail"
+ atomicWrite(recoveryFile(ctx), (message + "\n").toByteArray())
+ return Config()
+ }
+
+ internal fun quarantine(source: File): File {
+ var n = 0
+ var target: File
+ do {
+ val suffix = if (n == 0) ".broken" else ".broken.$n"
+ target = File(source.parentFile, source.name + suffix)
+ n++
+ } while (target.exists())
+ if (!source.renameTo(target)) {
+ throw IllegalStateException("could not preserve unreadable ${source.name}")
+ }
+ return target
+ }
+
+ internal fun readText(input: InputStream, limit: Int): String {
+ val out = ByteArrayOutputStream(minOf(limit, 8192))
+ val buf = ByteArray(4096)
+ while (true) {
+ val n = input.read(buf)
+ if (n < 0) break
+ if (out.size() + n > limit) throw IllegalArgumentException("file exceeds $limit bytes")
+ out.write(buf, 0, n)
+ }
+ return String(out.toByteArray(), Charsets.UTF_8)
+ }
+
+ private fun atomicWrite(f: File, bytes: ByteArray) {
+ val file = AtomicFile(f)
+ val out = file.startWrite()
+ try {
+ out.write(bytes)
+ file.finishWrite(out)
+ } catch (e: Exception) {
+ file.failWrite(out)
+ throw e
+ }
}
}
}
diff --git a/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt b/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt
index 14e04c9..5565bff 100644
--- a/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt
+++ b/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt
@@ -8,10 +8,11 @@ import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.EditText
import android.widget.Spinner
-import android.widget.Switch
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.widget.SwitchCompat
import java.io.File
// FolderEditActivity adds or edits one folder mapping. index < 0 means a new
@@ -24,10 +25,10 @@ class FolderEditActivity : AppCompatActivity() {
private lateinit var remoteName: Spinner
private lateinit var remotePath: EditText
private lateinit var excludes: EditText
- private lateinit var delete: Switch
+ private lateinit var delete: SwitchCompat
+ private var mirrorConfirmed = false
- // remoteNames[i] is the real name behind spinner position i; the label
- // differs only for a reference to a remote that no longer exists.
+ // remoteNames[i] is the real name behind spinner position i.
private var remoteNames: List<String> = emptyList()
// The picker returns a real filesystem path; drop it into the local field and
@@ -63,18 +64,10 @@ class FolderEditActivity : AppCompatActivity() {
excludes.setText(f.excludes.joinToString(", "))
delete.isChecked = f.delete
- // A reference to a missing remote (hand-edited config) stays selectable
- // as "name (missing)" so Save cannot silently repoint the folder, which
- // with mirror on would --delete into the wrong host.
val names = cfg.remotes.map { it.name }.toMutableList()
- val labels = names.toMutableList()
- if (f.remoteName.isNotEmpty() && f.remoteName !in names) {
- names.add(0, f.remoteName)
- labels.add(0, "${f.remoteName} (missing)")
- }
remoteNames = names
remoteName.adapter =
- ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, labels)
+ ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, names)
val sel = names.indexOf(f.remoteName)
if (sel >= 0) remoteName.setSelection(sel)
@@ -105,16 +98,67 @@ class FolderEditActivity : AppCompatActivity() {
delete = delete.isChecked,
excludes = excludes.text.toString().split(",").map { it.trim() }.filter { it.isNotEmpty() },
)
- // An empty path would rsync / (local) or the rrsync root (remote):
- // refuse rather than sync the world.
+ // Neither filesystem root is a reasonable backup boundary. Require a
+ // local path below root and a named remote destination tree.
if (f.local.isEmpty() || f.remotePath.isEmpty()) {
Toast.makeText(this, "Set both local and remote paths first.", Toast.LENGTH_LONG).show()
return false
}
- if (!File(f.local).exists()) {
+ if (!FolderRules.nameAllowed(f.name) || !FolderRules.remotePathAllowed(f.remotePath) ||
+ !FolderRules.excludesAllowed(f.excludes)
+ ) {
+ Toast.makeText(
+ this,
+ "Names, remote paths, and excludes cannot contain control characters; " +
+ "use a named remote directory without :, ., .., or a bare ~.",
+ Toast.LENGTH_LONG,
+ ).show()
+ return false
+ }
+ if (!FolderRules.localPathAllowed(f.local)) {
+ Toast.makeText(this, "Local path must be an absolute path below the filesystem root.", Toast.LENGTH_LONG)
+ .show()
+ return false
+ }
+ val localFile = File(f.local)
+ if (localFile.exists() && !localFile.isDirectory) {
+ Toast.makeText(this, "Local path is not a directory.", Toast.LENGTH_LONG).show()
+ return false
+ }
+ if (localFile.exists() && !FolderRules.sourceReadable(f.local)) {
+ Toast.makeText(this, "Local directory cannot be read.", Toast.LENGTH_LONG).show()
+ return false
+ }
+ if (!localFile.exists()) {
Toast.makeText(this, "Warning: local path does not exist yet.", Toast.LENGTH_LONG).show()
}
val cfg = Config.load(this)
+ val conflict = FolderRules.destinationConflict(cfg, f, index)
+ if (conflict != null) {
+ val label = conflict.name.ifEmpty { conflict.remotePath }
+ Toast.makeText(
+ this,
+ "Remote destination conflicts with folder \"$label\". Use separate trees and one path style per remote.",
+ Toast.LENGTH_LONG,
+ ).show()
+ return false
+ }
+ val oldMirror = cfg.folders.getOrNull(index)?.delete == true
+ if (f.delete && !oldMirror && !mirrorConfirmed) {
+ AlertDialog.Builder(this)
+ .setTitle("Enable exact mirror?")
+ .setMessage(
+ "Mirror follows rsync exactly: files absent locally are deleted remotely. " +
+ "An empty local folder empties the remote destination."
+ )
+ .setPositiveButton("Enable mirror") { _, _ ->
+ mirrorConfirmed = true
+ if (saveFolder()) finish()
+ }
+ .setNegativeButton("Cancel", null)
+ .show()
+ return false
+ }
val list = cfg.folders.toMutableList()
if (index in list.indices) list[index] = f else list.add(f)
Config.save(this, cfg.copy(folders = list))
diff --git a/app/src/main/java/invalid/lena/rsend/FolderPickerActivity.kt b/app/src/main/java/invalid/lena/rsend/FolderPickerActivity.kt
index 097be3b..0e403e0 100644
--- a/app/src/main/java/invalid/lena/rsend/FolderPickerActivity.kt
+++ b/app/src/main/java/invalid/lena/rsend/FolderPickerActivity.kt
@@ -9,6 +9,7 @@ import android.widget.TextView
import androidx.activity.addCallback
import androidx.appcompat.app.AppCompatActivity
import java.io.File
+import java.nio.file.Files
// FolderPickerActivity browses the real filesystem and returns one directory's
// absolute path in the "path" result extra. The app holds all-files access, so
@@ -17,8 +18,13 @@ import java.io.File
// above it; SD cards and other volumes are still reachable by typing the path.
class FolderPickerActivity : AppCompatActivity() {
+ companion object {
+ private const val MAX_VISIBLE_DIRECTORIES = 500
+ }
+
private lateinit var pathView: TextView
private lateinit var list: android.widget.LinearLayout
+ private lateinit var use: Button
private lateinit var root: File
private lateinit var current: File
@@ -31,15 +37,16 @@ class FolderPickerActivity : AppCompatActivity() {
pathView = findViewById(R.id.currentPath)
list = findViewById(R.id.list)
- root = getSystemService(StorageManager::class.java).primaryStorageVolume.directory
- ?: Environment.getExternalStorageDirectory()
+ root = (getSystemService(StorageManager::class.java).primaryStorageVolume.directory
+ ?: Environment.getExternalStorageDirectory()).canonicalFile
// Start where the field already points, if that is a directory under the
// root; otherwise at the root itself.
- val start = intent.getStringExtra("start")?.let { File(it) }
- current = if (start != null && start.isDirectory && underRoot(start)) start else root
+ val start = intent.getStringExtra("start")?.let { safeDirectory(File(it)) }
+ current = start ?: root
- findViewById<Button>(R.id.use).setOnClickListener {
+ use = findViewById(R.id.use)
+ use.setOnClickListener {
setResult(RESULT_OK, Intent().putExtra("path", current.absolutePath))
finish()
}
@@ -67,23 +74,58 @@ class FolderPickerActivity : AppCompatActivity() {
addRow("..", current.parentFile!!)
}
- val dirs = current.listFiles { f -> f.isDirectory }
- ?.sortedBy { it.name.lowercase() } ?: emptyList()
+ // null means the directory could not be read at all, which is a
+ // different thing from having no subfolders and must not read as an
+ // invitation to pick it.
+ val listed = try {
+ Files.newDirectoryStream(current.toPath()) { Files.isDirectory(it) }.use { stream ->
+ val found = stream.asSequence().map { it.toFile() }.mapNotNull(::safeDirectory)
+ .distinctBy { it.absolutePath }.take(MAX_VISIBLE_DIRECTORIES + 1).toList()
+ Listing(
+ found.take(MAX_VISIBLE_DIRECTORIES).sortedBy { it.name.lowercase() },
+ found.size > MAX_VISIBLE_DIRECTORIES,
+ )
+ }
+ } catch (_: Exception) {
+ null
+ }
+ use.isEnabled = listed != null
+ val dirs = listed?.directories ?: emptyList()
for (d in dirs) addRow(d.name, d)
- if (dirs.isEmpty()) {
- list.addView(TextView(this).apply {
- text = "No subfolders here. Tap \"Use this folder\" to pick it."
- setTextAppearance(R.style.TextAppearance_Rsend_Caption)
- })
+ if (listed?.truncated == true) {
+ list.addView(message("More folders exist. Type a deeper path to reach one not shown."))
+ } else if (dirs.isEmpty()) {
+ list.addView(
+ message(
+ if (listed == null) {
+ "Cannot read this folder. Check all-files access on the main screen."
+ } else {
+ "No subfolders here. Tap \"Use this folder\" to pick it."
+ }
+ )
+ )
}
}
+ private data class Listing(val directories: List<File>, val truncated: Boolean)
+
+ private fun message(value: String): TextView = TextView(this).apply {
+ text = value
+ setTextAppearance(R.style.TextAppearance_Rsend_Caption)
+ }
+
// Anchored at a path separator so /storage/emulated/0-evil does not pass
// for a root of /storage/emulated/0.
private fun underRoot(f: File): Boolean =
f.absolutePath == root.absolutePath || f.absolutePath.startsWith(root.absolutePath + "/")
+ private fun safeDirectory(file: File): File? = try {
+ file.canonicalFile.takeIf { it.isDirectory && underRoot(it) }
+ } catch (_: Exception) {
+ null
+ }
+
private fun addRow(label: String, dir: File) {
val row = layoutInflater.inflate(R.layout.item_dir, list, false)
row.findViewById<TextView>(R.id.dirName).text = label
diff --git a/app/src/main/java/invalid/lena/rsend/FolderRules.kt b/app/src/main/java/invalid/lena/rsend/FolderRules.kt
new file mode 100644
index 0000000..1aa07c9
--- /dev/null
+++ b/app/src/main/java/invalid/lena/rsend/FolderRules.kt
@@ -0,0 +1,108 @@
+package invalid.lena.rsend
+
+import java.io.File
+import java.nio.file.Files
+
+// FolderRules holds the small set of path checks shared by the editor and the
+// worker. Remote paths are compared lexically because their filesystem lives
+// on another machine and cannot be resolved safely on the phone.
+object FolderRules {
+ private const val MAX_NAME_CHARS = 256
+ private const val MAX_PATH_CHARS = 4096
+ private const val MAX_EXCLUDE_CHARS = 512
+ internal const val MAX_EXCLUDES = 64
+
+ fun nameAllowed(value: String): Boolean =
+ value.length <= MAX_NAME_CHARS && value.none(Char::isISOControl)
+
+ fun localPathAllowed(path: String): Boolean {
+ if (path.isEmpty() || path.length > MAX_PATH_CHARS || path.any(Char::isISOControl)) return false
+ val file = File(path)
+ if (!file.isAbsolute) return false
+ return try {
+ file.toPath().normalize() != File(File.separator).toPath()
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ // Empty directories are readable sources. Opening a DirectoryStream checks
+ // access without allocating one String for every entry in a large media
+ // directory. The source itself must not be a symlink: rsync follows a
+ // command-line symlink when the source has a trailing slash.
+ fun sourceReadable(path: String): Boolean {
+ if (!localPathAllowed(path)) return false
+ val file = File(path)
+ return try {
+ if (!file.isDirectory || Files.isSymbolicLink(file.toPath())) return false
+ Files.newDirectoryStream(file.toPath()).use { true }
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ fun remotePathAllowed(path: String): Boolean {
+ if (path.isEmpty() || path.length > MAX_PATH_CHARS || path.startsWith(':') ||
+ path.any(Char::isISOControl)
+ ) return false
+ if (path.startsWith('~') && !path.startsWith("~/")) return false
+ if (path.split('/').any { it == "." || it == ".." }) return false
+ return remotePath(path).parts.isNotEmpty()
+ }
+
+ fun excludeAllowed(pattern: String): Boolean =
+ pattern.isNotEmpty() && pattern.length <= MAX_EXCLUDE_CHARS && pattern.none(Char::isISOControl)
+
+ fun excludesAllowed(patterns: List<String>): Boolean =
+ patterns.size <= MAX_EXCLUDES && patterns.all(::excludeAllowed)
+
+ fun destinationConflict(cfg: Config, candidate: Folder, exceptIndex: Int): Folder? {
+ val candidateRemote = cfg.remote(candidate.remoteName)
+ return cfg.folders.withIndex().firstOrNull { (i, existing) ->
+ i != exceptIndex &&
+ sameEndpoint(candidate.remoteName, candidateRemote, existing.remoteName, cfg.remote(existing.remoteName)) &&
+ remotePathsConflict(candidate.remotePath, existing.remotePath)
+ }?.value
+ }
+
+ fun firstDestinationConflict(cfg: Config): Pair<Folder, Folder>? {
+ for ((i, folder) in cfg.folders.withIndex()) {
+ val other = destinationConflict(cfg, folder, i)
+ if (other != null) return folder to other
+ }
+ return null
+ }
+
+ // Absolute and home-relative destinations can resolve to the same tree,
+ // but the phone cannot prove how a remote account's home is laid out. Keep
+ // one style per endpoint and compare conservatively on case too.
+ internal fun remotePathsConflict(a: String, b: String): Boolean {
+ val left = remotePath(a)
+ val right = remotePath(b)
+ if (left.absolute != right.absolute) return true
+ val shorter = minOf(left.parts.size, right.parts.size)
+ return (0 until shorter).all { left.parts[it].equals(right.parts[it], ignoreCase = true) }
+ }
+
+ private fun sameEndpoint(aName: String, a: Remote?, bName: String, b: Remote?): Boolean {
+ if (aName == bName) return true
+ if (a == null || b == null) return false
+ return a.user == b.user && a.port == b.port && a.host.equals(b.host, ignoreCase = true)
+ }
+
+ private data class RemotePath(val absolute: Boolean, val parts: List<String>)
+
+ private fun remotePath(path: String): RemotePath {
+ val absolute = path.startsWith("/")
+ var value = path
+ if (!absolute) {
+ value = when {
+ value == "~" -> ""
+ value.startsWith("~/") -> value.substring(2)
+ else -> value
+ }
+ }
+ val parts = value.split('/').filter(String::isNotEmpty)
+ return RemotePath(absolute, parts)
+ }
+}
diff --git a/app/src/main/java/invalid/lena/rsend/KeyVault.kt b/app/src/main/java/invalid/lena/rsend/KeyVault.kt
index ecbc9cf..e0fe66f 100644
--- a/app/src/main/java/invalid/lena/rsend/KeyVault.kt
+++ b/app/src/main/java/invalid/lena/rsend/KeyVault.kt
@@ -18,9 +18,12 @@ object KeyVault {
private const val IV_LEN = 12
private const val TAG_BITS = 128
- private fun secret(): SecretKey {
+ private fun existing(): SecretKey? {
val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
- (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
+ return (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.secretKey
+ }
+
+ private fun create(): SecretKey {
val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
gen.init(
KeyGenParameterSpec.Builder(
@@ -36,13 +39,21 @@ object KeyVault {
fun encrypt(plain: ByteArray): ByteArray {
val c = Cipher.getInstance(TRANSFORM)
- c.init(Cipher.ENCRYPT_MODE, secret())
+ c.init(Cipher.ENCRYPT_MODE, existing() ?: create())
return c.iv + c.doFinal(plain)
}
+ // Decrypting must never mint a key: doing so would replace the one that
+ // wrapped the stored blob, making it undecryptable forever while looking
+ // like an ordinary cipher failure. Say what happened instead, so the user
+ // is told to import or generate the identity key again.
fun decrypt(blob: ByteArray): ByteArray {
+ require(blob.size >= IV_LEN + TAG_BITS / 8) { "encrypted private key is truncated" }
+ val key = existing() ?: throw IllegalStateException(
+ "the device keystore entry for rsend is gone; generate or import the key again"
+ )
val c = Cipher.getInstance(TRANSFORM)
- c.init(Cipher.DECRYPT_MODE, secret(), GCMParameterSpec(TAG_BITS, blob, 0, IV_LEN))
+ c.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, blob, 0, IV_LEN))
return c.doFinal(blob, IV_LEN, blob.size - IV_LEN)
}
}
diff --git a/app/src/main/java/invalid/lena/rsend/Keys.kt b/app/src/main/java/invalid/lena/rsend/Keys.kt
index 5ba6d67..bd18080 100644
--- a/app/src/main/java/invalid/lena/rsend/Keys.kt
+++ b/app/src/main/java/invalid/lena/rsend/Keys.kt
@@ -2,7 +2,9 @@ package invalid.lena.rsend
import android.content.Context
import android.util.AtomicFile
+import java.io.ByteArrayOutputStream
import java.io.File
+import java.io.FileNotFoundException
// Keys manages the on-device ed25519 identity and the pinned host keys. The
// private key is stored Keystore-encrypted (KeyVault) and only ever exists in
@@ -10,21 +12,29 @@ import java.io.File
object Keys {
private fun keyEnc(ctx: Context): File = File(ctx.filesDir, "id_ed25519.enc")
- private fun publicKey(ctx: Context): File = File(ctx.filesDir, "id_ed25519.pub")
+ private fun knownHosts(ctx: Context): File = File(ctx.filesDir, "known_hosts")
const val MAX_KEY_BYTES = 64 * 1024
+ private const val MAX_ENCRYPTED_KEY_BYTES = MAX_KEY_BYTES + 64
+ private val keyLock = Any()
- fun knownHosts(ctx: Context): File = File(ctx.filesDir, "known_hosts")
-
- fun exists(ctx: Context): Boolean = keyEnc(ctx).exists()
+ fun exists(ctx: Context): Boolean = synchronized(keyLock) {
+ try {
+ AtomicFile(keyEnc(ctx)).openRead().use { }
+ true
+ } catch (_: FileNotFoundException) {
+ false
+ }
+ }
// generate creates a key pair via rsh -keygen (the plaintext key never
// touches disk), stores it encrypted, and returns the public key in
// authorized_keys format. Storage and validation reuse importKey.
- fun generate(ctx: Context): String {
- val r = Native.run(Native.rsh(ctx), listOf("-keygen"))
- if (r.code != 0) throw IllegalStateException(r.output.trim().ifEmpty { "keygen failed" })
- importKey(ctx, r.output.toByteArray())
- return publicKeyText(ctx)
+ fun generate(ctx: Context): String = synchronized(keyLock) {
+ val result = Native.run(Native.rsh(ctx), listOf("-keygen"))
+ if (result.code != 0) {
+ throw IllegalStateException(result.output.trim().ifEmpty { "keygen failed" })
+ }
+ validateAndStore(ctx, result.output.toByteArray(Charsets.UTF_8))
}
// importKey stores a user-supplied private key, replacing any current one.
@@ -32,47 +42,106 @@ object Keys {
// same parser as the transport), so a key that imports here will also sync.
// Throws IllegalArgumentException with rsh's message if the key is unusable
// (wrong type, or passphrase-protected: rsend needs an unencrypted key).
- fun importKey(ctx: Context, pem: ByteArray) {
+ fun importKey(ctx: Context, pem: ByteArray): String = synchronized(keyLock) {
+ validateAndStore(ctx, pem)
+ }
+
+ private fun validateAndStore(ctx: Context, pem: ByteArray): String {
require(pem.size <= MAX_KEY_BYTES) { "private key exceeds $MAX_KEY_BYTES bytes" }
- val r = Native.run(Native.rsh(ctx), listOf("-pubkey"), mapOf("RSH_KEY_DATA" to String(pem)))
+ val r = Native.run(
+ Native.rsh(ctx),
+ listOf("-pubkey"),
+ mapOf("RSH_KEY_DATA" to String(pem, Charsets.UTF_8)),
+ )
if (r.code != 0) throw IllegalArgumentException(r.output.trim().ifEmpty { "invalid private key" })
atomicWrite(keyEnc(ctx), KeyVault.encrypt(pem))
- atomicWrite(publicKey(ctx), (r.output.trim() + "\n").toByteArray())
+ return r.output.trim()
}
- fun publicKeyText(ctx: Context): String =
- if (publicKey(ctx).exists()) publicKey(ctx).readText().trim() else ""
+ fun publicKeyText(ctx: Context): String = synchronized(keyLock) {
+ if (!exists(ctx)) return ""
+ val r = Native.run(Native.rsh(ctx), listOf("-pubkey"), keyEnvironment(ctx))
+ if (r.code != 0) throw IllegalStateException(r.output.trim().ifEmpty { "could not read public key" })
+ r.output.trim()
+ }
- // env returns the RSH_* environment rsh needs. The decrypted key is passed
- // by value so it never touches the filesystem.
- fun env(ctx: Context, port: Int): Map<String, String> = mapOf(
- "RSH_KEY_DATA" to String(KeyVault.decrypt(keyEnc(ctx).readBytes())),
- "RSH_KNOWN_HOSTS" to knownHosts(ctx).absolutePath,
- "RSH_PORT" to port.toString(),
- )
+ // env derives a one-pin known_hosts file for this connection. A shared file
+ // containing every remote's key would let one configured alias authorize a
+ // different alias of the same endpoint.
+ fun env(ctx: Context, remote: Remote): Map<String, String> = synchronized(keyLock) {
+ require(RemoteRules.hostKeyAllowed(remote)) { "remote pin is invalid" }
+ require(remote.pinned()) { "remote is not pinned" }
+ val hosts = knownHosts(ctx)
+ atomicWrite(hosts, (remote.hostKey.trim() + "\n").toByteArray(Charsets.UTF_8))
+ keyEnvironment(ctx) + mapOf(
+ "RSH_KNOWN_HOSTS" to hosts.absolutePath,
+ "RSH_PORT" to remote.port.toString(),
+ )
+ }
+
+ // legacyPin returns the known_hosts line 0.1.x kept beside the config, back
+ // when one pin was all there was. Config reads it once while migrating a
+ // 0.1.x config; nothing else should, because this path is now a scratch
+ // file rewritten per connection. Delete with that migration in 0.4.0.
+ fun legacyPin(ctx: Context): String {
+ val f = knownHosts(ctx)
+ if (!f.exists() || f.length() > MAX_LEGACY_PIN_BYTES) return ""
+ return try {
+ f.readText(Charsets.UTF_8).lineSequence().firstOrNull { it.isNotBlank() }?.trim().orEmpty()
+ } catch (_: Exception) {
+ ""
+ }
+ }
+
+ private const val MAX_LEGACY_PIN_BYTES = 64L * 1024
data class Scan(val ok: Boolean, val fingerprint: String, val line: String, val error: String)
// scan connects (proving the key is installed), captures the host key, and
// returns its fingerprint and the known_hosts line to pin. pin() writes it
// once the user accepts.
- fun scan(ctx: Context, remote: Remote): Scan {
+ fun scan(ctx: Context, remote: Remote): Scan = synchronized(keyLock) {
+ // Point the scan at this remote's own pin, so rsh offers the key type
+ // already pinned and an unchanged server reproduces its stored line. A
+ // config pinned by an older rsend holds whichever key that version
+ // negotiated, and re-scanning without this would return a different key
+ // type and look like a host-key change on an untouched server.
+ val scanHosts = File(ctx.cacheDir, "scan_known_hosts")
+ atomicWrite(
+ scanHosts,
+ if (remote.pinned()) (remote.hostKey + "\n").toByteArray(Charsets.UTF_8) else ByteArray(0),
+ )
val r = Native.run(
Native.rsh(ctx),
listOf("-scan", "${remote.user}@${remote.host}"),
- env(ctx, remote.port),
+ keyEnvironment(ctx) + mapOf(
+ "RSH_KNOWN_HOSTS" to scanHosts.absolutePath,
+ "RSH_PORT" to remote.port.toString(),
+ ),
)
- if (r.code != 0) return Scan(false, "", "", r.output.trim())
+ if (r.code != 0) return@synchronized Scan(false, "", "", r.output.trim())
val lines = r.output.trim().lines()
- if (lines.size < 2) return Scan(false, "", "", "unexpected scan output:\n${r.output}")
- return Scan(true, lines[0], lines[1], "")
+ if (lines.size < 2) {
+ return@synchronized Scan(false, "", "", "unexpected scan output:\n${r.output}")
+ }
+ Scan(true, lines[0], lines[1], "")
}
- // writeKnownHosts regenerates the known_hosts file rsh verifies against.
- // The pins live in config.json (Remote.hostKey); this file is derived
- // state, rewritten from all pinned remotes before every sync.
- fun writeKnownHosts(ctx: Context, lines: List<String>) {
- atomicWrite(knownHosts(ctx), lines.joinToString("") { it + "\n" }.toByteArray())
+ private fun keyEnvironment(ctx: Context): Map<String, String> =
+ mapOf("RSH_KEY_DATA" to String(KeyVault.decrypt(encryptedKey(ctx)), Charsets.UTF_8))
+
+ private fun encryptedKey(ctx: Context): ByteArray {
+ val out = ByteArrayOutputStream()
+ AtomicFile(keyEnc(ctx)).openRead().use { input ->
+ val buf = ByteArray(4096)
+ while (true) {
+ val n = input.read(buf)
+ if (n < 0) break
+ require(out.size() + n <= MAX_ENCRYPTED_KEY_BYTES) { "encrypted private key is too large" }
+ out.write(buf, 0, n)
+ }
+ }
+ return out.toByteArray()
}
// AtomicFile keeps the previous complete file if a write is interrupted.
diff --git a/app/src/main/java/invalid/lena/rsend/LastSync.kt b/app/src/main/java/invalid/lena/rsend/LastSync.kt
index 3f1d518..9179b19 100644
--- a/app/src/main/java/invalid/lena/rsend/LastSync.kt
+++ b/app/src/main/java/invalid/lena/rsend/LastSync.kt
@@ -1,21 +1,48 @@
package invalid.lena.rsend
import android.content.Context
+import android.util.AtomicFile
import java.io.File
+import java.io.FileNotFoundException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
// LastSync records the outcome of the most recent run so the dashboard can show
-// it without opening the log.
+// it without opening the log. This small status is independent of the full log.
object LastSync {
+ private const val MAX_STATUS_CHARS = 4_000
+ private const val MAX_FILE_BYTES = 20 * 1024
+
private fun file(ctx: Context): File = File(ctx.filesDir, "last-sync")
- private val fmt = SimpleDateFormat("MM-dd HH:mm", Locale.US)
- fun set(ctx: Context, ok: Boolean) {
- file(ctx).writeText("${fmt.format(Date())} ${if (ok) "ok" else "FAILED"}")
+ @Synchronized
+ fun set(ctx: Context, status: String) {
+ val clean = buildString(minOf(status.length, MAX_STATUS_CHARS)) {
+ for (c in status) {
+ append(if (c.isISOControl()) ' ' else c)
+ if (length == MAX_STATUS_CHARS) break
+ }
+ }
+ val time = SimpleDateFormat("MM-dd HH:mm", Locale.US).format(Date())
+ val file = AtomicFile(file(ctx))
+ val out = file.startWrite()
+ try {
+ out.write("$time $clean".toByteArray(Charsets.UTF_8))
+ file.finishWrite(out)
+ } catch (e: Exception) {
+ file.failWrite(out)
+ throw e
+ }
}
- fun get(ctx: Context): String = if (file(ctx).exists()) file(ctx).readText() else "never"
+ @Synchronized
+ fun get(ctx: Context): String = try {
+ AtomicFile(file(ctx)).openRead().use { Config.readText(it, MAX_FILE_BYTES) }
+ } catch (_: FileNotFoundException) {
+ "never"
+ } catch (e: Exception) {
+ "unavailable (${e.javaClass.simpleName})"
+ }
}
diff --git a/app/src/main/java/invalid/lena/rsend/LogActivity.kt b/app/src/main/java/invalid/lena/rsend/LogActivity.kt
index c9cce94..13ad766 100644
--- a/app/src/main/java/invalid/lena/rsend/LogActivity.kt
+++ b/app/src/main/java/invalid/lena/rsend/LogActivity.kt
@@ -4,11 +4,13 @@ import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
+import kotlin.concurrent.thread
// LogActivity shows the plain-text sync log with refresh and clear.
class LogActivity : AppCompatActivity() {
private lateinit var log: TextView
+ private var generation = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -18,10 +20,7 @@ class LogActivity : AppCompatActivity() {
fitSystemBars()
log = findViewById(R.id.log)
findViewById<Button>(R.id.refresh).setOnClickListener { load() }
- findViewById<Button>(R.id.clear).setOnClickListener {
- SyncLog(this).file.writeText("")
- load()
- }
+ findViewById<Button>(R.id.clear).setOnClickListener { load(clear = true) }
}
override fun onResume() {
@@ -29,7 +28,22 @@ class LogActivity : AppCompatActivity() {
load()
}
- private fun load() {
- log.text = SyncLog(this).text().ifEmpty { "No log yet." }
+ // File I/O stays off the main thread. SyncLog caps displayed text at 64 KB
+ // so the unavoidable TextView assignment and layout remain bounded.
+ private fun load(clear: Boolean = false) {
+ val current = ++generation
+ thread {
+ val result = try {
+ val syncLog = SyncLog(this)
+ if (clear) syncLog.clear()
+ Result.success(syncLog.text().ifEmpty { "No log yet." })
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ runOnUiThread {
+ if (isFinishing || isDestroyed || current != generation) return@runOnUiThread
+ log.text = result.getOrElse { "Could not read log: ${it.message}" }
+ }
+ }
}
}
diff --git a/app/src/main/java/invalid/lena/rsend/MainActivity.kt b/app/src/main/java/invalid/lena/rsend/MainActivity.kt
index 2f300c9..875afdf 100644
--- a/app/src/main/java/invalid/lena/rsend/MainActivity.kt
+++ b/app/src/main/java/invalid/lena/rsend/MainActivity.kt
@@ -1,10 +1,12 @@
package invalid.lena.rsend
import android.Manifest
+import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
+import android.net.ConnectivityManager
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
@@ -27,7 +29,6 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import java.io.ByteArrayOutputStream
-import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
// MainActivity is the dashboard: a status hero, a primary Sync action, grouped
@@ -35,6 +36,10 @@ import kotlin.concurrent.thread
// happens in RemoteActivity, ScheduleActivity, and FolderEditActivity.
class MainActivity : AppCompatActivity() {
+ companion object {
+ private const val MANUAL_WORK_NAME = "sync-now"
+ }
+
private lateinit var lastSync: TextView
private lateinit var keyValue: TextView
private lateinit var scheduleValue: TextView
@@ -50,6 +55,13 @@ class MainActivity : AppCompatActivity() {
private lateinit var syncBar: ProgressBar
private lateinit var syncStatus: TextView
+ // Last drawn schedule and the observed state of the periodic job. Observing
+ // it keeps the dashboard off the 500ms blocking WorkManager query that used
+ // to run on the main thread for every refresh.
+ private var schedule: Schedule = Schedule()
+ private var jobState: String = "none"
+ private var recoveryShown = false
+
private val notifPerm =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { refresh() }
@@ -82,16 +94,23 @@ class MainActivity : AppCompatActivity() {
btnSync.setOnClickListener { syncNow() }
observeSync()
+ WorkManager.getInstance(this)
+ .getWorkInfosForUniqueWorkLiveData(Scheduler.NAME)
+ .observe(this) { infos ->
+ jobState = activeWork(infos)?.state?.name ?: "none"
+ renderSchedule(schedule)
+ }
findViewById<LinearLayout>(R.id.rowKey).setOnClickListener { showKey() }
findViewById<LinearLayout>(R.id.rowSchedule).setOnClickListener {
startActivity(Intent(this, ScheduleActivity::class.java))
}
findViewById<LinearLayout>(R.id.rowAllFiles).setOnClickListener {
- startActivity(
+ openSettings(
Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:$packageName"),
- )
+ ),
+ Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION),
)
}
findViewById<LinearLayout>(R.id.rowNotif).setOnClickListener {
@@ -100,21 +119,23 @@ class MainActivity : AppCompatActivity() {
} else {
// No runtime permission to request (pre-13) or already granted:
// open the app's notification settings so the row still acts.
- startActivity(
+ openSettings(
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, packageName),
+ Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName")),
)
}
}
findViewById<LinearLayout>(R.id.rowBattery).setOnClickListener {
if (batteryUnrestricted()) {
- startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
+ openSettings(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
} else {
- startActivity(
+ openSettings(
Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:$packageName"),
),
+ Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS),
)
}
}
@@ -140,8 +161,26 @@ class MainActivity : AppCompatActivity() {
.show()
return
}
+ if (!allFiles()) {
+ Toast.makeText(this, "Grant all-files access first, or rsend cannot read your folders.", Toast.LENGTH_LONG)
+ .show()
+ return
+ }
+ // A manual sync honours the "unmetered only" setting, but as a check
+ // here rather than as a WorkManager constraint. A constrained request
+ // would sit ENQUEUED until the phone found wifi, and observeSync counts
+ // ENQUEUED as running, so the button would lock at "Syncing..." with no
+ // way to cancel it. Refuse now, with a reason the user can act on.
+ if (cfg.schedule.wifiOnly && getSystemService(ConnectivityManager::class.java).isActiveNetworkMetered) {
+ Toast.makeText(
+ this,
+ "This network is metered and the schedule is set to unmetered only. Connect to Wi-Fi, or turn that off under Schedule.",
+ Toast.LENGTH_LONG,
+ ).show()
+ return
+ }
val req = OneTimeWorkRequestBuilder<SyncWorker>().build()
- WorkManager.getInstance(this).enqueueUniqueWork("sync-now", ExistingWorkPolicy.KEEP, req)
+ WorkManager.getInstance(this).enqueueUniqueWork(MANUAL_WORK_NAME, ExistingWorkPolicy.KEEP, req)
Toast.makeText(this, "Sync started.", Toast.LENGTH_SHORT).show()
}
@@ -150,17 +189,15 @@ class MainActivity : AppCompatActivity() {
// fresh status (last sync) the moment it ends, without leaving the screen.
private fun observeSync() {
WorkManager.getInstance(this)
- .getWorkInfosForUniqueWorkLiveData("sync-now")
+ .getWorkInfosForUniqueWorkLiveData(MANUAL_WORK_NAME)
.observe(this) { infos ->
- val info = infos.lastOrNull()
- val running = info != null &&
- (info.state == WorkInfo.State.RUNNING || info.state == WorkInfo.State.ENQUEUED)
- if (running) {
+ val active = activeWork(infos)
+ if (active != null) {
syncBar.visibility = View.VISIBLE
syncStatus.visibility = View.VISIBLE
btnSync.isEnabled = false
btnSync.text = "Syncing..."
- val p = info!!.progress
+ val p = active.progress
val folder = p.getString(SyncWorker.KEY_FOLDER)
val i = p.getInt(SyncWorker.KEY_INDEX, 0)
val n = p.getInt(SyncWorker.KEY_TOTAL, 0)
@@ -176,10 +213,30 @@ class MainActivity : AppCompatActivity() {
}
}
+ // WorkManager does not promise query result order. Unique work should have
+ // one unfinished record; generation is a deterministic tie-breaker if an
+ // old database contains more than one.
+ private fun activeWork(infos: List<WorkInfo>): WorkInfo? =
+ infos.filterNot { it.state.isFinished }.maxByOrNull { it.generation }
+
override fun onResume() {
super.onResume()
Scheduler.apply(this)
refresh()
+ showRecoveryNotice()
+ }
+
+ private fun showRecoveryNotice() {
+ if (recoveryShown) return
+ val message = Config.recoveryMessage(this).trim()
+ if (message.isEmpty()) return
+ recoveryShown = true
+ AlertDialog.Builder(this)
+ .setTitle("Configuration recovered")
+ .setMessage(message)
+ .setPositiveButton("OK") { _, _ -> Config.clearRecoveryMessage(this) }
+ .setCancelable(false)
+ .show()
}
private fun allFiles() = Environment.isExternalStorageManager()
@@ -190,25 +247,26 @@ class MainActivity : AppCompatActivity() {
private fun batteryUnrestricted(): Boolean =
getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(packageName)
- // jobState reports the WorkManager state of the scheduled sync (ENQUEUED,
- // RUNNING, none, ...) so the dashboard shows whether the scheduler is armed.
- private fun jobState(): String = try {
- WorkManager.getInstance(this)
- .getWorkInfosForUniqueWork(Scheduler.NAME)
- .get(500, TimeUnit.MILLISECONDS)
- .firstOrNull()?.state?.name ?: "none"
- } catch (e: Exception) {
- "?"
+ private fun openSettings(intent: Intent, fallback: Intent? = null) {
+ try {
+ startActivity(intent)
+ } catch (_: ActivityNotFoundException) {
+ if (fallback == null) {
+ Toast.makeText(this, "This settings screen is not available on this device.", Toast.LENGTH_LONG).show()
+ } else {
+ try {
+ startActivity(fallback)
+ } catch (_: ActivityNotFoundException) {
+ Toast.makeText(this, "This settings screen is not available on this device.", Toast.LENGTH_LONG)
+ .show()
+ }
+ }
+ }
}
- private fun refresh() {
- val cfg = Config.load(this)
-
- lastSync.text = "Last sync: ${LastSync.get(this)}"
-
- keyValue.text = if (Keys.exists(this)) "Generated, tap to view" else "Not created, tap to create"
-
- val s = cfg.schedule
+ // renderSchedule draws the schedule row from the saved settings plus the
+ // observed WorkManager state, so neither has to block to fetch the other.
+ private fun renderSchedule(s: Schedule) {
val sched = if (s.enabled) {
"Every ${s.intervalMinutes}m" +
(if (s.wifiOnly) ", unmetered" else "") +
@@ -216,7 +274,18 @@ class MainActivity : AppCompatActivity() {
} else {
"Off"
}
- scheduleValue.text = "$sched, job ${jobState()}"
+ scheduleValue.text = "$sched, job $jobState"
+ }
+
+ private fun refresh() {
+ val cfg = Config.load(this)
+
+ lastSync.text = "Last sync: ${LastSync.get(this)}"
+
+ keyValue.text = if (Keys.exists(this)) "Generated, tap to view" else "Not created, tap to create"
+
+ schedule = cfg.schedule
+ renderSchedule(cfg.schedule)
setPerm(dotAllFiles, valAllFiles, allFiles(), "Granted", "Tap to grant")
setPerm(dotNotif, valNotif, notif(), "Granted", "Tap to grant")
@@ -289,9 +358,35 @@ class MainActivity : AppCompatActivity() {
// showKey displays the public key (only the key) and offers to copy it,
// generate a fresh key, or import the user's own private key.
private fun showKey() {
+ if (!Keys.exists(this)) {
+ showKeyDialog("")
+ return
+ }
+ thread {
+ val result = try {
+ Result.success(Keys.publicKeyText(this))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ runOnUiThread {
+ if (isFinishing || isDestroyed) return@runOnUiThread
+ result.fold(
+ onSuccess = { showKeyDialog(it) },
+ onFailure = {
+ AlertDialog.Builder(this)
+ .setTitle("Key unavailable")
+ .setMessage(it.message ?: "could not read public key")
+ .setPositiveButton("OK", null)
+ .show()
+ },
+ )
+ }
+ }
+ }
+
+ private fun showKeyDialog(pub: String) {
val view = layoutInflater.inflate(R.layout.dialog_key, null)
val keyText = view.findViewById<TextView>(R.id.keyText)
- val pub = Keys.publicKeyText(this)
keyText.text = pub.ifEmpty { "No key yet. Generate or import one below." }
val dialog = AlertDialog.Builder(this)
@@ -313,11 +408,27 @@ class MainActivity : AppCompatActivity() {
}
view.findViewById<Button>(R.id.btnImportKey).setOnClickListener {
dialog.dismiss()
- openKeyDoc.launch(arrayOf("*/*"))
+ chooseKeyToImport()
}
dialog.show()
}
+ private fun chooseKeyToImport() {
+ if (!Keys.exists(this)) {
+ openKeyDoc.launch(arrayOf("*/*"))
+ return
+ }
+ AlertDialog.Builder(this)
+ .setTitle("Replace identity key?")
+ .setMessage(
+ "Importing a key replaces the current one. The imported public key must be present in every " +
+ "server's authorized_keys file."
+ )
+ .setPositiveButton("Choose key") { _, _ -> openKeyDoc.launch(arrayOf("*/*")) }
+ .setNegativeButton("Cancel", null)
+ .show()
+ }
+
// confirmGenerate warns before replacing an existing key, then generates a
// new one off the main thread and shows it so the user can copy it.
private fun confirmGenerate() {
@@ -359,8 +470,8 @@ class MainActivity : AppCompatActivity() {
}
// importKeyFrom reads the chosen private-key file and stores it, replacing
- // the current key. rsh validates the key first; an unusable key (wrong type
- // or passphrase-protected) is reported rather than saved.
+ // the current key. rsh accepts only unencrypted Ed25519 keys, using the
+ // same parser as the transport, and reports any other file without saving.
private fun importKeyFrom(uri: Uri) {
thread {
val error = try {
diff --git a/app/src/main/java/invalid/lena/rsend/Native.kt b/app/src/main/java/invalid/lena/rsend/Native.kt
index 7f7b5ba..fcc42d7 100644
--- a/app/src/main/java/invalid/lena/rsend/Native.kt
+++ b/app/src/main/java/invalid/lena/rsend/Native.kt
@@ -1,7 +1,9 @@
package invalid.lena.rsend
import android.content.Context
+import java.io.ByteArrayOutputStream
import java.io.File
+import java.io.InputStream
import java.util.concurrent.TimeUnit
// Native locates and runs the executables shipped inside the APK as lib*.so.
@@ -16,17 +18,53 @@ object Native {
data class Result(val code: Int, val output: String)
// Blocking; call off the main thread. stderr is merged into stdout.
+ //
+ // The output is drained on its own thread while the process runs. Waiting
+ // first would deadlock as soon as the child filled the pipe buffer, and the
+ // only symptom would be the 45s timeout.
fun run(bin: File, args: List<String>, env: Map<String, String> = emptyMap()): Result {
val pb = ProcessBuilder(listOf(bin.absolutePath) + args).redirectErrorStream(true)
pb.environment().putAll(env)
val p = pb.start()
+ var output = ""
+ val drain = Thread {
+ try {
+ p.inputStream.use { output = boundedOutput(it) }
+ } catch (_: Exception) {
+ // The pipe closes when the process is killed; whatever was read
+ // before that is still worth returning.
+ }
+ }
+ drain.start()
if (!p.waitFor(45, TimeUnit.SECONDS)) {
p.destroyForcibly()
p.waitFor()
+ p.inputStream.close()
+ drain.join(5_000)
throw IllegalStateException("${bin.name} timed out")
}
- val out = p.inputStream.bufferedReader().use { it.readText() }
- val code = p.exitValue()
- return Result(code, out)
+ drain.join(5_000)
+ if (drain.isAlive) {
+ p.inputStream.close()
+ drain.join(5_000)
+ }
+ if (drain.isAlive) throw IllegalStateException("${bin.name} output reader did not stop")
+ return Result(p.exitValue(), output)
+ }
+
+ internal fun boundedOutput(input: InputStream): String {
+ val out = ByteArrayOutputStream()
+ val buf = ByteArray(4096)
+ var truncated = false
+ while (true) {
+ val n = input.read(buf)
+ if (n < 0) break
+ val room = MAX_OUTPUT_BYTES - out.size()
+ if (room > 0) out.write(buf, 0, minOf(room, n))
+ if (n > room) truncated = true
+ }
+ return String(out.toByteArray(), Charsets.UTF_8) + if (truncated) "\n[output truncated]\n" else ""
}
+
+ private const val MAX_OUTPUT_BYTES = 64 * 1024
}
diff --git a/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt b/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt
index 0d77e6c..994e233 100644
--- a/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt
+++ b/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt
@@ -1,12 +1,14 @@
package invalid.lena.rsend
import android.os.Bundle
+import android.util.Base64
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
+import java.security.MessageDigest
import kotlin.concurrent.thread
// RemoteActivity adds or edits one named remote and runs Test connection,
@@ -33,7 +35,7 @@ class RemoteActivity : AppCompatActivity() {
user = findViewById(R.id.user)
status = findViewById(R.id.status)
- index = intent.getIntExtra("index", -1)
+ index = savedInstanceState?.getInt("index") ?: intent.getIntExtra("index", -1)
val cfg = Config.load(this)
if (index in cfg.remotes.indices) {
val r = cfg.remotes[index]
@@ -52,9 +54,14 @@ class RemoteActivity : AppCompatActivity() {
del.setOnClickListener { if (remove()) finish() }
}
+ override fun onSaveInstanceState(outState: Bundle) {
+ outState.putInt("index", index)
+ super.onSaveInstanceState(outState)
+ }
+
private fun current(): Remote? {
val n = name.text.toString().trim()
- if (n.isEmpty()) {
+ if (!RemoteRules.nameAllowed(n)) {
status.text = "Set a name; folders pick their remote by it."
return null
}
@@ -63,18 +70,29 @@ class RemoteActivity : AppCompatActivity() {
status.text = "Port must be between 1 and 65535."
return null
}
- return Remote(
- name = n,
- host = host.text.toString().trim(),
- port = p,
- user = user.text.toString().trim(),
- )
+ // Unbracket before validating, or a host of "[]" passes the emptiness
+ // check and is then stored empty. An IPv6 literal is kept bare: rsh and
+ // rsync each add the brackets their own syntax needs, and a stored
+ // bracketed form would be bracketed twice.
+ val h = host.text.toString().trim().removeSurrounding("[", "]").trim()
+ val u = user.text.toString().trim()
+ // Checked here rather than only in test(), so Save and Test reject the
+ // same incomplete form.
+ if (!RemoteRules.hostAllowed(h)) {
+ status.text = "Host must be a hostname or IP address without brackets."
+ return null
+ }
+ if (!RemoteRules.userAllowed(u)) {
+ status.text = "User may contain letters, digits, dot, underscore, and hyphen."
+ return null
+ }
+ return Remote(name = n, host = h, port = p, user = u)
}
- // save validates, keeps the pin only while host and port are unchanged,
- // renames folder references along with the remote, and persists. Returns
- // the saved entry's index (a new remote is appended), or -1 on a
- // validation failure.
+ // save validates, takes the pin the caller just accepted or carries the
+ // stored one over while host and port are unchanged, renames folder
+ // references along with the remote, and persists. Returns the saved entry's
+ // index (a new remote is appended), or -1 on a validation failure.
private fun save(remote: Remote? = current()): Int {
if (remote == null) return -1
val cfg = Config.load(this)
@@ -83,8 +101,11 @@ class RemoteActivity : AppCompatActivity() {
return -1
}
val old = cfg.remotes.getOrNull(index)
- val hostKey =
- if (old != null && old.host == remote.host && old.port == remote.port) old.hostKey else ""
+ val hostKey = when {
+ remote.hostKey.isNotEmpty() -> remote.hostKey
+ old != null && old.host == remote.host && old.port == remote.port -> old.hostKey
+ else -> ""
+ }
val entry = remote.copy(hostKey = hostKey)
val remotes = cfg.remotes.toMutableList()
val at = if (index in remotes.indices) {
@@ -100,7 +121,17 @@ class RemoteActivity : AppCompatActivity() {
if (it.remoteName == old.name) it.copy(remoteName = entry.name) else it
}
}
- Config.save(this, cfg.copy(remotes = remotes, folders = folders))
+ val next = cfg.copy(remotes = remotes, folders = folders)
+ val conflict = FolderRules.firstDestinationConflict(next)
+ if (conflict != null) {
+ val (a, b) = conflict
+ status.text =
+ "This endpoint would make folder \"${a.name.ifEmpty { a.remotePath }}\" conflict with " +
+ "folder \"${b.name.ifEmpty { b.remotePath }}\"."
+ return -1
+ }
+ Config.save(this, next)
+ Scheduler.apply(this)
return at
}
@@ -115,57 +146,113 @@ class RemoteActivity : AppCompatActivity() {
return false
}
Config.save(this, cfg.copy(remotes = cfg.remotes.filterIndexed { i, _ -> i != index }))
+ Scheduler.apply(this)
return true
}
+ // test scans the form's remote without writing anything. Persisting first
+ // would let a failed or cancelled test clear the pin of a working remote
+ // the user never saved, which also silently disarms the periodic job.
private fun test() {
val r = current() ?: return
- val saved = save(r)
- if (saved < 0) return
- // Testing a new remote adopts the appended entry, so Pin and a second
- // Save update it instead of appending again.
- index = saved
- when {
- !Keys.exists(this) ->
- status.text = "No key yet. Generate one on the main screen and add it to the server first."
- r.host.isEmpty() || r.user.isEmpty() ->
- status.text = "Set host and user first."
- else -> {
- status.text = "Connecting to ${r.user}@${r.host}:${r.port} ..."
- thread {
- val scan = try {
- Keys.scan(this, r)
- } catch (e: Exception) {
- Keys.Scan(false, "", "", e.message ?: "connection failed")
- }
- runOnUiThread {
- if (isFinishing || isDestroyed) return@runOnUiThread
- if (!scan.ok) {
- status.text = "Connection failed:\n${scan.error}"
- } else {
- AlertDialog.Builder(this)
- .setTitle("Verify host key")
- .setMessage("Fingerprint:\n${scan.fingerprint}\n\nPin this host?")
- .setPositiveButton("Pin") { _, _ ->
- pin(scan.line)
- status.text = "Host key pinned. Connection OK."
- }
- .setNegativeButton("Cancel", null)
- .show()
- }
- }
+ val cfg = Config.load(this)
+ if (cfg.remotes.withIndex().any { (i, existing) -> i != index && existing.name == r.name }) {
+ status.text = "A remote named \"${r.name}\" already exists."
+ return
+ }
+ if (!Keys.exists(this)) {
+ status.text = "No key yet. Generate one on the main screen and add it to the server first."
+ return
+ }
+ // Hand the scan the pin this remote already has, so rsh offers the
+ // pinned key type and an untouched server reproduces its stored line.
+ // Scanning the bare form instead would negotiate whatever the server
+ // prefers and report a key change that never happened.
+ val pinned = storedPin(r)
+ status.text = "Connecting to ${r.user}@${r.host}:${r.port} ..."
+ thread {
+ val scan = try {
+ Keys.scan(this, r.copy(hostKey = pinned))
+ } catch (e: Exception) {
+ Keys.Scan(false, "", "", e.message ?: "connection failed")
+ }
+ runOnUiThread {
+ if (isFinishing || isDestroyed) return@runOnUiThread
+ if (!scan.ok) {
+ status.text = "Connection failed:\n${scan.error}"
+ } else {
+ confirmPin(r, pinned, scan)
}
}
}
}
- // pin re-loads the config before writing: the scan ran in the background
- // and the dialog may have sat open for a while.
- private fun pin(line: String) {
- val cfg = Config.load(this)
- val remotes = cfg.remotes.toMutableList()
- if (index !in remotes.indices) return
- remotes[index] = remotes[index].copy(hostKey = line)
- Config.save(this, cfg.copy(remotes = remotes))
+ // storedPin is the pin already saved for this remote, and only while host
+ // and port still match the form: a pin belongs to one host and port.
+ private fun storedPin(r: Remote): String {
+ val old = Config.load(this).remotes.getOrNull(index) ?: return ""
+ return if (old.host == r.host && old.port == r.port) old.hostKey else ""
+ }
+
+ // confirmPin is the one security decision the user makes, so a key that
+ // differs from the stored pin must not look like a first-time pin.
+ private fun confirmPin(r: Remote, pinned: String, scan: Keys.Scan) {
+ if (pinned == scan.line) {
+ status.text = "Connection OK. Host key unchanged."
+ return
+ }
+ val b = AlertDialog.Builder(this).setNegativeButton("Cancel", null)
+ if (pinned.isEmpty()) {
+ b.setTitle("Verify host key")
+ .setMessage(
+ "Fingerprint:\n${scan.fingerprint}\n\n" +
+ "Check it on the server with:\n" +
+ "ssh-keygen -lf ${hostKeyFile(scan.fingerprint)}\n\nPin this host?"
+ )
+ .setPositiveButton("Pin") { _, _ -> savePinned(r, scan.line) }
+ } else {
+ b.setTitle("HOST KEY CHANGED")
+ .setMessage(
+ "This host presents a different key from the one rsend pinned.\n\n" +
+ "Pinned:\n${fingerprintOf(pinned)}\n\n" +
+ "Presented:\n${scan.fingerprint}\n\n" +
+ "This is what an intercepted connection looks like. Only replace " +
+ "the pin if you rebuilt the server or rotated its host key."
+ )
+ .setPositiveButton("Replace pin") { _, _ -> savePinned(r, scan.line) }
+ }
+ b.show()
+ }
+
+ // savePinned writes the form and the accepted key together: until the user
+ // accepts a key, nothing about this remote is persisted.
+ private fun savePinned(r: Remote, line: String) {
+ val at = save(r.copy(hostKey = line))
+ if (at < 0) return
+ index = at
+ intent.putExtra("index", at)
+ status.text = "Host key pinned. Connection OK."
+ }
+
+ private fun hostKeyFile(fingerprint: String): String = when (fingerprint.substringBefore(' ')) {
+ "ssh-ed25519" -> "/etc/ssh/ssh_host_ed25519_key.pub"
+ "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521" ->
+ "/etc/ssh/ssh_host_ecdsa_key.pub"
+ "ssh-rsa", "rsa-sha2-256", "rsa-sha2-512" -> "/etc/ssh/ssh_host_rsa_key.pub"
+ else -> "/etc/ssh/ssh_host_key.pub"
+ }
+
+ // fingerprintOf renders a stored known_hosts line the way rsh -scan and
+ // ssh-keygen -lf do, so the two fingerprints in the warning are comparable.
+ private fun fingerprintOf(line: String): String {
+ val parts = line.trim().split(" ")
+ if (parts.size < 3) return "(unreadable pin)"
+ return try {
+ val raw = Base64.decode(parts[2], Base64.DEFAULT)
+ val sha = MessageDigest.getInstance("SHA-256").digest(raw)
+ parts[1] + " SHA256:" + Base64.encodeToString(sha, Base64.NO_PADDING or Base64.NO_WRAP)
+ } catch (e: Exception) {
+ "(unreadable pin)"
+ }
}
}
diff --git a/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt b/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt
index c4c0536..f807288 100644
--- a/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt
+++ b/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt
@@ -6,6 +6,8 @@ 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
@@ -13,6 +15,44 @@ import java.util.concurrent.TimeUnit
// 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"
+
+ // "<operation> <path>": %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
@@ -22,10 +62,34 @@ object RsyncRunner {
// 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. <path>" for a
+ // deletion, "send <path>" 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<String> {
+ 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<String>()
a.add("-rt")
- a.add("--partial")
+ 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")
@@ -35,35 +99,99 @@ object RsyncRunner {
a.add("-e")
a.add(rsh)
for (e in f.excludes) a.add("--exclude=$e")
- if (f.delete) a.add("--delete")
+ // --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}@${remote.host}:${withSlash(f.remotePath)}")
+ 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): Int = coroutineScope {
+ 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.port))
+ pb.environment().putAll(Keys.env(ctx, remote))
val p = pb.start()
val watchdog = launch(Dispatchers.IO) { try { awaitCancellation() } finally { terminate(p) } }
try {
- p.inputStream.bufferedReader().forEachLine { log.line(it) }
- p.waitFor()
+ 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)) {
diff --git a/app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt b/app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt
index 31ec6b4..b3c3060 100644
--- a/app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt
+++ b/app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt
@@ -3,17 +3,18 @@ package invalid.lena.rsend
import android.os.Bundle
import android.widget.Button
import android.widget.EditText
-import android.widget.Switch
+import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.widget.SwitchCompat
// ScheduleActivity edits the periodic sync settings and (re)applies them to
// WorkManager on save.
class ScheduleActivity : AppCompatActivity() {
- private lateinit var enabled: Switch
+ private lateinit var enabled: SwitchCompat
private lateinit var interval: EditText
- private lateinit var wifiOnly: Switch
- private lateinit var charging: Switch
+ private lateinit var wifiOnly: SwitchCompat
+ private lateinit var charging: SwitchCompat
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -32,19 +33,28 @@ class ScheduleActivity : AppCompatActivity() {
wifiOnly.isChecked = s.wifiOnly
charging.isChecked = s.requireCharging
- findViewById<Button>(R.id.save).setOnClickListener { save(); finish() }
+ findViewById<Button>(R.id.save).setOnClickListener { if (save()) finish() }
}
- private fun save() {
+ private fun save(): Boolean {
+ val minutes = interval.text.toString().toIntOrNull()
+ if (minutes == null || minutes < Schedule.MIN_INTERVAL_MINUTES) {
+ Toast.makeText(
+ this,
+ "Interval must be at least ${Schedule.MIN_INTERVAL_MINUTES} minutes.",
+ Toast.LENGTH_LONG,
+ ).show()
+ return false
+ }
val cfg = Config.load(this)
val s = Schedule(
enabled = enabled.isChecked,
- intervalMinutes = interval.text.toString().toIntOrNull()
- ?.coerceAtLeast(Schedule.MIN_INTERVAL_MINUTES) ?: 120,
+ intervalMinutes = minutes,
wifiOnly = wifiOnly.isChecked,
requireCharging = charging.isChecked,
)
Config.save(this, cfg.copy(schedule = s))
Scheduler.apply(this)
+ return true
}
}
diff --git a/app/src/main/java/invalid/lena/rsend/Scheduler.kt b/app/src/main/java/invalid/lena/rsend/Scheduler.kt
index 4b0e687..c35fb42 100644
--- a/app/src/main/java/invalid/lena/rsend/Scheduler.kt
+++ b/app/src/main/java/invalid/lena/rsend/Scheduler.kt
@@ -11,15 +11,21 @@ import java.util.concurrent.TimeUnit
// Scheduler keeps one periodic sync request aligned with the saved config.
object Scheduler {
- // Versioned so it can never collide with the retired one-time chain's
- // unique work name.
+ // Keep this distinct from the retired pre-0.1.3 one-time chain. WorkManager
+ // persists unique names across app upgrades, so changing it creates a
+ // second schedule and leaves the first one invisible to the UI.
const val NAME = "periodic-sync-v2"
+ private const val RETIRED_NAME = "periodic-sync"
fun apply(ctx: Context) {
val cfg = Config.load(ctx)
val s = cfg.schedule
val wm = WorkManager.getInstance(ctx)
+ // Idempotently retire both the old one-time chain and any periodic job
+ // created by the unreleased name regression.
+ wm.cancelUniqueWork(RETIRED_NAME)
+
if (!s.enabled || !cfg.syncReady(Keys.exists(ctx))) {
wm.cancelUniqueWork(NAME)
return
diff --git a/app/src/main/java/invalid/lena/rsend/SyncLog.kt b/app/src/main/java/invalid/lena/rsend/SyncLog.kt
index 44c4d46..f0db905 100644
--- a/app/src/main/java/invalid/lena/rsend/SyncLog.kt
+++ b/app/src/main/java/invalid/lena/rsend/SyncLog.kt
@@ -2,6 +2,8 @@ package invalid.lena.rsend
import android.content.Context
import java.io.File
+import java.io.FileOutputStream
+import java.io.RandomAccessFile
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -11,16 +13,112 @@ import java.util.Locale
class SyncLog(ctx: Context) {
val file: File = File(ctx.filesDir, "sync.log")
+ private val previous: File = File(ctx.filesDir, "sync.log.1")
private val fmt = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US)
fun line(s: String) {
- file.appendText("${fmt.format(Date())} $s\n")
+ synchronized(lock) {
+ val clean = cleanLine(s)
+ val entry = "${fmt.format(Date())} $clean\n".toByteArray(Charsets.UTF_8)
+ if (file.exists() && file.length() + entry.size > MAX_BYTES) rotate()
+ FileOutputStream(file, true).use { it.write(entry) }
+ }
}
- // Truncate once the log grows past the cap so it cannot fill storage.
+ // Called once at the start of a run; line() also enforces the cap as the
+ // run goes, so a single long run cannot grow without bound.
fun rotateIfBig() {
- if (file.exists() && file.length() > 512 * 1024) file.writeText("")
+ synchronized(lock) {
+ if (file.exists() && file.length() >= MAX_BYTES) rotate()
+ }
}
- fun text(): String = if (file.exists()) file.readText() else ""
+ // Roll over rather than truncate. Truncating destroyed every earlier run,
+ // and a run that outgrew the cap destroyed its own output, including the
+ // record of what it had just deleted on the server.
+ private fun rotate() {
+ if (previous.exists() && !previous.delete()) {
+ throw IllegalStateException("could not remove ${previous.name}")
+ }
+ if (file.exists() && !file.renameTo(previous)) {
+ throw IllegalStateException("could not rotate ${file.name}")
+ }
+ }
+
+ // Both halves, oldest first. Showing only the current file would hide
+ // everything a rotation moved aside, which is where a long run's earliest
+ // and most interesting lines end up.
+ fun text(): String {
+ synchronized(lock) {
+ val current = tail(file, DISPLAY_BYTES)
+ val left = DISPLAY_BYTES - current.bytes.size
+ val old = if (left > 0) tail(previous, left) else Tail(ByteArray(0), previous.exists())
+ val omitted = current.omitted || old.omitted
+ val bytes = old.bytes + current.bytes
+ val text = String(skipUtf8Continuation(bytes), Charsets.UTF_8)
+ return if (omitted) "[older log omitted]\n$text" else text
+ }
+ }
+
+ // Clear both halves, or Clear would leave the rotated one on screen.
+ fun clear() {
+ synchronized(lock) {
+ if (previous.exists() && !previous.delete()) {
+ throw IllegalStateException("could not remove ${previous.name}")
+ }
+ FileOutputStream(file, false).use { }
+ }
+ }
+
+ companion object {
+ private const val MAX_BYTES = 512L * 1024
+ private const val DISPLAY_BYTES = 64 * 1024
+ private const val MAX_LINE_CHARS = 4_000
+ private val lock = Any()
+
+ internal fun cleanLine(value: String): String = buildString(minOf(value.length, MAX_LINE_CHARS + 20)) {
+ for (c in value) {
+ when (c) {
+ '\n' -> append("\\n")
+ '\r' -> append("\\r")
+ else -> if (unsafeDisplayCharacter(c)) {
+ append("\\u")
+ append(c.code.toString(16).padStart(4, '0'))
+ } else {
+ append(c)
+ }
+ }
+ if (length >= MAX_LINE_CHARS) {
+ append(" [truncated]")
+ break
+ }
+ }
+ }
+
+ private fun unsafeDisplayCharacter(c: Char): Boolean {
+ val type = Character.getType(c)
+ return c.isISOControl() || type == Character.FORMAT.toInt() ||
+ type == Character.LINE_SEPARATOR.toInt() || type == Character.PARAGRAPH_SEPARATOR.toInt()
+ }
+ }
+
+ private data class Tail(val bytes: ByteArray, val omitted: Boolean)
+
+ private fun tail(source: File, limit: Int): Tail {
+ if (!source.exists() || limit <= 0) return Tail(ByteArray(0), source.exists() && source.length() > 0)
+ val length = source.length()
+ val count = minOf(length, limit.toLong()).toInt()
+ val bytes = ByteArray(count)
+ RandomAccessFile(source, "r").use {
+ it.seek(length - count)
+ it.readFully(bytes)
+ }
+ return Tail(bytes, length > count)
+ }
+
+ private fun skipUtf8Continuation(bytes: ByteArray): ByteArray {
+ var first = 0
+ while (first < bytes.size && bytes[first].toInt() and 0xc0 == 0x80) first++
+ return if (first == 0) bytes else bytes.copyOfRange(first, bytes.size)
+ }
}
diff --git a/app/src/main/java/invalid/lena/rsend/SyncWorker.kt b/app/src/main/java/invalid/lena/rsend/SyncWorker.kt
index b8c9a9d..6bc2b2c 100644
--- a/app/src/main/java/invalid/lena/rsend/SyncWorker.kt
+++ b/app/src/main/java/invalid/lena/rsend/SyncWorker.kt
@@ -3,7 +3,8 @@ package invalid.lena.rsend
import android.app.Notification
import android.content.Context
import android.content.pm.ServiceInfo
-import android.os.Build
+import android.net.ConnectivityManager
+import android.os.Environment
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.CoroutineWorker
@@ -30,69 +31,123 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
}
override suspend fun doWork(): Result = syncMutex.withLock {
- withContext(Dispatchers.IO) {
- val ctx = applicationContext
- val cfg = Config.load(ctx)
- if (!cfg.syncReady(Keys.exists(ctx))) {
- SyncLog(ctx).line("sync skipped: key, folders, or a pinned remote missing")
- return@withContext Result.failure()
- }
-
- val log = SyncLog(ctx)
- log.rotateIfBig()
- log.line("sync started (${cfg.folders.size} folders)")
- // known_hosts is derived from the config's pins; regenerate it here,
- // under the sync mutex, before the first rsync reads it.
- Keys.writeKnownHosts(ctx, cfg.remotes.map { it.hostKey }.filter { it.isNotEmpty() })
- // A foreground service lets long syncs survive, but Android 12+ forbids
- // starting one when the periodic job fires in the background. Try it, and
- // fall back to a plain background job when it is not allowed.
- try {
- setForeground(foregroundInfo())
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- log.line("foreground unavailable, running in background: ${e.javaClass.simpleName}: ${e.message}")
- }
+ val ctx = applicationContext
+ try {
+ withContext(Dispatchers.IO) {
+ val cfg = Config.load(ctx)
+ // A refused run must be as visible as a failed one. These return
+ // before the loop that would otherwise record the outcome, so
+ // without this the dashboard keeps showing the last success while
+ // nothing has been backed up since.
+ if (!cfg.syncReady(Keys.exists(ctx))) {
+ return@withContext refuse(ctx, "key, folders, or a pinned remote missing")
+ }
+ // All-files access is the only storage permission rsend declares.
+ // Without it configured storage can be unreadable. Refuse the run
+ // before rsync sees a partial view of the source tree.
+ if (!Environment.isExternalStorageManager()) {
+ return@withContext refuse(ctx, "all-files access not granted")
+ }
- var ok = true
- val total = cfg.folders.size
- cfg.folders.forEachIndexed { i, f ->
- val label = f.name.ifEmpty { f.local }
- // Publish progress the dashboard observes (current folder, i of n).
- setProgress(workDataOf(KEY_FOLDER to label, KEY_INDEX to i + 1, KEY_TOTAL to total))
- log.line("=== $label -> ${f.remoteName} ===")
- val remote = cfg.remote(f.remoteName)
- // A broken folder fails alone; the others still sync.
- // Guard against a hand-edited config: an empty path would rsync /.
- val code = if (f.local.isEmpty() || f.remotePath.isEmpty()) {
- log.line("error: local or remote path not set")
- 1
- } else if (remote == null) {
- log.line("error: remote \"${f.remoteName}\" not found")
- 1
- } else if (remote.host.isEmpty() || remote.user.isEmpty()) {
- log.line("error: remote \"${f.remoteName}\" has no host or user")
- 1
- } else if (!remote.pinned()) {
- log.line("error: remote \"${f.remoteName}\" not pinned, run Test connection")
- 1
- } else try {
- RsyncRunner.runFolder(ctx, remote, f, log)
+ val log = SyncLog(ctx)
+ log.rotateIfBig()
+ log.line("sync started (${cfg.folders.size} folders)")
+ // A foreground service lets long syncs survive, but Android 12+ forbids
+ // starting one when the periodic job fires in the background. Try it, and
+ // fall back to a plain background job when it is not allowed.
+ try {
+ setForeground(foregroundInfo())
} catch (e: CancellationException) {
- log.line("cancelled")
throw e
} catch (e: Exception) {
- log.line("error: ${e.javaClass.simpleName}: ${e.message}")
- 1
+ // Worth shouting about: a background job is subject to the
+ // execution limit, so a large first sync can be cut short every
+ // period. rsync resumes, but the user should know why progress
+ // is slow and that the battery exemption fixes it.
+ log.line("WARNING: foreground service refused (${e.javaClass.simpleName}: ${e.message})")
+ log.line("WARNING: running as a background job, which the system may stop early;")
+ log.line("WARNING: grant Battery > unrestricted so long syncs can finish")
}
- log.line("exit=$code")
- if (code != 0) ok = false
+
+ var ok = true
+ val total = cfg.folders.size
+ // Folder labels that failed, so the dashboard identifies the work
+ // that did not complete instead of only saying the run failed.
+ val failed = LinkedHashSet<String>()
+ // Remotes that turned out to be unreachable during this run. A
+ // dropped connect costs the full dial timeout, so pay it once per
+ // remote instead of once per folder pointing at it.
+ val unreachable = HashSet<String>()
+ // Total removed on the server across the run.
+ var deleted = 0L
+ for ((i, f) in cfg.folders.withIndex()) {
+ val label = f.name.ifEmpty { f.local }
+ // Re-check per folder: a run that began on wifi can lose it and
+ // would otherwise push the rest of a photo library over mobile
+ // data. Stop rather than carry on against the user's setting.
+ if (cfg.schedule.wifiOnly && metered(ctx)) {
+ log.line("stopping: network became metered and the schedule is unmetered only")
+ ok = false
+ failed.add("metered network")
+ break
+ }
+ // Publish progress the dashboard observes (current folder, i of n).
+ setProgress(workDataOf(KEY_FOLDER to label.take(256), KEY_INDEX to i + 1, KEY_TOTAL to total))
+ log.line("=== $label -> ${f.remoteName} ===")
+ // Config parsing guarantees the remote exists; being pinned is
+ // a separate thing the user can still be part-way through.
+ val remote = checkNotNull(cfg.remote(f.remoteName))
+ val code = if (!remote.pinned()) {
+ log.line("error: remote \"${f.remoteName}\" not pinned, run Test connection")
+ 1
+ } else if (unreachable.contains(f.remoteName)) {
+ log.line("error: remote \"${f.remoteName}\" was unreachable earlier this run, skipped")
+ 1
+ } else if (!FolderRules.sourceReadable(f.local)) {
+ log.line("error: local source \"${f.local}\" is not a readable directory")
+ 1
+ } else try {
+ val r = RsyncRunner.runFolder(ctx, remote, f, log)
+ if (r.unreachable) unreachable.add(f.remoteName)
+ deleted += r.deleted
+ r.code
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ log.line("error: ${e.javaClass.simpleName}: ${e.message}")
+ 1
+ }
+ log.line("exit=$code")
+ if (!RsyncRunner.succeeded(code, f.delete)) {
+ ok = false
+ failed.add(label)
+ }
+ }
+ log.line("sync finished ok=$ok")
+ // A mirror may legitimately empty its destination, so deletions are
+ // reported rather than prevented. Saying so on the dashboard, and
+ // notifying when a run deleted more than it sent, is what turns a
+ // surprising mass deletion into something noticed the same day.
+ val summary = if (deleted > 0) " ($deleted deleted)" else ""
+ LastSync.set(ctx, if (ok) "ok$summary" else "FAILED: ${failed.joinToString(", ")}$summary")
+ if (!ok) {
+ notifyError(ctx)
+ } else if (deleted > 0) {
+ notifyDeleted(ctx, deleted)
+ }
+ if (ok) Result.success() else Result.failure()
}
- log.line("sync finished ok=$ok")
- LastSync.set(ctx, ok)
- if (!ok) notifyError(ctx)
- if (ok) Result.success() else Result.failure()
+ } catch (e: CancellationException) {
+ // Cancellation can arrive at any suspension point, including
+ // between folders or while publishing progress. Record it once at
+ // the worker boundary instead of only around the rsync process.
+ try {
+ SyncLog(ctx).line("cancelled")
+ LastSync.set(ctx, "interrupted")
+ } catch (recordError: Exception) {
+ e.addSuppressed(recordError)
+ }
+ throw e
}
}
@@ -110,6 +165,40 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
}
}
+ // notifyDeleted reports a successful mirror that removed files on the
+ // server. Mirror semantics make that correct, but a user who did not mean
+ // to empty a folder should hear about it while the local copy may still
+ // exist, not months later.
+ private fun notifyDeleted(ctx: Context, deleted: Long) {
+ val n = NotificationCompat.Builder(ctx, App.CHANNEL)
+ .setContentTitle("rsend")
+ .setContentText("Sync ok. Mirror deleted $deleted item(s) on the server.")
+ .setSmallIcon(R.drawable.ic_notification)
+ .setAutoCancel(true)
+ .build()
+ try {
+ NotificationManagerCompat.from(ctx).notify(3, n)
+ } catch (_: SecurityException) {
+ // POST_NOTIFICATIONS not granted; the log still records the count.
+ }
+ }
+
+ // refuse records a run that never started, so the dashboard and the
+ // notification say so instead of leaving the last success on screen.
+ private fun refuse(ctx: Context, why: String): Result {
+ SyncLog(ctx).line("sync skipped: $why")
+ LastSync.set(ctx, "SKIPPED: $why")
+ notifyError(ctx)
+ return Result.failure()
+ }
+
+ // metered reports whether the current network costs money, so a run can
+ // stop when the user asked for unmetered only. The periodic job has a
+ // WorkManager constraint, but a manual run has none by design, and either
+ // can lose wifi part way through a long sync.
+ private fun metered(ctx: Context): Boolean =
+ ctx.getSystemService(ConnectivityManager::class.java).isActiveNetworkMetered
+
private fun foregroundInfo(): ForegroundInfo {
val n: Notification = NotificationCompat.Builder(applicationContext, App.CHANNEL)
.setContentTitle("rsend")
@@ -117,10 +206,6 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
.build()
- return if (Build.VERSION.SDK_INT >= 29) {
- ForegroundInfo(1, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
- } else {
- ForegroundInfo(1, n)
- }
+ return ForegroundInfo(1, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
}
}
diff --git a/app/src/main/res/layout/activity_folder.xml b/app/src/main/res/layout/activity_folder.xml
index a67af55..40ad432 100644
--- a/app/src/main/res/layout/activity_folder.xml
+++ b/app/src/main/res/layout/activity_folder.xml
@@ -36,6 +36,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
+ android:importantForAutofill="no"
android:inputType="text"
android:hint="DCIM" />
@@ -59,6 +60,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
+ android:importantForAutofill="no"
android:inputType="textUri"
android:hint="/storage/emulated/0/DCIM" />
@@ -97,6 +99,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
+ android:importantForAutofill="no"
android:inputType="textUri"
android:hint="DCIM" />
@@ -113,10 +116,11 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
+ android:importantForAutofill="no"
android:inputType="text"
android:hint=".thumbnails/, .trashed/" />
- <Switch
+ <androidx.appcompat.widget.SwitchCompat
android:id="@+id/delete"
android:layout_width="match_parent"
android:layout_height="wrap_content"
diff --git a/app/src/main/res/layout/activity_log.xml b/app/src/main/res/layout/activity_log.xml
index af15ec2..a44f657 100644
--- a/app/src/main/res/layout/activity_log.xml
+++ b/app/src/main/res/layout/activity_log.xml
@@ -7,50 +7,52 @@
<include layout="@layout/app_toolbar" />
- <LinearLayout
+ <RelativeLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
- android:orientation="vertical"
android:padding="@dimen/space_m">
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="horizontal"
- android:layout_marginBottom="@dimen/space_m">
-
- <Button
- android:id="@+id/refresh"
- style="@style/Widget.Rsend.Button.Secondary"
- android:layout_width="0dp"
+ <LinearLayout
+ android:id="@+id/logActions"
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_weight="1"
- android:layout_marginEnd="@dimen/space_s"
- android:text="Refresh" />
+ android:layout_alignParentTop="true"
+ android:layout_marginBottom="@dimen/space_m"
+ android:orientation="horizontal">
- <Button
- android:id="@+id/clear"
- style="@style/Widget.Rsend.Button.Secondary"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_weight="1"
- android:text="Clear" />
- </LinearLayout>
+ <Button
+ android:id="@+id/refresh"
+ style="@style/Widget.Rsend.Button.Secondary"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginEnd="@dimen/space_s"
+ android:layout_weight="1"
+ android:text="Refresh" />
- <ScrollView
- style="@style/Widget.Rsend.Card"
- android:layout_width="match_parent"
- android:layout_height="0dp"
- android:layout_weight="1">
+ <Button
+ android:id="@+id/clear"
+ style="@style/Widget.Rsend.Button.Secondary"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Clear" />
+ </LinearLayout>
- <TextView
- android:id="@+id/log"
+ <ScrollView
+ style="@style/Widget.Rsend.Card"
android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:textAppearance="@style/TextAppearance.Rsend.Mono"
- android:textSize="11sp"
- android:textIsSelectable="true" />
- </ScrollView>
- </LinearLayout>
+ android:layout_height="match_parent"
+ android:layout_alignParentBottom="true"
+ android:layout_below="@id/logActions">
+
+ <TextView
+ android:id="@+id/log"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textAppearance="@style/TextAppearance.Rsend.Mono"
+ android:textIsSelectable="true"
+ android:textSize="11sp" />
+ </ScrollView>
+ </RelativeLayout>
</LinearLayout>
diff --git a/app/src/main/res/layout/activity_picker.xml b/app/src/main/res/layout/activity_picker.xml
index db4a044..39d8d94 100644
--- a/app/src/main/res/layout/activity_picker.xml
+++ b/app/src/main/res/layout/activity_picker.xml
@@ -7,11 +7,10 @@
<include layout="@layout/app_toolbar" />
- <LinearLayout
+ <RelativeLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
- android:orientation="vertical"
android:padding="@dimen/space_m">
<TextView
@@ -19,43 +18,47 @@
style="@style/Widget.Rsend.Card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
+ android:layout_alignParentTop="true"
android:textAppearance="@style/TextAppearance.Rsend.Mono"
android:textSize="12sp" />
- <ScrollView
- android:layout_width="match_parent"
- android:layout_height="0dp"
- android:layout_weight="1">
-
- <LinearLayout
- android:id="@+id/list"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="vertical" />
- </ScrollView>
-
<LinearLayout
+ android:id="@+id/pickerActions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:orientation="horizontal"
- android:layout_marginTop="@dimen/space_m">
+ android:layout_alignParentBottom="true"
+ android:layout_marginTop="@dimen/space_m"
+ android:orientation="horizontal">
<Button
- android:id="@+id/use"
- style="@style/Widget.Rsend.Button.Primary"
+ android:id="@+id/cancel"
+ style="@style/Widget.Rsend.Button.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
- android:layout_weight="1"
android:layout_marginEnd="@dimen/space_s"
- android:text="Use this folder" />
+ android:layout_weight="1"
+ android:text="Cancel" />
<Button
- android:id="@+id/cancel"
- style="@style/Widget.Rsend.Button.Secondary"
+ android:id="@+id/use"
+ style="@style/Widget.Rsend.Button.Primary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
- android:text="Cancel" />
+ android:text="Use this folder" />
</LinearLayout>
- </LinearLayout>
+
+ <ScrollView
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_above="@id/pickerActions"
+ android:layout_below="@id/currentPath">
+
+ <LinearLayout
+ android:id="@+id/list"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical" />
+ </ScrollView>
+ </RelativeLayout>
</LinearLayout>
diff --git a/app/src/main/res/layout/activity_remote.xml b/app/src/main/res/layout/activity_remote.xml
index ab7690c..fa89385 100644
--- a/app/src/main/res/layout/activity_remote.xml
+++ b/app/src/main/res/layout/activity_remote.xml
@@ -36,6 +36,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
+ android:importantForAutofill="no"
android:inputType="text"
android:hint="nas" />
@@ -52,6 +53,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
+ android:importantForAutofill="no"
android:inputType="textUri"
android:hint="home.example.org" />
@@ -68,6 +70,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
+ android:importantForAutofill="no"
android:inputType="number"
android:hint="22" />
@@ -84,6 +87,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
+ android:importantForAutofill="no"
android:inputType="text"
android:hint="backup" />
</LinearLayout>
diff --git a/app/src/main/res/layout/activity_schedule.xml b/app/src/main/res/layout/activity_schedule.xml
index d06ef1b..a02edd3 100644
--- a/app/src/main/res/layout/activity_schedule.xml
+++ b/app/src/main/res/layout/activity_schedule.xml
@@ -24,7 +24,7 @@
android:layout_height="wrap_content"
android:orientation="vertical">
- <Switch
+ <androidx.appcompat.widget.SwitchCompat
android:id="@+id/enabled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -44,10 +44,11 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
+ android:importantForAutofill="no"
android:inputType="number"
android:hint="120" />
- <Switch
+ <androidx.appcompat.widget.SwitchCompat
android:id="@+id/wifiOnly"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -55,7 +56,7 @@
android:textAppearance="@style/TextAppearance.Rsend.Body"
android:text="Unmetered network only" />
- <Switch
+ <androidx.appcompat.widget.SwitchCompat
android:id="@+id/charging"
android:layout_width="match_parent"
android:layout_height="wrap_content"
diff --git a/app/src/main/res/layout/app_toolbar.xml b/app/src/main/res/layout/app_toolbar.xml
index 12eeb47..a6c18b8 100644
--- a/app/src/main/res/layout/app_toolbar.xml
+++ b/app/src/main/res/layout/app_toolbar.xml
@@ -3,9 +3,11 @@
Toolbar; the dark overlay gives the title light text on the dark bar. -->
<androidx.appcompat.widget.Toolbar
xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/bar"
android:elevation="4dp"
- android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar" />
+ android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
+ tools:ignore="Overdraw" />
diff --git a/app/src/main/res/layout/item_dir.xml b/app/src/main/res/layout/item_dir.xml
index 651224c..4af38e3 100644
--- a/app/src/main/res/layout/item_dir.xml
+++ b/app/src/main/res/layout/item_dir.xml
@@ -1,27 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<TextView xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/dirName"
style="@style/Widget.Rsend.Card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:orientation="horizontal"
- android:gravity="center_vertical"
android:clickable="true"
+ android:drawablePadding="@dimen/space_m"
+ android:ellipsize="end"
android:focusable="true"
- android:foreground="?attr/selectableItemBackground">
-
- <ImageView
- android:layout_width="24dp"
- android:layout_height="24dp"
- android:layout_marginEnd="@dimen/space_m"
- android:src="@drawable/ic_folder"
- android:contentDescription="@null" />
-
- <TextView
- android:id="@+id/dirName"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_weight="1"
- android:maxLines="1"
- android:ellipsize="end"
- android:textAppearance="@style/TextAppearance.Rsend.Title" />
-</LinearLayout>
+ android:foreground="?attr/selectableItemBackground"
+ android:gravity="center_vertical"
+ android:maxLines="1"
+ android:textAppearance="@style/TextAppearance.Rsend.Title"
+ app:drawableStartCompat="@drawable/ic_folder" />
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
index b3e26b4..b3e26b4 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..b853a8d
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<data-extraction-rules>
+ <cloud-backup>
+ <exclude domain="root" path="." />
+ <exclude domain="file" path="." />
+ <exclude domain="database" path="." />
+ <exclude domain="sharedpref" path="." />
+ <exclude domain="external" path="." />
+ <exclude domain="device_root" path="." />
+ <exclude domain="device_file" path="." />
+ <exclude domain="device_database" path="." />
+ <exclude domain="device_sharedpref" path="." />
+ </cloud-backup>
+ <device-transfer>
+ <exclude domain="root" path="." />
+ <exclude domain="file" path="." />
+ <exclude domain="database" path="." />
+ <exclude domain="sharedpref" path="." />
+ <exclude domain="external" path="." />
+ <exclude domain="device_root" path="." />
+ <exclude domain="device_file" path="." />
+ <exclude domain="device_database" path="." />
+ <exclude domain="device_sharedpref" path="." />
+ </device-transfer>
+</data-extraction-rules>
diff --git a/app/src/main/res/xml/backup_rules_legacy.xml b/app/src/main/res/xml/backup_rules_legacy.xml
new file mode 100644
index 0000000..49fc980
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules_legacy.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<full-backup-content>
+ <exclude domain="root" path="." />
+ <exclude domain="file" path="." />
+ <exclude domain="database" path="." />
+ <exclude domain="sharedpref" path="." />
+ <exclude domain="external" path="." />
+ <exclude domain="device_root" path="." />
+ <exclude domain="device_file" path="." />
+ <exclude domain="device_database" path="." />
+ <exclude domain="device_sharedpref" path="." />
+</full-backup-content>
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))
+ }
+}