diff --git a/android/app/src/main/java/tv/orbit/classic/InstallSnapshot.kt b/android/app/src/main/java/tv/orbit/classic/InstallSnapshot.kt index 115f7b7..17b0f54 100644 --- a/android/app/src/main/java/tv/orbit/classic/InstallSnapshot.kt +++ b/android/app/src/main/java/tv/orbit/classic/InstallSnapshot.kt @@ -24,10 +24,16 @@ import java.security.MessageDigest class InstallSnapshotReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val result = runCatching { - if (intent.getStringExtra("do") == "restore") { + if (intent.getStringExtra("do") == "prepare-restore") { + // adb can't make folders in Orbit's shared folder on Android 14, only put files in one Orbit made. + File(context.getExternalFilesDir(null), "install-restore").run { deleteRecursively(); mkdirs(); "ready" } + } else if (intent.getStringExtra("do") == "restore") { if (InstallSnapshot.screenOpen) "refused: Orbit is open; force-stop it first" - else InstallRestore.apply(context.filesDir.parentFile!!, File(context.getExternalFilesDir(null), "install-restore"), - intent.getStringExtra("confirm"), System.currentTimeMillis()) + else File(context.getExternalFilesDir(null), "install-restore").let { folder -> + // The pushed copy goes once it's been used, restored or refused. + try { InstallRestore.apply(context.filesDir.parentFile!!, folder, intent.getStringExtra("confirm"), System.currentTimeMillis()) } + finally { if (intent.getStringExtra("confirm") == "RESTORE") folder.deleteRecursively() } + } } else InstallSnapshot.handle(context, intent.getStringExtra("do").orEmpty(), intent.getStringExtra("version")) } resultData = result.fold({ it }, { "failed" }) @@ -95,8 +101,24 @@ internal object InstallSnapshot { internal object InstallRestore { private val allowed = Regex("""no_backup/schedule/[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)?|shared_prefs/orbit_(settings|profiles)\.xml""") - fun apply(data: File, source: File, confirm: String?, nowMs: Long): String { + fun apply(data: File, folder: File, confirm: String?, nowMs: Long): String { if (confirm != "RESTORE") return "refused: needs confirm RESTORE" + // The snapshot comes as one restore.zip (adb can only put files in a folder Orbit made): unpacked beside it, taking + // only restore.txt and the names a snapshot can hold, so nothing can be written anywhere else. + val zip = File(folder, "restore.zip") + val source = if (!zip.isFile) folder else File(folder, "unpacked").also { out -> + out.deleteRecursively(); out.mkdirs() + java.util.zip.ZipInputStream(zip.inputStream().buffered()).use { z -> + while (true) { + val e = z.nextEntry ?: break + if (e.isDirectory) continue + val name = e.name + if (name != "restore.txt" && (!allowed.matches(name) || name.split('/').any { it == ".." || it == "." || it.isEmpty() })) + return "refused: not allowed in zip: ${name.take(80)}" + File(out, name).apply { parentFile?.mkdirs() }.outputStream().use { z.copyTo(it) } + } + } + } val manifest = File(source, "restore.txt").takeIf { it.isFile } ?: return "refused: no restore.txt" val listed = manifest.readLines().filter { it.isNotBlank() }.map { line -> val hash = line.substringBefore(' '); val path = line.substringAfter(' ').trimStart(' ', '*') diff --git a/android/app/src/test/java/tv/orbit/classic/InstallRestoreTest.kt b/android/app/src/test/java/tv/orbit/classic/InstallRestoreTest.kt index a8612e6..262a368 100644 --- a/android/app/src/test/java/tv/orbit/classic/InstallRestoreTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/InstallRestoreTest.kt @@ -62,4 +62,22 @@ class InstallRestoreTest { for (t in 1L..4L) InstallRestore.apply(data, src, "RESTORE", t) assertEquals(listOf("before-restore-3", "before-restore-4"), File(data, "no_backup").list()!!.filter { it.startsWith("before-restore-") }.sorted()) } + + @Test fun aZipFromThePcIsUnpackedAndRestored() { + val (data, src) = box() + manifest(src, listOf("no_backup/schedule/booked-0.json", "shared_prefs/orbit_profiles.xml")) + val folder = tmp.newFolder("pushed") + java.util.zip.ZipOutputStream(File(folder, "restore.zip").outputStream()).use { z -> + for (n in listOf("no_backup/schedule/booked-0.json", "shared_prefs/orbit_profiles.xml", "restore.txt")) { z.putNextEntry(java.util.zip.ZipEntry(n)); z.write(File(src, n).readBytes()); z.closeEntry() } + } + assertTrue(InstallRestore.apply(data, folder, "RESTORE", 5L).startsWith("restored 2")) + assertEquals("then", File(data, "no_backup/schedule/booked-0.json").readText()) + } + @Test fun aZipCantWriteOutsideTheSnapshot() { + val (data, _) = box() + val folder = tmp.newFolder("evil") + java.util.zip.ZipOutputStream(File(folder, "restore.zip").outputStream()).use { z -> z.putNextEntry(java.util.zip.ZipEntry("../../no_backup/catalogue.db")); z.write("x".toByteArray()); z.closeEntry() } + assertTrue(InstallRestore.apply(data, folder, "RESTORE", 5L).startsWith("refused")) + assertEquals("library", File(data, "no_backup/catalogue.db").readText()) + } } diff --git a/tools/restore-tv.sh b/tools/restore-tv.sh index 930ee18..d382e70 100755 --- a/tools/restore-tv.sh +++ b/tools/restore-tv.sh @@ -8,18 +8,20 @@ set -euo pipefail A=${ADB:-/opt/android-sdk/platform-tools/adb}; D=$1; SNAP=$(cd "$2" && pwd) [ -d "$SNAP/no_backup/schedule" ] || { echo "Not a snapshot folder: $SNAP"; exit 1; } REMOTE=/sdcard/Android/data/tv.orbit.classic/files/install-restore -STAGE=$(mktemp -d); trap 'rm -rf "$STAGE"' EXIT +STAGE=$(mktemp -d); trap 'rm -rf "$STAGE"; $A -s "$D" shell am start -n tv.orbit.classic/.MainActivity >/dev/null 2>&1 || true' EXIT ( cd "$SNAP" && find no_backup/schedule -type f; for p in shared_prefs/orbit_settings.xml shared_prefs/orbit_profiles.xml; do [ -f "$p" ] && echo "$p"; done ) | sort > "$STAGE/list" ( cd "$SNAP" && xargs -d '\n' sha256sum < "$STAGE/list" ) > "$STAGE/restore.txt" echo "restoring $(wc -l < "$STAGE/list") files from $SNAP" read -r -p "Type RESTORE to put this snapshot back on $D: " ok; [ "$ok" = "RESTORE" ] || { echo "Not restored."; exit 1; } +( cd "$SNAP" && python3 -I -c 'import sys,zipfile +z=zipfile.ZipFile(sys.argv[1],"w",zipfile.ZIP_DEFLATED) +[z.write(p.strip(),p.strip()) for p in open(sys.argv[2]) if p.strip()] +z.write(sys.argv[3],"restore.txt"); z.close()' "$STAGE/restore.zip" "$STAGE/list" "$STAGE/restore.txt" ) $A -s "$D" shell am force-stop tv.orbit.classic -$A -s "$D" shell rm -rf "$REMOTE" -$A -s "$D" push "$SNAP/no_backup" "$REMOTE/no_backup" >/dev/null -[ -d "$SNAP/shared_prefs" ] && $A -s "$D" push "$SNAP/shared_prefs" "$REMOTE/shared_prefs" >/dev/null -$A -s "$D" push "$STAGE/restore.txt" "$REMOTE/restore.txt" >/dev/null -$A -s "$D" shell am broadcast -f 0x20 -n tv.orbit.classic/.InstallSnapshotReceiver -a tv.orbit.classic.INSTALL_SNAPSHOT --es do restore --es confirm RESTORE | grep -o 'data="[^"]*"' +R="$A -s $D shell am broadcast -f 0x20 -n tv.orbit.classic/.InstallSnapshotReceiver -a tv.orbit.classic.INSTALL_SNAPSHOT" +$R --es do prepare-restore | grep -q 'data="ready"' || { echo "The TV couldn't get ready; nothing changed."; $A -s "$D" shell am start -n tv.orbit.classic/.MainActivity >/dev/null; exit 1; } +$A -s "$D" push "$STAGE/restore.zip" "$REMOTE/restore.zip" >/dev/null +$R --es do restore --es confirm RESTORE | grep -o 'data="[^"]*"' $A -s "$D" shell am force-stop tv.orbit.classic -$A -s "$D" shell rm -rf "$REMOTE" $A -s "$D" shell am start -n tv.orbit.classic/.MainActivity >/dev/null echo "Orbit restarted. Take a snapshot and compare it with $SNAP to check."