diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java index d6f6b0076..cd8c6c44d 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java @@ -299,27 +299,50 @@ public final class LinuxRuntimeInstaller { discard(staging, null); return false; } + java.util.List carryPaths = root.isDirectory() + ? RuntimeFileTree.existingUserPaths(root, staging) : java.util.Collections.emptyList(); if (root.isDirectory() && !root.renameTo(old)) { RuntimeFileTree.delete(staging, null); return false; } + File carryMarker = new File(staging, CARRY_MARKER); + java.util.List encodedPaths = new java.util.ArrayList<>(); + for (String rel : carryPaths) encodedPaths.add(java.net.URLEncoder.encode(rel, "UTF-8")); + if (!FileUtils.writeString(carryMarker, String.join("\n", encodedPaths))) { + if (old.isDirectory() && !old.renameTo(root)) + Log.w(TAG, "could not restore the previous runtime; retained at " + old); + discard(staging, null); + return false; + } + java.util.List carried; try { - RuntimeFileTree.carryHome(old, staging); + carried = RuntimeFileTree.carryUserData(old, staging, false, carryPaths); } catch (IOException e) { - Log.w(TAG, "could not carry root across the update; rolling back", e); - old.renameTo(root); - try { RuntimeFileTree.delete(staging, null); } - catch (IOException cleanup) { Log.w(TAG, "could not clear the failed staging tree", cleanup); } + Log.w(TAG, "could not carry user data across the update; rolling back", e); + if (old.isDirectory() && !old.renameTo(root)) + Log.w(TAG, "could not restore the previous runtime; retained at " + old); + if (e.getSuppressed().length == 0) { + new File(staging, CARRY_MARKER).delete(); + try { RuntimeFileTree.delete(staging, null); } + catch (IOException cleanup) { Log.w(TAG, "could not clear the failed staging tree", cleanup); } + } else { + Log.w(TAG, "incomplete rollback; retaining user data in " + staging); + } return false; } if (!staging.renameTo(root)) { - File keptTo = new File(staging, USER_DATA); - if (keptTo.isDirectory() && old.isDirectory()) keptTo.renameTo(new File(old, USER_DATA)); - if (old.isDirectory()) old.renameTo(root); + if (old.isDirectory()) { + try { + RuntimeFileTree.carryBack(staging, old, carried); + new File(staging, CARRY_MARKER).delete(); + } catch (IOException e) { Log.w(TAG, "could not move all user data back after a failed swap", e); } + old.renameTo(root); + } return false; } + new File(root, CARRY_MARKER).delete(); discard(old, null); return LinuxRuntime.isInstalled(context); } catch (Exception e) { @@ -330,17 +353,41 @@ public final class LinuxRuntimeInstaller { } } + static final String CARRY_MARKER = ".droiddeck-carry"; + static void recoverInterruptedSwap(File root, File staging, File old) throws IOException { + if (!root.isDirectory() && old.isDirectory()) { + if (!old.renameTo(root)) throw new IOException("Cannot restore the previous runtime at " + old); + Log.w(TAG, "restored the previous runtime after an interrupted update"); + } + File marker = new File(staging, CARRY_MARKER); + if (!RuntimeFileTree.exists(marker.toPath())) return; File target = root.isDirectory() ? root : old; + String markerContent = FileUtils.readString(marker); + if (markerContent == null) throw new IOException("Cannot read runtime recovery marker: " + marker); + if (!markerContent.equals("1")) { + java.util.List intended = new java.util.ArrayList<>(); + for (String rel : markerContent.split("\\n")) + if (!rel.isEmpty()) intended.add(java.net.URLDecoder.decode(rel, "UTF-8")); + if (target.isDirectory()) RuntimeFileTree.recoverCarriedPaths(staging, target, intended); + if (staging.isDirectory() && RuntimeFileTree.hasPaths(staging, intended)) + throw new IOException("User data still needs recovery from " + staging); + marker.delete(); + return; + } File stagedHome = new File(staging, USER_DATA); File home = new File(target, USER_DATA); - if (target.isDirectory() && stagedHome.isDirectory() && (!home.exists() || isEmptyDir(home))) { + if (target.isDirectory() && stagedHome.isDirectory() && (!RuntimeFileTree.exists(home.toPath()) || isEmptyDir(home))) { RuntimeFileTree.carryHome(staging, target); Log.w(TAG, "recovered " + USER_DATA + " from an interrupted update"); } - if (!root.isDirectory() && old.isDirectory() && old.renameTo(root)) { - Log.w(TAG, "restored the previous runtime after an interrupted update"); + if (target.isDirectory() && staging.isDirectory()) { + java.util.List rest = RuntimeFileTree.carryUserData(staging, target, true); + if (!rest.isEmpty()) Log.w(TAG, "recovered " + rest + " from an interrupted update"); } + if (staging.isDirectory() && RuntimeFileTree.hasUserData(staging, target)) + throw new IOException("User data still needs recovery from " + staging); + new File(staging, CARRY_MARKER).delete(); } static final String TRASH = ".runtime-trash"; @@ -364,9 +411,10 @@ public final class LinuxRuntimeInstaller { Log.i(TAG, "some runtime leftovers still cannot be deleted: " + trash); } - private static boolean isEmptyDir(File dir) { - String[] names = dir.list(); - return dir.isDirectory() && names != null && names.length == 0; + private static boolean isEmptyDir(File dir) throws IOException { + try (java.nio.file.DirectoryStream entries = java.nio.file.Files.newDirectoryStream(dir.toPath())) { + return !entries.iterator().hasNext(); + } } public static Removal beginUninstall(Context context) { diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java index 5fb94de05..42e7f5b11 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java @@ -74,6 +74,163 @@ public final class RuntimeFileTree { throw new IOException("Cannot make directory removable: " + dir); } + /** + * Paths in a runtime tree that belong to the user: the home folder, imported AppImages and script apps, and catalog + * AppImages with their menu entries and markers. Tar-kind catalog packages (the desktop) unpack over /usr and cannot + * be carried. + */ + static java.util.List userPaths(File tree) throws IOException { + java.util.List paths = new java.util.ArrayList<>(java.util.Arrays.asList("root", "opt/appimages/user", "opt/droiddeck-apps")); + for (String n : children(new File(tree, "usr/local/share/applications"))) { + if (n.endsWith(".desktop") && (n.startsWith("droiddeck-app-") || n.startsWith("droiddeck-appimage-"))) + paths.add("usr/local/share/applications/" + n); + } + for (String n : children(new File(tree, "opt/appimages"))) { + if (!n.endsWith(".AppImage")) continue; + String id = n.substring(0, n.length() - ".AppImage".length()); + java.util.Collections.addAll(paths, "opt/appimages/" + n, + "usr/share/applications/droiddeck-" + id + ".desktop", ".droiddeck-pkg-" + id); + } + for (String n : children(new File(tree, "usr/share/applications"))) { + if (!n.startsWith("droiddeck-") || !n.endsWith(".desktop")) continue; + String id = n.substring("droiddeck-".length(), n.length() - ".desktop".length()); + if (!exists(new File(tree, "opt/appimages/" + id + ".AppImage").toPath())) + paths.add("usr/share/applications/" + n); + } + for (String n : children(tree)) { + if (!n.startsWith(".droiddeck-pkg-")) continue; + String id = n.substring(".droiddeck-pkg-".length()); + boolean hasMenu = exists(new File(tree, "usr/share/applications/droiddeck-" + id + ".desktop").toPath()); + if (hasMenu && !exists(new File(tree, "opt/appimages/" + id + ".AppImage").toPath())) + paths.add(n); + } + return paths; + } + + static java.util.List existingUserPaths(File from, File to) throws IOException { + java.util.List paths = new java.util.ArrayList<>(); + for (String rel : userPaths(from, to)) if (exists(safePath(from, rel).toPath())) paths.add(rel); + return paths; + } + + private static java.util.List children(File dir) throws IOException { + java.util.List names = new java.util.ArrayList<>(); + try (DirectoryStream entries = Files.newDirectoryStream(dir.toPath())) { + for (Path entry : entries) names.add(entry.getFileName().toString()); + } catch (NoSuchFileException e) { + return names; + } + return names; + } + + static boolean exists(Path path) throws IOException { + try { Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); return true; } + catch (NoSuchFileException e) { return false; } + } + + private static java.util.Set userPaths(File from, File to) throws IOException { + java.util.Set paths = new java.util.LinkedHashSet<>(userPaths(from)); + paths.addAll(userPaths(to)); + return paths; + } + + static boolean hasUserData(File from, File to) throws IOException { + for (String rel : userPaths(from, to)) if (exists(new File(from, rel).toPath())) return true; + return false; + } + + /** + * Moves each user path that exists in {@code from} into {@code to}, replacing what {@code to} ships there. With + * {@code onlyMissing}, a path already present in {@code to} is left alone. On failure the paths already moved go back + * before the exception is thrown. Rollback failures are suppressed on that exception; callers must retain the source + * of any incomplete rollback. + * Returns the moved paths. + */ + static java.util.List carryUserData(File from, File to, boolean onlyMissing) throws IOException { + return carryUserData(from, to, onlyMissing, new java.util.ArrayList<>(userPaths(from, to))); + } + + static java.util.List carryUserData(File from, File to, boolean onlyMissing, java.util.List paths) throws IOException { + java.util.List moved = new java.util.ArrayList<>(); + try { + for (String rel : paths) { + File src = safePath(from, rel); + if (!exists(src.toPath())) continue; + File dst = safePath(to, rel); + if (onlyMissing && exists(dst.toPath())) continue; + move(from, to, rel); + moved.add(rel); + } + } catch (IOException e) { + for (int i = moved.size() - 1; i >= 0; i--) { + try { move(to, from, moved.get(i)); } + catch (IOException back) { e.addSuppressed(back); } + } + throw e; + } + return moved; + } + + /** Puts the given paths back from {@code from} into {@code to}; failures are collected, not stopped on. */ + static void carryBack(File from, File to, java.util.List paths) throws IOException { + IOException failed = null; + for (int i = paths.size() - 1; i >= 0; i--) { + try { move(from, to, paths.get(i)); } + catch (IOException e) { if (failed == null) failed = e; else failed.addSuppressed(e); } + } + if (failed != null) throw failed; + } + + private static void move(File from, File to, String rel) throws IOException { + File src = safePath(from, rel); + File dst = safePath(to, rel); + File parent = src.getParentFile(); + if (parent != null) makeWritable(parent); + java.util.List made = new java.util.ArrayList<>(); + for (File d = dst.getParentFile(); d != null; d = d.getParentFile()) { + made.add(d); + if (d.equals(to)) break; + } + for (int i = made.size() - 1; i >= 0; i--) { + File d = made.get(i); + if (!d.isDirectory() && !d.mkdirs()) throw new IOException("Cannot create " + d); + makeWritable(d); + } + delete(dst, null); + if (!src.renameTo(dst)) throw new IOException("Cannot carry " + rel + " across the update"); + } + + static void recoverCarriedPaths(File staging, File target, java.util.List paths) throws IOException { + for (String rel : paths) { + File src = safePath(staging, rel); + if (!exists(src.toPath())) continue; + File dst = safePath(target, rel); + if (!exists(dst.toPath())) move(staging, target, rel); + } + } + + static boolean hasPaths(File tree, java.util.List paths) throws IOException { + for (String rel : paths) if (exists(safePath(tree, rel).toPath())) return true; + return false; + } + + private static File safePath(File tree, String rel) throws IOException { + Path relative = java.nio.file.Paths.get(rel); + if (relative.isAbsolute() || relative.getNameCount() == 0) throw new IOException("Invalid runtime path: " + rel); + for (Path part : relative) if (part.toString().equals("..")) throw new IOException("Invalid runtime path: " + rel); + Path current = tree.toPath(); + for (int i = 0; i < relative.getNameCount() - 1; i++) { + current = current.resolve(relative.getName(i)); + try { + BasicFileAttributes attrs = Files.readAttributes(current, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (!attrs.isDirectory()) throw new IOException("Runtime path has a non-directory parent: " + current); + } catch (NoSuchFileException ignored) { + break; + } + } + return tree.toPath().resolve(relative).toFile(); + } + static void carryHome(File old, File staging) throws IOException { File home = new File(old, "root"); if (!home.isDirectory()) return; diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeInstallRollbackTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeInstallRollbackTest.kt new file mode 100644 index 000000000..45b8019bb --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeInstallRollbackTest.kt @@ -0,0 +1,140 @@ +package com.droiddeck.launcher.runtime + +import android.app.Application +import android.content.Context +import android.content.ContextWrapper +import com.droiddeck.launcher.BuildConfig +import com.droiddeck.launcher.core.AddonPack +import java.io.ByteArrayOutputStream +import java.io.File +import java.net.ServerSocket +import java.security.MessageDigest +import java.util.concurrent.atomic.AtomicBoolean +import java.util.function.Predicate +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream +import org.apache.commons.compress.compressors.zstandard.ZstdCompressorOutputStream +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class RuntimeInstallRollbackTest { + @get:Rule val tmp = TemporaryFolder() + @After fun resetFaults() { RuntimeFileTree.refuseForTest = null } + + private fun put(root: File, rel: String, data: String) = File(root, rel).apply { + checkNotNull(parentFile).mkdirs() + writeText(data) + } + + private data class Attempt(val root: File, val staging: File, val old: File, val result: Boolean) + + private fun attempt(failRollback: Boolean): Attempt { + val files = tmp.newFolder("files") + val cache = tmp.newFolder("cache") + val context = object : ContextWrapper(RuntimeEnvironment.getApplication()) { + override fun getFilesDir(): File = files + override fun getCacheDir(): File = cache + override fun getApplicationContext(): Context = this + } + for ((pack, pin, required) in listOf( + Triple(AddonPack.PROOT, BuildConfig.PACK_PROOT_SHA256, listOf("libproot.so", "libproot-loader.so")), + Triple(AddonPack.DIRECTAUDIO, BuildConfig.PACK_DIRECTAUDIO_SHA256, + listOf("linux-wine11/version.txt", "linux-wine11-systhread/version.txt")), + )) { + val version = pin.ifEmpty { "0".repeat(64) } + val directory = File(files, "addons/${pack.name}/$version") + required.forEach { put(directory, it, "test pack") } + put(directory, AddonPack.COMPLETE, version) + } + val root = LinuxRuntime.rootDir(context) + val staging = File(files, LinuxRuntime.DIR + ".new") + val old = File(files, LinuxRuntime.DIR + ".old") + put(root, "root/save.dat", "original user data") + put(root, "opt/appimages/user/game/app.AppImage", "original app") + put(root, "usr/bin/usable", "previous runtime") + + val payload = ByteArrayOutputStream().also { bytes -> + ZstdCompressorOutputStream(bytes).use { zstd -> + TarArchiveOutputStream(zstd).use { tar -> + for ((name, value) in listOf("root/.bashrc" to "shipped home", "usr/bin/new" to "new runtime", + "opt/appimages/user/block" to "shipped app")) { + val data = value.toByteArray() + tar.putArchiveEntry(TarArchiveEntry(name).apply { size = data.size.toLong() }) + tar.write(data) + tar.closeArchiveEntry() + } + } + } + }.toByteArray() + val digest = MessageDigest.getInstance("SHA-256").digest(payload).joinToString("") { "%02x".format(it) } + val faultSeen = AtomicBoolean() + RuntimeFileTree.refuseForTest = Predicate { path -> + if (path == File(staging, "opt/appimages/user/block").toPath() && faultSeen.compareAndSet(false, true)) { + if (failRollback) put(old, "root/rollback-block", "concurrent data") + true + } else failRollback && path == File(old, "root/rollback-block").toPath() + } + val server = ServerSocket(0) + val thread = Thread { + runCatching { + server.accept().use { socket -> + socket.soTimeout = 5000 + val input = socket.getInputStream().bufferedReader(Charsets.ISO_8859_1) + while (true) { + val line = input.readLine() ?: break + if (line.isEmpty()) break + } + val out = socket.getOutputStream() + out.write(("HTTP/1.1 200 OK\r\nContent-Length: ${payload.size}\r\nConnection: close\r\n\r\n").toByteArray()) + out.write(payload) + out.flush() + } + } + }.apply { isDaemon = true; start() } + try { + val release = LinuxRuntimeInstaller.Release("synthetic", "http://127.0.0.1:${server.localPort}/runtime.tar.zst", digest, payload.size.toLong()) + val result = LinuxRuntimeInstaller.install(context, release, null) + assertTrue("the intended carry fault was not reached", faultSeen.get()) + return Attempt(root, staging, old, result) + } finally { + server.close() + thread.join(1000) + RuntimeFileTree.refuseForTest = null + } + } + + @Test fun aFailedCarryAndFailedRollbackKeepTheOnlyStagedCopy() { + val attempt = attempt(failRollback = true) + assertFalse(attempt.result) + assertTrue("the only staged copy was deleted", File(attempt.staging, "root/save.dat").isFile) + assertEquals("original user data", File(attempt.staging, "root/save.dat").readText()) + assertEquals("original app", File(attempt.root, "opt/appimages/user/game/app.AppImage").readText()) + assertEquals("previous runtime", File(attempt.root, "usr/bin/usable").readText()) + repeat(2) { + assertThrows(java.io.IOException::class.java) { + LinuxRuntimeInstaller.recoverInterruptedSwap(attempt.root, attempt.staging, attempt.old) + } + assertEquals("original user data", File(attempt.staging, "root/save.dat").readText()) + } + } + + @Test fun aCompleteRollbackStillRestoresAndCleansTheFailedCandidate() { + val attempt = attempt(failRollback = false) + assertFalse(attempt.result) + assertEquals("original user data", File(attempt.root, "root/save.dat").readText()) + assertEquals("original app", File(attempt.root, "opt/appimages/user/game/app.AppImage").readText()) + assertFalse(attempt.staging.exists()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalTest.kt index 318e6b980..83d43250f 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalTest.kt @@ -69,6 +69,7 @@ class RuntimeRemovalTest { val old = File(dir, "linuxfs.old").apply { File(this, "root").mkdirs() } val staging = File(dir, "linuxfs.new").apply { File(this, "root").mkdirs() } File(staging, "root/save").writeText("user data") + File(staging, LinuxRuntimeInstaller.CARRY_MARKER).writeText("1") for (tree in listOf(old, staging)) Files.setPosixFilePermissions(tree.toPath(), PosixFilePermissions.fromString("r-xr-xr-x")) LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old) diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeUserDataCarryTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeUserDataCarryTest.kt new file mode 100644 index 000000000..d977ffb8c --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeUserDataCarryTest.kt @@ -0,0 +1,243 @@ +package com.droiddeck.launcher.runtime + +import java.io.File +import java.io.IOException +import java.nio.file.Files +import org.junit.After +import org.junit.Assume.assumeTrue +import org.junit.Assert.assertThrows +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class RuntimeUserDataCarryTest { + @get:Rule val tmp = TemporaryFolder() + + @After fun resetFaults() { RuntimeFileTree.refuseForTest = null } + + private fun put(root: File, rel: String, text: String = rel) = File(root, rel).apply { parentFile.mkdirs(); writeText(text) } + + private fun userTree(): File = tmp.newFolder("old").also { old -> + put(old, "root/save.dat", "mine") + put(old, "opt/appimages/user/app1/app.AppImage") + put(old, "opt/droiddeck-apps/game/run.sh") + put(old, "usr/local/share/applications/droiddeck-app-game.desktop") + put(old, "usr/local/share/applications/droiddeck-appimage-app1.desktop") + put(old, "usr/local/share/applications/other.desktop") + put(old, "opt/appimages/dolphin.AppImage") + put(old, "usr/share/applications/droiddeck-dolphin.desktop") + put(old, ".droiddeck-pkg-dolphin", "1.0") + put(old, "usr/bin/tool", "old system file") + } + + @Test fun anUpdateKeepsImportedAppsCatalogAppImagesAndHome() { + val old = userTree() + val staging = tmp.newFolder("new").also { put(it, "root/.bashrc", "shipped"); put(it, "usr/bin/tool", "new system file") } + + val moved = RuntimeFileTree.carryUserData(old, staging, false) + + assertEquals("mine", File(staging, "root/save.dat").readText()) + assertFalse("the shipped home is replaced, not merged", File(staging, "root/.bashrc").exists()) + for (rel in listOf("opt/appimages/user/app1/app.AppImage", "opt/droiddeck-apps/game/run.sh", + "usr/local/share/applications/droiddeck-app-game.desktop", "usr/local/share/applications/droiddeck-appimage-app1.desktop", + "opt/appimages/dolphin.AppImage", "usr/share/applications/droiddeck-dolphin.desktop", ".droiddeck-pkg-dolphin")) { + assertTrue(rel, File(staging, rel).exists()) + assertTrue(rel, rel in moved || moved.any { rel.startsWith("$it/") }) + } + assertEquals("system files come from the new runtime", "new system file", File(staging, "usr/bin/tool").readText()) + assertFalse("only DroidDeck's own menu entries are user data", File(staging, "usr/local/share/applications/other.desktop").exists()) + } + + @Test fun aFailureHalfwayPutsEverythingBack() { + val old = userTree() + val staging = tmp.newFolder("new") + put(staging, "usr/local", "a file where a folder is needed") + + try { + RuntimeFileTree.carryUserData(old, staging, false) + fail("expected the carry to fail") + } catch (expected: IOException) { + } + + assertEquals("mine", File(old, "root/save.dat").readText()) + assertTrue(File(old, "opt/appimages/user/app1/app.AppImage").exists()) + assertTrue(File(old, "opt/droiddeck-apps/game/run.sh").exists()) + assertFalse(File(staging, "root").exists()) + } + + @Test fun recoveryOnlyFillsWhatIsMissing() { + val target = tmp.newFolder("target").also { put(it, "opt/droiddeck-apps/game/run.sh", "kept") } + val staging = tmp.newFolder("staging").also { + put(it, "opt/droiddeck-apps/game/run.sh", "stale") + put(it, "opt/appimages/user/app1/app.AppImage") + } + + val moved = RuntimeFileTree.carryUserData(staging, target, true) + + assertEquals(listOf("opt/appimages/user"), moved) + assertEquals("kept", File(target, "opt/droiddeck-apps/game/run.sh").readText()) + assertTrue(File(target, "opt/appimages/user/app1/app.AppImage").exists()) + } + @Test fun recoveryFindsCompanionsWhoseAppImageWasAlreadyPutBack() { + val root = tmp.newFolder("runtime") + val staging = tmp.newFolder("runtime.new") + val old = File(tmp.root, "runtime.old") + put(root, "opt/appimages/dolphin.AppImage", "user app") + put(staging, LinuxRuntimeInstaller.CARRY_MARKER, "1") + put(staging, "usr/share/applications/droiddeck-dolphin.desktop", "menu") + put(staging, ".droiddeck-pkg-dolphin", "version") + put(staging, ".droiddeck-pkg-desktop-kde", "system package marker") + + LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old) + assertEquals("menu", File(root, "usr/share/applications/droiddeck-dolphin.desktop").readText()) + assertEquals("version", File(root, ".droiddeck-pkg-dolphin").readText()) + assertFalse(File(root, ".droiddeck-pkg-desktop-kde").exists()) + assertEquals("user app", File(root, "opt/appimages/dolphin.AppImage").readText()) + } + + @Test fun unmarkedExtractedStagingDoesNotBlockRecovery() { + val root = tmp.newFolder("runtime") + val staging = tmp.newFolder("runtime.new") + val old = File(tmp.root, "runtime.old") + put(root, "root/save.dat", "current user data") + put(staging, "root/.bashrc", "shipped home from interrupted extract") + + LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old) + assertEquals("current user data", File(root, "root/save.dat").readText()) + assertEquals("shipped home from interrupted extract", File(staging, "root/.bashrc").readText()) + } + + @Test fun anUnreadableAppImageDirectoryAbortsBeforeMovingHome() { + assumeTrue(Files.getFileStore(tmp.root.toPath()).supportsFileAttributeView("posix")) + val old = userTree() + val images = File(old, "opt/appimages") + val staging = tmp.newFolder("staging") + assertTrue(images.setReadable(false, false)) + assertTrue(images.setExecutable(false, false)) + try { + assertThrows(IOException::class.java) { RuntimeFileTree.carryUserData(old, staging, false) } + assertEquals("mine", File(old, "root/save.dat").readText()) + assertFalse(File(staging, "root/save.dat").exists()) + } finally { + images.setReadable(true, true) + images.setExecutable(true, true) + } + } + + @Test fun recoveryRestoresTheRuntimeBeforeTryingAFallibleHomeMove() { + val root = File(tmp.root, "runtime") + val old = tmp.newFolder("runtime.old") + val staging = tmp.newFolder("runtime.new") + put(old, "usr/bin/usable", "previous runtime") + File(old, "root").mkdirs() + put(staging, LinuxRuntimeInstaller.CARRY_MARKER, "1") + put(staging, "root/save.dat", "staged user data") + RuntimeFileTree.refuseForTest = java.util.function.Predicate { it.fileName.toString() == "root" } + + assertThrows(IOException::class.java) { LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old) } + assertTrue("the previous runtime was not restored", root.isDirectory) + assertEquals("previous runtime", File(root, "usr/bin/usable").readText()) + assertEquals("staged user data", File(staging, "root/save.dat").readText()) + } + + @Test fun conflictingRecoveryDataIsRetainedOnEveryRetry() { + val root = tmp.newFolder("runtime") + val staging = tmp.newFolder("runtime.new") + val old = File(tmp.root, "runtime.old") + put(root, "root/current.dat", "current user data") + put(staging, LinuxRuntimeInstaller.CARRY_MARKER, "1") + put(staging, "root/older.dat", "unrecovered user data") + + repeat(2) { + assertThrows(IOException::class.java) { LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old) } + assertEquals("current user data", File(root, "root/current.dat").readText()) + assertEquals("unrecovered user data", File(staging, "root/older.dat").readText()) + } + } + + @Test fun stagedDataWithoutASurvivingRuntimeIsKeptForRecovery() { + val root = File(tmp.root, "runtime") + val old = File(tmp.root, "runtime.old") + val staging = tmp.newFolder("runtime.new") + put(staging, LinuxRuntimeInstaller.CARRY_MARKER, "1") + put(staging, "root/save.dat", "only surviving user data") + assertThrows(IOException::class.java) { LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old) } + assertEquals("only surviving user data", File(staging, "root/save.dat").readText()) + } + + @Test fun conflictingIntendedHomesAreRetainedWithoutProofOfTheirContents() { + val root = File(tmp.root, "runtime") + val old = tmp.newFolder("runtime.old") + val staging = tmp.newFolder("runtime.new") + put(old, "root/save.dat", "user data") + put(staging, LinuxRuntimeInstaller.CARRY_MARKER, "root") + put(staging, "root/.bashrc", "shipped default") + + assertThrows(IOException::class.java) { LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old) } + + assertEquals("user data", File(root, "root/save.dat").readText()) + assertEquals("shipped default", File(staging, "root/.bashrc").readText()) + assertTrue(File(staging, LinuxRuntimeInstaller.CARRY_MARKER).exists()) + } + + @Test fun anUnreadableCarryMarkerStopsRecovery() { + val root = tmp.newFolder("runtime") + val staging = tmp.newFolder("runtime.new") + val old = File(tmp.root, "runtime.old") + val marker = File(staging, LinuxRuntimeInstaller.CARRY_MARKER).apply { mkdirs() } + + assertThrows(IOException::class.java) { LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old) } + assertTrue(marker.isDirectory) + } + + @Test fun recoveryMovesOnlyAnIntendedPathThatWasAlreadyCarried() { + val root = File(tmp.root, "runtime") + val old = tmp.newFolder("runtime.old") + val staging = tmp.newFolder("runtime.new") + put(staging, LinuxRuntimeInstaller.CARRY_MARKER, "root") + put(staging, "root/save.dat", "moved user data") + + LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old) + + assertEquals("moved user data", File(root, "root/save.dat").readText()) + assertFalse(File(staging, LinuxRuntimeInstaller.CARRY_MARKER).exists()) + } + + @Test fun aSymlinkedParentCannotRedirectAUserDataMoveOutsideTheRuntime() { + val old = tmp.newFolder("old") + val staging = tmp.newFolder("staging") + val outside = tmp.newFolder("outside") + put(outside, "droiddeck-apps/game/run.sh", "outside data") + Files.createSymbolicLink(File(old, "opt").toPath(), outside.toPath()) + + assertThrows(IOException::class.java) { RuntimeFileTree.carryUserData(old, staging, false) } + + assertEquals("outside data", File(outside, "droiddeck-apps/game/run.sh").readText()) + assertFalse(File(staging, "opt/droiddeck-apps").exists()) + } + + @Test fun anInaccessibleLiveHomeIsNeverTreatedAsMissing() { + assumeTrue(Files.getFileStore(tmp.root.toPath()).supportsFileAttributeView("posix")) + val root = tmp.newFolder("runtime") + val staging = tmp.newFolder("runtime.new") + val old = File(tmp.root, "runtime.old") + put(root, "root/keep.dat", "current user data") + put(staging, LinuxRuntimeInstaller.CARRY_MARKER, "1") + put(staging, "root/staged.dat", "staged user data") + assertTrue(root.setExecutable(false, false)) + val failure = try { + runCatching { LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old) }.exceptionOrNull() + } finally { + root.setExecutable(true, true) + } + + assertTrue("the existing live home was deleted", File(root, "root/keep.dat").isFile) + assertEquals("current user data", File(root, "root/keep.dat").readText()) + assertEquals("staged user data", File(staging, "root/staged.dat").readText()) + assertTrue("inaccessible data must abort recovery", failure is IOException) + } +}