From 0de672f94ecf25d767166300a055c85cd4f42905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Resul=20=C3=96v=C3=BCr?= <92828257+ovurrsl@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:57:04 +0300 Subject: [PATCH] fix: a Linux runtime update keeps imported apps and catalog AppImages (#1062) * fix: a Linux runtime update keeps imported apps and catalog AppImages The update carried only /root into the new tree and then discarded the old one, so every imported AppImage, script app, Flatpak override and catalog emulator AppImage was deleted. User-owned paths are now moved across as a list, moved back if the carry or swap fails partway, and recovered after an interrupted update. * fix(runtime): retain user data when carry and recovery fail * fix(runtime): mark staging carry start so interrupted extraction is discardable * test: mark interrupted runtime data for recovery * fix: make runtime data carry recoverable * fix: the runtime update with carried user data builds again carryUserData passed the de-duplicated Set of user paths where a List is expected after the two carry changes met; copy it into a List. * fix: a runtime update stops instead of dropping app markers it cannot inspect, and keeps app names with line breaks userPaths now uses the throwing exists() helper for the AppImage, menu entry and marker checks. The carry marker stores each path URL-encoded on its own line, so a name containing a newline cannot split into two paths. The symlink test now links a parent directory, which is what safePath rejects. * fix: retain conflicting runtime recovery data on every retry Stop on unreadable carry markers and keep rollback tests independent of add-on downloads. --------- Co-authored-by: ovurrsl Co-authored-by: xXJsonDeruloXx Co-authored-by: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> --- .../runtime/LinuxRuntimeInstaller.java | 76 +++++- .../launcher/runtime/RuntimeFileTree.java | 157 +++++++++++ .../runtime/RuntimeInstallRollbackTest.kt | 140 ++++++++++ .../launcher/runtime/RuntimeRemovalTest.kt | 1 + .../runtime/RuntimeUserDataCarryTest.kt | 243 ++++++++++++++++++ 5 files changed, 603 insertions(+), 14 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/runtime/RuntimeInstallRollbackTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/runtime/RuntimeUserDataCarryTest.kt 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) + } +}