From 2593151ca2c67256442ad25b074504e3ecd3371f Mon Sep 17 00:00:00 2001 From: Lena Date: Wed, 1 Jul 2026 00:00:00 +0000 Subject: app: harden sync execution and persisted state Persist config, key blob, and host pin via AtomicFile so an interrupted write cannot corrupt state. Validate the port, clear the host pin when the remote changes, cap imported key size, time out native helpers, and escalate rsync termination to destroyForcibly. Serialize manual and scheduled syncs behind a mutex. Replace the chained one-time jobs with plain periodic work: it survives reboots without a boot receiver and cannot silently die like a broken chain. Costs the sub-15-minute interval, which photo backup does not need. Document the floor and that syncs run on any unmetered network, not only WiFi. --- .../main/java/invalid/lena/rsend/BootReceiver.kt | 15 ---- app/src/main/java/invalid/lena/rsend/Config.kt | 38 ++++++--- app/src/main/java/invalid/lena/rsend/KeyVault.kt | 4 +- app/src/main/java/invalid/lena/rsend/Keys.kt | 27 +++++- .../main/java/invalid/lena/rsend/MainActivity.kt | 28 ++++++- app/src/main/java/invalid/lena/rsend/Native.kt | 8 +- .../main/java/invalid/lena/rsend/RemoteActivity.kt | 37 +++++--- .../main/java/invalid/lena/rsend/RsyncRunner.kt | 15 +++- .../java/invalid/lena/rsend/ScheduleActivity.kt | 7 +- app/src/main/java/invalid/lena/rsend/Scheduler.kt | 55 +++--------- app/src/main/java/invalid/lena/rsend/SyncWorker.kt | 98 +++++++++++----------- 11 files changed, 188 insertions(+), 144 deletions(-) delete mode 100644 app/src/main/java/invalid/lena/rsend/BootReceiver.kt (limited to 'app/src/main/java/invalid') diff --git a/app/src/main/java/invalid/lena/rsend/BootReceiver.kt b/app/src/main/java/invalid/lena/rsend/BootReceiver.kt deleted file mode 100644 index 3e135f3..0000000 --- a/app/src/main/java/invalid/lena/rsend/BootReceiver.kt +++ /dev/null @@ -1,15 +0,0 @@ -package invalid.lena.rsend - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent - -// BootReceiver re-applies the schedule after the device restarts, so periodic -// sync survives reboots even if WorkManager's own state was cleared. -class BootReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == Intent.ACTION_BOOT_COMPLETED) { - Scheduler.apply(context) - } - } -} diff --git a/app/src/main/java/invalid/lena/rsend/Config.kt b/app/src/main/java/invalid/lena/rsend/Config.kt index d7f17eb..2662ea3 100644 --- a/app/src/main/java/invalid/lena/rsend/Config.kt +++ b/app/src/main/java/invalid/lena/rsend/Config.kt @@ -1,10 +1,11 @@ package invalid.lena.rsend import android.content.Context +import android.util.AtomicFile import org.json.JSONArray import org.json.JSONObject import java.io.File -import java.io.IOException +import java.io.FileNotFoundException // Config is rsend's whole state: the remote target, the schedule, and the // folders to push. It is stored as plain JSON in app-private storage. @@ -16,7 +17,11 @@ data class Schedule( val intervalMinutes: Int = 120, val wifiOnly: Boolean = true, val requireCharging: Boolean = false, -) +) { + companion object { + const val MIN_INTERVAL_MINUTES = 15 + } +} data class Folder( val name: String = "", @@ -61,17 +66,27 @@ data class Config( fun load(ctx: Context): Config { val f = file(ctx) - if (!f.exists()) return Config() - return fromJson(JSONObject(f.readText())) + return try { + val text = AtomicFile(f).openRead().bufferedReader().use { it.readText() } + fromJson(JSONObject(text)) + } catch (_: FileNotFoundException) { + Config() + } catch (e: Exception) { + throw IllegalStateException("could not read ${f.name}: ${e.message}", e) + } } - // save writes tmp-then-rename so a crash mid-write cannot corrupt the - // config and brick every later load. + // AtomicFile keeps the previous complete config if a write is interrupted. fun save(ctx: Context, c: Config) { - val f = file(ctx) - val tmp = File(f.path + ".tmp") - tmp.writeText(c.toJson().toString(2)) - if (!tmp.renameTo(f)) throw IOException("rename ${tmp.path} failed") + val file = AtomicFile(file(ctx)) + val out = file.startWrite() + try { + out.write(c.toJson().toString(2).toByteArray()) + file.finishWrite(out) + } catch (e: Exception) { + file.failWrite(out) + throw e + } } fun fromJson(o: JSONObject): Config { @@ -102,7 +117,8 @@ data class Config( ), schedule = Schedule( enabled = s.optBoolean("enabled", false), - intervalMinutes = s.optInt("intervalMinutes", 120), + intervalMinutes = s.optInt("intervalMinutes", 120) + .coerceAtLeast(Schedule.MIN_INTERVAL_MINUTES), wifiOnly = s.optBoolean("wifiOnly", true), requireCharging = s.optBoolean("requireCharging", false), ), diff --git a/app/src/main/java/invalid/lena/rsend/KeyVault.kt b/app/src/main/java/invalid/lena/rsend/KeyVault.kt index ae3e8d4..ecbc9cf 100644 --- a/app/src/main/java/invalid/lena/rsend/KeyVault.kt +++ b/app/src/main/java/invalid/lena/rsend/KeyVault.kt @@ -8,8 +8,8 @@ import javax.crypto.KeyGenerator import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec -// KeyVault encrypts the SSH private key at rest with a hardware-backed AES-GCM -// key in the Android Keystore. The Keystore key cannot be exported, so the blob +// KeyVault encrypts the SSH private key at rest with an AES-GCM key in the +// Android Keystore. The Keystore key cannot be exported, so the blob // on disk is useless off this device. Blob layout is IV || ciphertext+tag. object KeyVault { diff --git a/app/src/main/java/invalid/lena/rsend/Keys.kt b/app/src/main/java/invalid/lena/rsend/Keys.kt index fa233f7..501a124 100644 --- a/app/src/main/java/invalid/lena/rsend/Keys.kt +++ b/app/src/main/java/invalid/lena/rsend/Keys.kt @@ -1,6 +1,7 @@ package invalid.lena.rsend import android.content.Context +import android.util.AtomicFile import java.io.File // Keys manages the on-device ed25519 identity and the pinned host keys. The @@ -10,6 +11,7 @@ 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") + const val MAX_KEY_BYTES = 64 * 1024 fun knownHosts(ctx: Context): File = File(ctx.filesDir, "known_hosts") @@ -31,11 +33,11 @@ object Keys { // 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) { + 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))) if (r.code != 0) throw IllegalArgumentException(r.output.trim().ifEmpty { "invalid private key" }) - val pub = r.output.trim() - keyEnc(ctx).writeBytes(KeyVault.encrypt(pem)) - publicKey(ctx).writeText(pub + "\n") + atomicWrite(keyEnc(ctx), KeyVault.encrypt(pem)) + atomicWrite(publicKey(ctx), (r.output.trim() + "\n").toByteArray()) } fun publicKeyText(ctx: Context): String = @@ -67,7 +69,24 @@ object Keys { } fun pin(ctx: Context, line: String) { - knownHosts(ctx).writeText(line + "\n") + atomicWrite(knownHosts(ctx), (line + "\n").toByteArray()) + } + + fun clearPin(ctx: Context) { + knownHosts(ctx).delete() + } + + // AtomicFile keeps the previous complete file if a write is interrupted. + 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 + } } fun pinned(ctx: Context): Boolean = knownHosts(ctx).let { it.exists() && it.length() > 0 } diff --git a/app/src/main/java/invalid/lena/rsend/MainActivity.kt b/app/src/main/java/invalid/lena/rsend/MainActivity.kt index ce65c2e..1154449 100644 --- a/app/src/main/java/invalid/lena/rsend/MainActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/MainActivity.kt @@ -25,6 +25,7 @@ import androidx.work.ExistingWorkPolicy 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 @@ -128,8 +129,11 @@ class MainActivity : AppCompatActivity() { } private fun syncNow() { - if (!Keys.pinned(this)) { - Toast.makeText(this, "Set the remote and pin its host key first.", Toast.LENGTH_LONG).show() + val cfg = Config.load(this) + if (cfg.remote.host.isEmpty() || cfg.remote.user.isEmpty() || cfg.folders.isEmpty() || + !Keys.exists(this) || !Keys.pinned(this)) { + Toast.makeText(this, "Set the remote, key, host pin, and at least one folder first.", Toast.LENGTH_LONG) + .show() return } val req = OneTimeWorkRequestBuilder().build() @@ -210,7 +214,7 @@ class MainActivity : AppCompatActivity() { val s = cfg.schedule val sched = if (s.enabled) { "Every ${s.intervalMinutes}m" + - (if (s.wifiOnly) ", wifi" else "") + + (if (s.wifiOnly) ", unmetered" else "") + (if (s.requireCharging) ", charging" else "") } else { "Off" @@ -302,7 +306,9 @@ class MainActivity : AppCompatActivity() { e.message ?: "keygen failed" } runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread if (error == null) { + Scheduler.apply(this) refresh() Toast.makeText(this, "New key generated.", Toast.LENGTH_SHORT).show() showKey() @@ -326,7 +332,19 @@ class MainActivity : AppCompatActivity() { private fun importKeyFrom(uri: Uri) { thread { val error = try { - val data = contentResolver.openInputStream(uri)?.use { it.readBytes() } + val data = contentResolver.openInputStream(uri)?.use { input -> + val out = ByteArrayOutputStream() + val buf = ByteArray(4096) + while (true) { + val n = input.read(buf) + if (n < 0) break + if (out.size() + n > Keys.MAX_KEY_BYTES) { + throw IllegalArgumentException("private key exceeds ${Keys.MAX_KEY_BYTES} bytes") + } + out.write(buf, 0, n) + } + out.toByteArray() + } ?: throw IllegalArgumentException("could not read the file") Keys.importKey(this, data) null @@ -334,7 +352,9 @@ class MainActivity : AppCompatActivity() { e.message ?: "import failed" } runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread if (error == null) { + Scheduler.apply(this) refresh() Toast.makeText(this, "Key imported.", Toast.LENGTH_SHORT).show() showKey() diff --git a/app/src/main/java/invalid/lena/rsend/Native.kt b/app/src/main/java/invalid/lena/rsend/Native.kt index ec02cfe..7f7b5ba 100644 --- a/app/src/main/java/invalid/lena/rsend/Native.kt +++ b/app/src/main/java/invalid/lena/rsend/Native.kt @@ -2,6 +2,7 @@ package invalid.lena.rsend import android.content.Context import java.io.File +import java.util.concurrent.TimeUnit // Native locates and runs the executables shipped inside the APK as lib*.so. // Android only allows exec of native code from the app's native library dir, @@ -19,8 +20,13 @@ object Native { val pb = ProcessBuilder(listOf(bin.absolutePath) + args).redirectErrorStream(true) pb.environment().putAll(env) val p = pb.start() + if (!p.waitFor(45, TimeUnit.SECONDS)) { + p.destroyForcibly() + p.waitFor() + throw IllegalStateException("${bin.name} timed out") + } val out = p.inputStream.bufferedReader().use { it.readText() } - val code = p.waitFor() + val code = p.exitValue() return Result(code, out) } } diff --git a/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt b/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt index a587667..5ff53f9 100644 --- a/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt @@ -33,24 +33,34 @@ class RemoteActivity : AppCompatActivity() { port.setText(cfg.remote.port.toString()) user.setText(cfg.remote.user) - findViewById