From 718712eb0132fe0b7a1390eaa12187c672b84233 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:57:05 -0500 Subject: [PATCH 01/11] fix: secure and refresh unrooted log exports --- .../launcher/agent/AgentBridgeProvider.kt | 9 +++- .../session/EnhancedSessionLogShare.kt | 5 ++- .../session/SessionLogShareZipTest.kt | 7 +++- docs/agent-control.md | 2 + tools/droiddeckctl | 41 +++++++++++++++++-- tools/tests/test_droiddeckctl.py | 25 +++++++++-- 6 files changed, 78 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt index 5570bd981..a7a930680 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt @@ -25,6 +25,7 @@ import com.droiddeck.launcher.session.SessionState import org.json.JSONArray import org.json.JSONObject import java.io.File +import java.util.UUID import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -206,7 +207,13 @@ class AgentBridgeProvider : ContentProvider() { private fun logs(context: Context): JSONObject { val dir = logDir(context)?.takeIf { it.isDirectory } ?: return error("NO_SESSION_LOGS", "No session artifact directory is available") - val zip = SessionLogShare.zipFolder(context, dir, File(context.getExternalFilesDir(null), "agent-logs")) + val output = File(context.getExternalFilesDir(null), "agent-logs/${UUID.randomUUID()}") + val zip = try { + SessionLogShare.zipFolder(context, dir, output) + } catch (error: Exception) { + output.deleteRecursively() + throw error + } ?: return error("NO_SESSION_LOGS", "The session artifact directory is empty") return JSONObject().put("ok", true).put("command", "logs").put("sessionId", dir.name).put("zip", zip.absolutePath) } diff --git a/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt b/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt index 899faf4ca..d20da13d6 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt @@ -27,6 +27,7 @@ import java.nio.file.SimpleFileVisitor import java.nio.file.attribute.BasicFileAttributes import java.security.MessageDigest import java.util.Locale +import java.util.UUID import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -107,7 +108,7 @@ internal object EnhancedSessionLogShare { return emptyList() } val out = SessionLogShare.shareDir(context).apply { deleteRecursively(); mkdirs() } - val stage = File(out, ".diagnostic-stage").apply { mkdirs() } + val stage = File(context.cacheDir, "${out.name}-${UUID.randomUUID()}.diagnostic-stage").apply { mkdirs() } val redactionLearningComplete = LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context)) val discovery = collect(context, folder, limits.maxDiscoveredFiles, legacy = false) val candidates = discovery.candidates @@ -173,7 +174,7 @@ internal object EnhancedSessionLogShare { out.deleteRecursively() return null } - val stage = File(out, ".diagnostic-stage").apply { mkdirs() } + val stage = File(context.cacheDir, "${out.name}-${UUID.randomUUID()}.diagnostic-stage").apply { mkdirs() } val records = ArrayList() val budget = Budget(limits.maxBytesTotal) val zip = File(out, "DroidDeck-${folder.name}.zip") diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt index cd4a711d1..0779a378c 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt @@ -29,15 +29,20 @@ class SessionLogShareZipTest { @Test fun anArchiveCanBeWrittenOutsideTheShareFolder() { SessionLogShare.clear(app) val session = File(LinuxRuntime.logDir(app), "2026-11-03-01-steam").apply { mkdirs() } - val out = File(app.getExternalFilesDir(null), "agent-logs") + val out = File(app.getExternalFilesDir(null), "agent-logs/request-1") + val secondOut = File(app.getExternalFilesDir(null), "agent-logs/request-2") try { File(session, "session.log").writeText("booted\n") val zip = SessionLogShare.zipFolder(app, session, out)!! + val secondZip = SessionLogShare.zipFolder(app, session, secondOut)!! assertEquals(out, zip.parentFile) assertFalse(File(app.cacheDir, "share").exists()) assertTrue(zip.isFile) + assertTrue(secondZip.isFile) + assertTrue(zip.isFile) } finally { out.deleteRecursively() + secondOut.deleteRecursively() session.deleteRecursively() } } diff --git a/docs/agent-control.md b/docs/agent-control.md index dbbdbb9b0..eba6ca773 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -78,6 +78,8 @@ text. This does not redact arbitrary authorized command results, CDP data, prefe captures. Scenario directories are owner-only and JSON reports are written with mode 0600. Review artifacts before sharing. `logs` downloads the archive Share logs builds (scrubbed, Steam logs over 8 MiB left out) from the app's external files folder, since adb cannot read the session folder itself. +When the installed app predates archive export, the CLI falls back to pulling the private session folder; +that legacy path is available only with root and is not scrubbed before download. ## Device selection and output diff --git a/tools/droiddeckctl b/tools/droiddeckctl index 559a437a8..14676771f 100755 --- a/tools/droiddeckctl +++ b/tools/droiddeckctl @@ -424,8 +424,20 @@ def start_session(adb: str, serial: str, request: dict) -> dict: def unpack_logs(archive: Path, destination: Path, session_id: str) -> None: """Unpack a Share logs zip so the session's own files sit at the top of destination.""" + destination = destination.resolve() + destination.mkdir(parents=True, exist_ok=True) + manifest = destination / ".droiddeckctl-managed-logs.json" try: - with zipfile.ZipFile(archive) as z: + previous = json.loads(manifest.read_text(encoding="utf-8")) + if not isinstance(previous, list) or any(not isinstance(name, str) for name in previous): + previous = [] + except (OSError, ValueError): + previous = [] + previous = set(previous) + extracted = set() + try: + with tempfile.TemporaryDirectory(prefix="droiddeckctl-logs-") as temp, zipfile.ZipFile(archive) as z: + staged = Path(temp) for entry in z.infolist(): name = entry.filename if name.startswith(session_id + "/"): @@ -433,9 +445,29 @@ def unpack_logs(archive: Path, destination: Path, session_id: str) -> None: target = (destination / name).resolve() if entry.is_dir() or not name or not target.is_relative_to(destination): continue - target.parent.mkdir(parents=True, exist_ok=True) - with z.open(entry) as source, target.open("wb") as copy: + relative = target.relative_to(destination).as_posix() + if relative == manifest.name: + continue + staged_file = staged / relative + staged_file.parent.mkdir(parents=True, exist_ok=True) + with z.open(entry) as source, staged_file.open("wb") as copy: shutil.copyfileobj(source, copy) + extracted.add(relative) + for name in extracted - previous: + target = destination / name + if target.exists() or target.is_symlink(): + raise ControlError(f"Refusing to overwrite an unrelated file: {target}", "LOG_PULL_FAILED") + for name in previous - extracted: + target = (destination / name).resolve() + if target.is_relative_to(destination) and target.is_file() and not target.is_symlink(): + target.unlink() + for name in extracted: + target = destination / name + target.parent.mkdir(parents=True, exist_ok=True) + os.replace(staged / name, target) + temp_manifest = manifest.with_name(manifest.name + ".tmp") + temp_manifest.write_text(json.dumps(sorted(extracted), indent=2) + "\n", encoding="utf-8") + os.replace(temp_manifest, manifest) except (OSError, zipfile.BadZipFile) as exc: raise ControlError(f"Could not unpack the session logs: {exc}", "LOG_PULL_FAILED") from exc @@ -766,6 +798,7 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict: result = run_adb(adb, serial, "pull", remote_dir, str(destination), timeout=120) else: zip_path = checked(exported, "LOG_PULL_FAILED")["zip"] + cleanup_dir = str(Path(zip_path).parent) try: if exported.get("sessionId", session["id"]) != session["id"]: raise ControlError("A new session started while the logs were exported; run logs again", @@ -777,7 +810,7 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict: unpack_logs(local_zip, destination, session["id"]) finally: # The copy sits in shared storage, so it does not outlive the pull. - run_adb(adb, serial, "shell", "rm", "-f", zip_path, timeout=30) + run_adb(adb, serial, "shell", "rm", "-rf", cleanup_dir, timeout=30) if result.returncode != 0: detail = (result.stderr or result.stdout).decode(errors="replace").strip() raise ControlError(detail or "adb pull failed", "LOG_PULL_FAILED") diff --git a/tools/tests/test_droiddeckctl.py b/tools/tests/test_droiddeckctl.py index 565e918c8..0b521b5a0 100644 --- a/tools/tests/test_droiddeckctl.py +++ b/tools/tests/test_droiddeckctl.py @@ -138,7 +138,7 @@ CPUFREQ = textwrap.dedent('''\ def battery(tenths): return f"Current Battery Service state:\n level: 80\n temperature: {tenths}\n technology: Li-poly" PRIVATE_LOG_DIR = "/data/user/0/com.droiddeck.launcher/files/logs/sessions/s1" -EXPORTED_ZIP = "/storage/emulated/0/Android/data/com.droiddeck.launcher/files/agent-logs/DroidDeck-s1.zip" +EXPORTED_ZIP = "/storage/emulated/0/Android/data/com.droiddeck.launcher/files/agent-logs/request-1/DroidDeck-s1.zip" def share_zip(entries): @@ -542,7 +542,7 @@ class DroiddeckctlTest(FakeAdbTest): self.assertEqual("e", (destination / "stores" / "epic.log").read_text()) self.assertFalse((self.dir / "outside").exists()) self.assertEqual([["pull", EXPORTED_ZIP]], [c["args"][:2] for c in self.calls() if c["args"][:1] == ["pull"]]) - self.assertIn(["shell", "rm", "-f", EXPORTED_ZIP], [c["args"] for c in self.calls()]) + self.assertIn(["shell", "rm", "-rf", str(Path(EXPORTED_ZIP).parent)], [c["args"] for c in self.calls()]) def test_logs_of_a_session_that_started_meanwhile_are_refused(self): self.configure({"state": ended_state(), "logs": {"ok": True, "sessionId": "s2", "zip": EXPORTED_ZIP}}, @@ -551,7 +551,26 @@ class DroiddeckctlTest(FakeAdbTest): self.assertEqual(6, code) self.assertEqual("LOG_PULL_FAILED", out["error"]["code"]) self.assertFalse((self.dir / "artifacts").exists()) - self.assertIn(["shell", "rm", "-f", EXPORTED_ZIP], [c["args"] for c in self.calls()]) + self.assertIn(["shell", "rm", "-rf", str(Path(EXPORTED_ZIP).parent)], [c["args"] for c in self.calls()]) + + def test_logs_replace_the_previous_snapshot_and_preserve_unrelated_files(self): + destination = self.dir / "artifacts" + destination.mkdir() + (destination / "notes.txt").write_text("keep me") + first = self.dir / "first.zip" + with zipfile.ZipFile(first, "w") as archive: + archive.writestr("s1/session.log", "first") + archive.writestr("s1/steam/console.log", "old") + ctl.unpack_logs(first, destination, "s1") + self.assertEqual("old", (destination / "steam/console.log").read_text()) + + second = self.dir / "second.zip" + with zipfile.ZipFile(second, "w") as archive: + archive.writestr("s1/session.log", "second") + ctl.unpack_logs(second, destination, "s1") + self.assertEqual("second", (destination / "session.log").read_text()) + self.assertFalse((destination / "steam/console.log").exists()) + self.assertEqual("keep me", (destination / "notes.txt").read_text()) def test_logs_from_an_older_build_fall_back_to_the_private_folder(self): self.configure({"state": ended_state()}) From 4765644451ebee3ab2fd489724b73f546c9d7cf4 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:09:06 -0500 Subject: [PATCH 02/11] fix: harden log export transactions --- .../launcher/agent/AgentBridgeProvider.kt | 3 +- tools/droiddeckctl | 107 ++++++++++++++---- tools/tests/test_droiddeckctl.py | 95 +++++++++++++++- 3 files changed, 176 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt index a7a930680..7d1602963 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt @@ -215,7 +215,8 @@ class AgentBridgeProvider : ContentProvider() { throw error } ?: return error("NO_SESSION_LOGS", "The session artifact directory is empty") - return JSONObject().put("ok", true).put("command", "logs").put("sessionId", dir.name).put("zip", zip.absolutePath) + return JSONObject().put("ok", true).put("command", "logs").put("sessionId", dir.name) + .put("zip", zip.absolutePath).put("cleanupDir", output.absolutePath) } private fun state(context: Context): JSONObject { diff --git a/tools/droiddeckctl b/tools/droiddeckctl index 14676771f..270cb7b80 100755 --- a/tools/droiddeckctl +++ b/tools/droiddeckctl @@ -18,7 +18,7 @@ import time import uuid import zipfile import zlib -from pathlib import Path +from pathlib import Path, PurePosixPath sys.path.insert(0, str(Path(__file__).resolve().parent)) from adb_device import DeviceResolutionError, resolve_serial @@ -434,40 +434,84 @@ def unpack_logs(archive: Path, destination: Path, session_id: str) -> None: except (OSError, ValueError): previous = [] previous = set(previous) + previous.discard(manifest.name) extracted = set() + + def managed_target(name: str) -> Path | None: + relative = Path(name) + if relative.is_absolute() or not relative.parts or any(part in {".", ".."} for part in relative.parts): + return None + target = destination + for part in relative.parts: + target = target / part + if target.is_symlink(): + return None + return target if target.resolve().is_relative_to(destination) else None + try: - with tempfile.TemporaryDirectory(prefix="droiddeckctl-logs-") as temp, zipfile.ZipFile(archive) as z: + with tempfile.TemporaryDirectory(prefix=".droiddeckctl-logs-", dir=destination) as temp, zipfile.ZipFile(archive) as z: staged = Path(temp) + staged_files = staged / "new" + backups = staged / "old" + staged_files.mkdir() for entry in z.infolist(): name = entry.filename if name.startswith(session_id + "/"): name = name[len(session_id) + 1:] - target = (destination / name).resolve() - if entry.is_dir() or not name or not target.is_relative_to(destination): + target = managed_target(name) + if entry.is_dir() or target is None or target == manifest: continue relative = target.relative_to(destination).as_posix() - if relative == manifest.name: - continue - staged_file = staged / relative + staged_file = staged_files / relative staged_file.parent.mkdir(parents=True, exist_ok=True) with z.open(entry) as source, staged_file.open("wb") as copy: shutil.copyfileobj(source, copy) extracted.add(relative) for name in extracted - previous: - target = destination / name - if target.exists() or target.is_symlink(): + target = managed_target(name) + if target is None or target.exists(): raise ControlError(f"Refusing to overwrite an unrelated file: {target}", "LOG_PULL_FAILED") - for name in previous - extracted: - target = (destination / name).resolve() - if target.is_relative_to(destination) and target.is_file() and not target.is_symlink(): - target.unlink() - for name in extracted: - target = destination / name - target.parent.mkdir(parents=True, exist_ok=True) - os.replace(staged / name, target) - temp_manifest = manifest.with_name(manifest.name + ".tmp") - temp_manifest.write_text(json.dumps(sorted(extracted), indent=2) + "\n", encoding="utf-8") - os.replace(temp_manifest, manifest) + if manifest.is_symlink() or (manifest.exists() and not manifest.is_file()): + raise ControlError("The managed log list is not a regular file", "LOG_PULL_FAILED") + backed_up = {} + installed = set() + manifest_backup = backups / manifest.name + try: + for name in sorted(previous): + target = managed_target(name) + if target is not None and target.is_file(): + backup = backups / name + backup.parent.mkdir(parents=True, exist_ok=True) + os.replace(target, backup) + backed_up[name] = backup + if manifest.is_file(): + manifest_backup.parent.mkdir(parents=True, exist_ok=True) + os.replace(manifest, manifest_backup) + for name in sorted(extracted): + target = managed_target(name) + if target is None: + raise ControlError(f"A log destination became unsafe: {name}", "LOG_PULL_FAILED") + target.parent.mkdir(parents=True, exist_ok=True) + os.replace(staged_files / name, target) + installed.add(name) + temp_manifest = staged / "managed-logs.json" + temp_manifest.write_text(json.dumps(sorted(extracted), indent=2) + "\n", encoding="utf-8") + os.replace(temp_manifest, manifest) + except Exception: + for name in installed: + target = managed_target(name) + if target is not None and target.is_file(): + target.unlink() + if manifest.exists() and not manifest.is_symlink(): + manifest.unlink() + for name, backup in backed_up.items(): + target = managed_target(name) + if target is not None: + target.parent.mkdir(parents=True, exist_ok=True) + os.replace(backup, target) + if manifest_backup.is_file(): + os.replace(manifest_backup, manifest) + raise except (OSError, zipfile.BadZipFile) as exc: raise ControlError(f"Could not unpack the session logs: {exc}", "LOG_PULL_FAILED") from exc @@ -798,7 +842,17 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict: result = run_adb(adb, serial, "pull", remote_dir, str(destination), timeout=120) else: zip_path = checked(exported, "LOG_PULL_FAILED")["zip"] - cleanup_dir = str(Path(zip_path).parent) + remote_zip = PurePosixPath(zip_path) + if (not remote_zip.is_absolute() or ".." in remote_zip.parts or + not re.fullmatch(r"/[A-Za-z0-9_./-]+", zip_path)): + raise ControlError("The exported logs path is invalid", "LOG_PULL_FAILED") + requested_dir = exported.get("cleanupDir") + request_dir = PurePosixPath(requested_dir) if isinstance(requested_dir, str) else None + cleanup = ["shell", "rm", "-f", zip_path] + if (request_dir and request_dir.is_absolute() and ".." not in request_dir.parts and + request_dir == remote_zip.parent and request_dir.parent.name == "agent-logs" and + re.fullmatch(r"[0-9a-fA-F-]{36}", request_dir.name)): + cleanup = ["shell", "rm", "-rf", str(request_dir)] try: if exported.get("sessionId", session["id"]) != session["id"]: raise ControlError("A new session started while the logs were exported; run logs again", @@ -810,7 +864,14 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict: unpack_logs(local_zip, destination, session["id"]) finally: # The copy sits in shared storage, so it does not outlive the pull. - run_adb(adb, serial, "shell", "rm", "-rf", cleanup_dir, timeout=30) + cleaned = run_adb(adb, serial, *cleanup, timeout=30) + if cleaned.returncode != 0: + detail = (cleaned.stderr or cleaned.stdout).decode(errors="replace").strip() + raise ControlError( + "Could not remove the exported log archive; it may remain in shared storage" + + (f": {detail}" if detail else ""), + "LOG_CLEANUP_FAILED", + ) if result.returncode != 0: detail = (result.stderr or result.stdout).decode(errors="replace").strip() raise ControlError(detail or "adb pull failed", "LOG_PULL_FAILED") @@ -1044,7 +1105,7 @@ EXIT_SESSION = { "NO_STEAM_SESSION", "SESSION_NOT_READY", "AGENT_UNAVAILABLE", "LAUNCH_REJECTED", "NO_PAD", "NO_FRAMES", } EXIT_TIMEOUT = {"TIMEOUT", "ARTIFACT_TIMEOUT"} -EXIT_FILE = {"NO_SESSION_LOGS", "LOG_PULL_FAILED", "SCREENSHOT_FAILED", "FILE_IO", "RECORD_FAILED", "PUSH_FAILED", +EXIT_FILE = {"NO_SESSION_LOGS", "LOG_PULL_FAILED", "LOG_CLEANUP_FAILED", "SCREENSHOT_FAILED", "FILE_IO", "RECORD_FAILED", "PUSH_FAILED", "PACING_FAILED", "XR_CAPTURE_FAILED"} EXIT_DENIED = {"AGENT_COMMANDS_DISABLED", "PROTECTED_PREFS", "PROTECTED_CONTROL"} diff --git a/tools/tests/test_droiddeckctl.py b/tools/tests/test_droiddeckctl.py index 0b521b5a0..4197f721b 100644 --- a/tools/tests/test_droiddeckctl.py +++ b/tools/tests/test_droiddeckctl.py @@ -76,6 +76,9 @@ FAKE_ADB = textwrap.dedent('''\ elif args[:1] == ["pull"]: entry["denied"] = True print(f"adb: error: failed to stat remote object '{args[1]}': Permission denied", file=sys.stderr) + elif args[:2] == ["shell", "rm"] and config.get("fail_cleanup"): + entry["denied"] = True + print("adb: error: cleanup denied", file=sys.stderr) with open(config["log"], "a") as log: log.write(json.dumps(entry) + "\\n") sys.exit(1 if entry.get("denied") else 0) @@ -138,7 +141,9 @@ CPUFREQ = textwrap.dedent('''\ def battery(tenths): return f"Current Battery Service state:\n level: 80\n temperature: {tenths}\n technology: Li-poly" PRIVATE_LOG_DIR = "/data/user/0/com.droiddeck.launcher/files/logs/sessions/s1" -EXPORTED_ZIP = "/storage/emulated/0/Android/data/com.droiddeck.launcher/files/agent-logs/request-1/DroidDeck-s1.zip" +AGENT_LOGS_DIR = "/storage/emulated/0/Android/data/com.droiddeck.launcher/files/agent-logs" +EXPORTED_DIR = AGENT_LOGS_DIR + "/123e4567-e89b-12d3-a456-426614174000" +EXPORTED_ZIP = EXPORTED_DIR + "/DroidDeck-s1.zip" def share_zip(entries): @@ -531,7 +536,8 @@ class DroiddeckctlTest(FakeAdbTest): code, out = self.run_ctl("wait", "cool", "--below", "38") self.assertEqual("THERMAL_FAILED", out["error"]["code"]) def test_logs_pull_the_exported_zip_instead_of_the_private_folder(self): - self.configure({"state": ended_state(), "logs": {"ok": True, "sessionId": "s1", "zip": EXPORTED_ZIP}}, + self.configure({"state": ended_state(), "logs": {"ok": True, "sessionId": "s1", "zip": EXPORTED_ZIP, + "cleanupDir": EXPORTED_DIR}}, files={EXPORTED_ZIP: share_zip({"s1/session.log": "booted", "s1/steam/console.log": "c", "stores/epic.log": "e", "../outside": "x"})}) destination = self.dir / "artifacts" @@ -542,16 +548,38 @@ class DroiddeckctlTest(FakeAdbTest): self.assertEqual("e", (destination / "stores" / "epic.log").read_text()) self.assertFalse((self.dir / "outside").exists()) self.assertEqual([["pull", EXPORTED_ZIP]], [c["args"][:2] for c in self.calls() if c["args"][:1] == ["pull"]]) - self.assertIn(["shell", "rm", "-rf", str(Path(EXPORTED_ZIP).parent)], [c["args"] for c in self.calls()]) + self.assertIn(["shell", "rm", "-rf", EXPORTED_DIR], [c["args"] for c in self.calls()]) def test_logs_of_a_session_that_started_meanwhile_are_refused(self): - self.configure({"state": ended_state(), "logs": {"ok": True, "sessionId": "s2", "zip": EXPORTED_ZIP}}, + self.configure({"state": ended_state(), "logs": {"ok": True, "sessionId": "s2", "zip": EXPORTED_ZIP, + "cleanupDir": EXPORTED_DIR}}, files={EXPORTED_ZIP: share_zip({"s2/session.log": "other"})}) code, out = self.run_ctl("logs", "latest", str(self.dir / "artifacts")) self.assertEqual(6, code) self.assertEqual("LOG_PULL_FAILED", out["error"]["code"]) self.assertFalse((self.dir / "artifacts").exists()) - self.assertIn(["shell", "rm", "-rf", str(Path(EXPORTED_ZIP).parent)], [c["args"] for c in self.calls()]) + self.assertIn(["shell", "rm", "-rf", EXPORTED_DIR], [c["args"] for c in self.calls()]) + + def test_logs_from_a_legacy_provider_remove_only_the_export_zip(self): + legacy_zip = AGENT_LOGS_DIR + "/DroidDeck-s1.zip" + self.configure({"state": ended_state(), + "logs": {"ok": True, "sessionId": "s1", "zip": legacy_zip}}, + files={legacy_zip: share_zip({"s1/session.log": "booted"})}) + code, out = self.run_ctl("logs", "latest", str(self.dir / "artifacts")) + self.assertEqual(0, code, out) + commands = [c["args"] for c in self.calls()] + self.assertIn(["shell", "rm", "-f", legacy_zip], commands) + self.assertNotIn(["shell", "rm", "-rf", AGENT_LOGS_DIR], commands) + + def test_logs_report_when_remote_export_cleanup_fails(self): + self.configure({"state": ended_state(), + "logs": {"ok": True, "sessionId": "s1", "zip": EXPORTED_ZIP, + "cleanupDir": EXPORTED_DIR}}, + fail_cleanup=True, files={EXPORTED_ZIP: share_zip({"s1/session.log": "booted"})}) + code, out = self.run_ctl("logs", "latest", str(self.dir / "artifacts")) + self.assertEqual(6, code, out) + self.assertEqual("LOG_CLEANUP_FAILED", out["error"]["code"]) + self.assertIn("may remain in shared storage", out["error"]["message"]) def test_logs_replace_the_previous_snapshot_and_preserve_unrelated_files(self): destination = self.dir / "artifacts" @@ -572,6 +600,63 @@ class DroiddeckctlTest(FakeAdbTest): self.assertFalse((destination / "steam/console.log").exists()) self.assertEqual("keep me", (destination / "notes.txt").read_text()) + def test_logs_do_not_follow_a_stale_managed_symlink(self): + if os.name == "nt": + self.skipTest("symlink creation is unavailable on this Windows host") + destination = self.dir / "artifacts" + destination.mkdir() + victim = destination / "notes.txt" + victim.write_text("keep me") + (destination / ".droiddeckctl-managed-logs.json").write_text(json.dumps(["old.log"])) + (destination / "old.log").symlink_to(victim) + archive = self.dir / "logs.zip" + with zipfile.ZipFile(archive, "w") as z: + z.writestr("s1/session.log", "current") + + ctl.unpack_logs(archive, destination, "s1") + + self.assertEqual("keep me", victim.read_text()) + self.assertTrue((destination / "old.log").is_symlink()) + self.assertEqual("current", (destination / "session.log").read_text()) + + def test_logs_roll_back_a_failed_manifest_commit_and_can_retry(self): + destination = self.dir / "artifacts" + destination.mkdir() + (destination / "notes.txt").write_text("keep me") + first = self.dir / "first.zip" + with zipfile.ZipFile(first, "w") as z: + z.writestr("s1/session.log", "first") + z.writestr("s1/steam/console.log", "old") + ctl.unpack_logs(first, destination, "s1") + old_manifest = (destination / ".droiddeckctl-managed-logs.json").read_bytes() + second = self.dir / "second.zip" + with zipfile.ZipFile(second, "w") as z: + z.writestr("s1/session.log", "second") + z.writestr("s1/new.log", "new") + + real_replace = ctl.os.replace + failed = False + def fail_manifest_once(source, target): + nonlocal failed + if Path(target) == destination / ".droiddeckctl-managed-logs.json" and not failed: + failed = True + raise OSError("injected manifest failure") + return real_replace(source, target) + + with unittest.mock.patch.object(ctl.os, "replace", side_effect=fail_manifest_once): + with self.assertRaises(ctl.ControlError): + ctl.unpack_logs(second, destination, "s1") + + self.assertEqual("first", (destination / "session.log").read_text()) + self.assertEqual("old", (destination / "steam/console.log").read_text()) + self.assertFalse((destination / "new.log").exists()) + self.assertEqual(old_manifest, (destination / ".droiddeckctl-managed-logs.json").read_bytes()) + ctl.unpack_logs(second, destination, "s1") + self.assertEqual("second", (destination / "session.log").read_text()) + self.assertEqual("new", (destination / "new.log").read_text()) + self.assertFalse((destination / "steam/console.log").exists()) + self.assertEqual("keep me", (destination / "notes.txt").read_text()) + def test_logs_from_an_older_build_fall_back_to_the_private_folder(self): self.configure({"state": ended_state()}) code, out = self.run_ctl("logs", "latest", str(self.dir / "artifacts")) From ff686d7c3bd38a3b73891f7a93e7f3ba586328e5 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:16:48 -0500 Subject: [PATCH 03/11] fix: expire stale log exports safely --- .../launcher/agent/AgentBridgeProvider.kt | 22 ++++++++++++++++++- tools/droiddeckctl | 13 +++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt index 7d1602963..f41b34250 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt @@ -25,6 +25,7 @@ import com.droiddeck.launcher.session.SessionState import org.json.JSONArray import org.json.JSONObject import java.io.File +import java.nio.file.Files import java.util.UUID import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -203,11 +204,29 @@ class AgentBridgeProvider : ContentProvider() { private fun logDir(context: Context): File? = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: SessionPaths.sessionFolders(context).lastOrNull() + private fun cleanupExpiredLogExports(exports: File) { + val now = System.currentTimeMillis() + val canonicalRoot = exports.canonicalFile + exports.listFiles()?.forEach { candidate -> + val isUuid = runCatching { UUID.fromString(candidate.name).toString() == candidate.name }.getOrDefault(false) + if (!isUuid || !candidate.isDirectory || Files.isSymbolicLink(candidate.toPath())) return@forEach + if (candidate.canonicalFile.parentFile != canonicalRoot) return@forEach + if (now - candidate.lastModified() >= LOG_EXPORT_RETENTION_MS) candidate.deleteRecursively() + } + } + // The session folder is app-private, so adb pulls this copy from external files instead. private fun logs(context: Context): JSONObject { val dir = logDir(context)?.takeIf { it.isDirectory } ?: return error("NO_SESSION_LOGS", "No session artifact directory is available") - val output = File(context.getExternalFilesDir(null), "agent-logs/${UUID.randomUUID()}") + val externalFiles = context.getExternalFilesDir(null) + ?: return error("NO_SESSION_LOGS", "External app storage is unavailable") + val exports = File(externalFiles, "agent-logs") + if (!exports.exists() && !exports.mkdirs()) { + return error("NO_SESSION_LOGS", "Could not create the session export directory") + } + cleanupExpiredLogExports(exports) + val output = File(exports, UUID.randomUUID().toString()) val zip = try { SessionLogShare.zipFolder(context, dir, output) } catch (error: Exception) { @@ -373,6 +392,7 @@ class AgentBridgeProvider : ContentProvider() { const val RESULT_JSON = "json" const val EXTRA_REQUEST = "request" const val SCHEMA = 2 + private const val LOG_EXPORT_RETENTION_MS = 24 * 60 * 60 * 1000L private val recoveryComplete = CountDownLatch(1) fun awaitRecovery() { diff --git a/tools/droiddeckctl b/tools/droiddeckctl index 270cb7b80..2279bd12f 100755 --- a/tools/droiddeckctl +++ b/tools/droiddeckctl @@ -476,6 +476,16 @@ def unpack_logs(archive: Path, destination: Path, session_id: str) -> None: backed_up = {} installed = set() manifest_backup = backups / manifest.name + manifest_fd, manifest_path = tempfile.mkstemp( + prefix=".droiddeckctl-managed-logs-", suffix=".tmp", dir=destination + ) + temp_manifest = Path(manifest_path) + try: + with os.fdopen(manifest_fd, "w", encoding="utf-8") as manifest_file: + manifest_file.write(json.dumps(sorted(extracted), indent=2) + "\n") + except Exception: + temp_manifest.unlink(missing_ok=True) + raise try: for name in sorted(previous): target = managed_target(name) @@ -494,10 +504,9 @@ def unpack_logs(archive: Path, destination: Path, session_id: str) -> None: target.parent.mkdir(parents=True, exist_ok=True) os.replace(staged_files / name, target) installed.add(name) - temp_manifest = staged / "managed-logs.json" - temp_manifest.write_text(json.dumps(sorted(extracted), indent=2) + "\n", encoding="utf-8") os.replace(temp_manifest, manifest) except Exception: + temp_manifest.unlink(missing_ok=True) for name in installed: target = managed_target(name) if target is not None and target.is_file(): From 11c0763fbae7c22b7da7e42b3007163a422fbccd Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:30:59 -0500 Subject: [PATCH 04/11] fix: preserve log cleanup failures --- .../launcher/agent/AgentBridgeProvider.kt | 2 +- tools/droiddeckctl | 43 +++++++++++++------ tools/tests/test_droiddeckctl.py | 33 +++++++++++++- 3 files changed, 63 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt index f41b34250..c1955caa0 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt @@ -222,7 +222,7 @@ class AgentBridgeProvider : ContentProvider() { val externalFiles = context.getExternalFilesDir(null) ?: return error("NO_SESSION_LOGS", "External app storage is unavailable") val exports = File(externalFiles, "agent-logs") - if (!exports.exists() && !exports.mkdirs()) { + if ((!exports.exists() && !exports.mkdirs()) || !exports.isDirectory) { return error("NO_SESSION_LOGS", "Could not create the session export directory") } cleanupExpiredLogExports(exports) diff --git a/tools/droiddeckctl b/tools/droiddeckctl index 2279bd12f..02fe7020c 100755 --- a/tools/droiddeckctl +++ b/tools/droiddeckctl @@ -470,7 +470,7 @@ def unpack_logs(archive: Path, destination: Path, session_id: str) -> None: for name in extracted - previous: target = managed_target(name) if target is None or target.exists(): - raise ControlError(f"Refusing to overwrite an unrelated file: {target}", "LOG_PULL_FAILED") + raise ControlError(f"Refusing to overwrite an unrelated file: {name}", "LOG_PULL_FAILED") if manifest.is_symlink() or (manifest.exists() and not manifest.is_file()): raise ControlError("The managed log list is not a regular file", "LOG_PULL_FAILED") backed_up = {} @@ -511,7 +511,7 @@ def unpack_logs(archive: Path, destination: Path, session_id: str) -> None: target = managed_target(name) if target is not None and target.is_file(): target.unlink() - if manifest.exists() and not manifest.is_symlink(): + if manifest_backup.is_file() and manifest.exists() and not manifest.is_symlink(): manifest.unlink() for name, backup in backed_up.items(): target = managed_target(name) @@ -846,6 +846,7 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict: raise ControlError("No session artifact directory is available", "NO_SESSION_LOGS") destination = Path(args.destination or session["id"]).expanduser().resolve() exported = provider_call(adb, serial, "logs", timeout=args.timeout + 40) + cleanup_error = None if (exported.get("error") or {}).get("code") == "UNKNOWN_COMMAND": # Older builds have no logs method; their private folder only pulls on a rooted device. result = run_adb(adb, serial, "pull", remote_dir, str(destination), timeout=120) @@ -862,6 +863,20 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict: request_dir == remote_zip.parent and request_dir.parent.name == "agent-logs" and re.fullmatch(r"[0-9a-fA-F-]{36}", request_dir.name)): cleanup = ["shell", "rm", "-rf", str(request_dir)] + def cleanup_export(): + try: + cleaned = run_adb(adb, serial, *cleanup, timeout=30) + if cleaned.returncode == 0: + return None + detail = (cleaned.stderr or cleaned.stdout).decode(errors="replace").strip() + except Exception as error: + detail = str(error) + return ControlError( + "Could not remove the exported log archive; it may remain in shared storage" + + (f": {detail}" if detail else ""), + "LOG_CLEANUP_FAILED", + ) + try: if exported.get("sessionId", session["id"]) != session["id"]: raise ControlError("A new session started while the logs were exported; run logs again", @@ -869,21 +884,23 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict: with tempfile.TemporaryDirectory() as temp: local_zip = Path(temp) / "logs.zip" result = run_adb(adb, serial, "pull", zip_path, str(local_zip), timeout=120) - if result.returncode == 0: - unpack_logs(local_zip, destination, session["id"]) - finally: + if result.returncode != 0: + detail = (result.stderr or result.stdout).decode(errors="replace").strip() + raise ControlError(detail or "adb pull failed", "LOG_PULL_FAILED") + unpack_logs(local_zip, destination, session["id"]) + except Exception: + cleanup_error = cleanup_export() + if cleanup_error: + print(cleanup_error, file=sys.stderr) + raise + else: # The copy sits in shared storage, so it does not outlive the pull. - cleaned = run_adb(adb, serial, *cleanup, timeout=30) - if cleaned.returncode != 0: - detail = (cleaned.stderr or cleaned.stdout).decode(errors="replace").strip() - raise ControlError( - "Could not remove the exported log archive; it may remain in shared storage" + - (f": {detail}" if detail else ""), - "LOG_CLEANUP_FAILED", - ) + cleanup_error = cleanup_export() if result.returncode != 0: detail = (result.stderr or result.stdout).decode(errors="replace").strip() raise ControlError(detail or "adb pull failed", "LOG_PULL_FAILED") + if cleanup_error: + raise cleanup_error return add_serial({"ok": True, "command": "logs", "sessionId": session["id"], "path": str(destination)}, serial) if command == "screenshot": diff --git a/tools/tests/test_droiddeckctl.py b/tools/tests/test_droiddeckctl.py index 4197f721b..3598dea36 100644 --- a/tools/tests/test_droiddeckctl.py +++ b/tools/tests/test_droiddeckctl.py @@ -581,6 +581,15 @@ class DroiddeckctlTest(FakeAdbTest): self.assertEqual("LOG_CLEANUP_FAILED", out["error"]["code"]) self.assertIn("may remain in shared storage", out["error"]["message"]) + def test_logs_keep_pull_failure_primary_when_remote_cleanup_fails(self): + self.configure({"state": ended_state(), + "logs": {"ok": True, "sessionId": "s1", "zip": EXPORTED_ZIP, + "cleanupDir": EXPORTED_DIR}}, fail_cleanup=True) + code, out = self.run_ctl("logs", "latest", str(self.dir / "artifacts")) + self.assertEqual(6, code) + self.assertEqual("LOG_PULL_FAILED", out["error"]["code"]) + self.assertIn("Permission denied", out["error"]["message"]) + def test_logs_replace_the_previous_snapshot_and_preserve_unrelated_files(self): destination = self.dir / "artifacts" destination.mkdir() @@ -620,7 +629,7 @@ class DroiddeckctlTest(FakeAdbTest): self.assertEqual("current", (destination / "session.log").read_text()) def test_logs_roll_back_a_failed_manifest_commit_and_can_retry(self): - destination = self.dir / "artifacts" + destination = (self.dir / "artifacts").resolve() destination.mkdir() (destination / "notes.txt").write_text("keep me") first = self.dir / "first.zip" @@ -657,6 +666,28 @@ class DroiddeckctlTest(FakeAdbTest): self.assertFalse((destination / "steam/console.log").exists()) self.assertEqual("keep me", (destination / "notes.txt").read_text()) + def test_logs_preserve_the_old_manifest_when_its_backup_fails(self): + destination = self.dir / "artifacts" + destination.mkdir() + archive = self.dir / "logs.zip" + with zipfile.ZipFile(archive, "w") as z: + z.writestr("s1/session.log", "current") + manifest = destination / ".droiddeckctl-managed-logs.json" + manifest.write_text(json.dumps(["old.log"])) + original = manifest.read_bytes() + real_replace = ctl.os.replace + + def fail_manifest_backup(source, target): + if Path(source) == manifest: + raise OSError("injected manifest backup failure") + return real_replace(source, target) + + with unittest.mock.patch.object(ctl.os, "replace", side_effect=fail_manifest_backup): + with self.assertRaises(ctl.ControlError): + ctl.unpack_logs(archive, destination, "s1") + + self.assertEqual(original, manifest.read_bytes()) + def test_logs_from_an_older_build_fall_back_to_the_private_folder(self): self.configure({"state": ended_state()}) code, out = self.run_ctl("logs", "latest", str(self.dir / "artifacts")) From dfc0fd4340eb1be8799329343b952f9972b8edf5 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:49:11 -0500 Subject: [PATCH 05/11] fix: harden diagnostic staging lifecycle --- .../main/java/com/droiddeck/launcher/App.kt | 2 + .../session/EnhancedSessionLogShare.kt | 133 +++++++++++++++--- .../session/SessionLogShareZipTest.kt | 13 ++ tools/tests/test_droiddeckctl.py | 2 +- 4 files changed, 132 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 0d099cbf0..311dc0082 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -3,6 +3,7 @@ package com.droiddeck.launcher import android.app.Application import com.droiddeck.launcher.runtime.BwrapSpawner import com.droiddeck.launcher.session.CrashHandler +import com.droiddeck.launcher.session.EnhancedSessionLogShare import com.droiddeck.launcher.session.GpuClockPin class App : Application() { @@ -16,6 +17,7 @@ class App : Application() { override fun onCreate() { super.onCreate() + runCatching { EnhancedSessionLogShare.cleanupAbandonedStages(this) } com.droiddeck.launcher.ui.Themes.loadGradient(this) com.droiddeck.launcher.ui.BuildWatermark.install(this) HomeApp.trackRecents(this) diff --git a/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt b/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt index d20da13d6..9c19489a2 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt @@ -11,6 +11,7 @@ import org.json.JSONArray import org.json.JSONObject import java.io.BufferedInputStream import java.io.ByteArrayOutputStream +import java.io.Closeable import java.io.File import java.io.FileOutputStream import java.io.FileInputStream @@ -18,12 +19,15 @@ import java.io.IOException import java.nio.ByteBuffer import java.nio.channels.Channels import java.nio.channels.FileChannel +import java.nio.channels.FileLock +import java.nio.channels.OverlappingFileLockException import java.nio.channels.SeekableByteChannel import java.nio.file.FileVisitResult import java.nio.file.Files import java.nio.file.LinkOption import java.nio.file.Path import java.nio.file.SimpleFileVisitor +import java.nio.file.StandardOpenOption import java.nio.file.attribute.BasicFileAttributes import java.security.MessageDigest import java.util.Locale @@ -45,6 +49,24 @@ internal interface SecureSourceOpener { internal object EnhancedSessionLogShare { private enum class Category(val suffix: String) { SESSION("session"), GRAPHICS("graphics"), GAMES("games") } + internal class DiagnosticStageLease internal constructor( + val directory: File, + private val channel: FileChannel, + private val lock: FileLock, + ) : Closeable { + override fun close() { + try { + lock.release() + } finally { + try { + channel.close() + } finally { + directory.deleteRecursively() + } + } + } + } + private data class Candidate( val file: File, val anchor: File, @@ -65,6 +87,8 @@ internal object EnhancedSessionLogShare { private data class Snapshot(val file: File, val sourceBytes: Long, val truncated: Boolean) + private class StageUnavailableException(cause: Throwable) : IOException("Diagnostic staging directory is unavailable", cause) + private data class CollectionResult( val candidates: List, val truncated: Boolean, @@ -90,6 +114,70 @@ internal object EnhancedSessionLogShare { private val sourceOpenerOverride = object : ThreadLocal() { override fun initialValue(): SecureSourceOpener? = null } + private val stageLifecycleMonitor = Any() + + internal fun acquireDiagnosticStage(context: Context, outputName: String): DiagnosticStageLease { + val cache = context.cacheDir + require(outputName.isNotBlank() && File(outputName).name == outputName) + return withStageLifecycleLock(cache) { + val stage = File(cache, "$outputName-${UUID.randomUUID()}.diagnostic-stage") + if (!stage.mkdir()) throw IOException("Could not create the diagnostic staging directory") + val channel = try { + FileChannel.open(File(stage, ".active").toPath(), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE) + } catch (error: Exception) { + stage.deleteRecursively() + throw IOException("Could not create the diagnostic staging lock", error) + } + try { + val lock = channel.tryLock() + ?: throw IOException("Could not lock the diagnostic staging directory") + DiagnosticStageLease(stage, channel, lock) + } catch (error: Exception) { + channel.close() + stage.deleteRecursively() + throw IOException("Could not lock the diagnostic staging directory", error) + } + } + } + + internal fun cleanupAbandonedStages(context: Context) { + val cache = context.cacheDir + val canonicalCache = cache.canonicalFile + withStageLifecycleLock(cache) { + cache.listFiles()?.forEach { stage -> + if (!stage.isDirectory || Files.isSymbolicLink(stage.toPath()) || + stage.canonicalFile.parentFile != canonicalCache || !isDiagnosticStage(stage.name)) return@forEach + val active = File(stage, ".active") + if (Files.isSymbolicLink(active.toPath())) return@forEach + val channel = runCatching { + FileChannel.open(active.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE) + }.getOrNull() ?: return@forEach + val abandoned = try { + channel.tryLock()?.let { lock -> lock.release(); true } ?: false + } catch (_: OverlappingFileLockException) { + false + } finally { + channel.close() + } + if (abandoned) stage.deleteRecursively() + } + } + } + + private fun isDiagnosticStage(name: String): Boolean { + val suffix = ".diagnostic-stage" + if (!name.endsWith(suffix)) return false + val base = name.removeSuffix(suffix) + val uuidStart = base.length - 36 + if (uuidStart < 2 || base[uuidStart - 1] != '-') return false + return runCatching { UUID.fromString(base.substring(uuidStart)).toString() == base.substring(uuidStart) } + .getOrDefault(false) + } + + private fun withStageLifecycleLock(cache: File, block: () -> T): T = synchronized(stageLifecycleMonitor) { + FileChannel.open(File(cache, ".diagnostic-stages.lock").toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE) + .use { channel -> channel.lock().use { block() } } + } internal fun setSourceOpenerForTest(opener: SecureSourceOpener?) { if (opener == null) sourceOpenerOverride.remove() else sourceOpenerOverride.set(opener) @@ -108,17 +196,18 @@ internal object EnhancedSessionLogShare { return emptyList() } val out = SessionLogShare.shareDir(context).apply { deleteRecursively(); mkdirs() } - val stage = File(context.cacheDir, "${out.name}-${UUID.randomUUID()}.diagnostic-stage").apply { mkdirs() } - val redactionLearningComplete = LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context)) - val discovery = collect(context, folder, limits.maxDiscoveredFiles, legacy = false) - val candidates = discovery.candidates - val totalCandidates = candidates.size.coerceAtLeast(1) - val share = limits.maxBytesTotal / Category.values().size - val budgets = Category.values().associateWith { category -> - Budget(if (category == Category.GAMES) limits.maxBytesTotal - share * 2 else share) - } - var completed = 0 + val stageLease = acquireDiagnosticStage(context, out.name) + val stage = stageLease.directory return try { + val redactionLearningComplete = LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context)) + val discovery = collect(context, folder, limits.maxDiscoveredFiles, legacy = false) + val candidates = discovery.candidates + val totalCandidates = candidates.size.coerceAtLeast(1) + val share = limits.maxBytesTotal / Category.values().size + val budgets = Category.values().associateWith { category -> + Budget(if (category == Category.GAMES) limits.maxBytesTotal - share * 2 else share) + } + var completed = 0 Category.values().map { category -> val budget = budgets.getValue(category) val records = ArrayList() @@ -151,7 +240,7 @@ internal object EnhancedSessionLogShare { zip } } finally { - stage.deleteRecursively() + stageLease.close() } } @@ -174,7 +263,8 @@ internal object EnhancedSessionLogShare { out.deleteRecursively() return null } - val stage = File(context.cacheDir, "${out.name}-${UUID.randomUUID()}.diagnostic-stage").apply { mkdirs() } + val stageLease = acquireDiagnosticStage(context, out.name) + val stage = stageLease.directory val records = ArrayList() val budget = Budget(limits.maxBytesTotal) val zip = File(out, "DroidDeck-${folder.name}.zip") @@ -200,7 +290,7 @@ internal object EnhancedSessionLogShare { } zip } finally { - stage.deleteRecursively() + stageLease.close() } } @@ -228,10 +318,14 @@ internal object EnhancedSessionLogShare { if (budget.remaining <= 0) { return Record(candidate.entry, candidate.origin, "omitted", reason = "bundle-byte-limit") } - val snapshot = runCatching { + if (!stage.isDirectory || !stage.canWrite()) throw StageUnavailableException(IOException("staging path is not writable")) + val snapshot = try { snapshot(candidate, stage, minOf(limits.maxBytesPerFile, budget.remaining)) - }.getOrElse { - return Record(candidate.entry, candidate.origin, "error", reason = it.javaClass.simpleName) + } catch (error: StageUnavailableException) { + throw error + } catch (error: Exception) { + if (!stage.isDirectory || !stage.canWrite()) throw StageUnavailableException(error) + return Record(candidate.entry, candidate.origin, "error", reason = error.javaClass.simpleName) } ?: return Record(candidate.entry, candidate.origin, "omitted", reason = "unsafe-or-unavailable-source") if (snapshot.file.length() == 0L && candidate.linePrefix != null) { snapshot.file.delete() @@ -267,7 +361,11 @@ internal object EnhancedSessionLogShare { val rootPath = candidate.anchor.toPath().toAbsolutePath().normalize() val sourcePath = candidate.file.toPath().toAbsolutePath().normalize() if (!sourcePath.startsWith(rootPath) || sourcePath == rootPath || limit < 1) return null - val target = File.createTempFile("diagnostic-", ".log", stage) + val target = try { + File.createTempFile("diagnostic-", ".log", stage) + } catch (error: Exception) { + throw StageUnavailableException(error) + } try { val relative = rootPath.relativize(sourcePath) return withSecureChannel(rootPath, relative) { input -> @@ -293,6 +391,7 @@ internal object EnhancedSessionLogShare { } ?: target.delete().let { null } } catch (e: Exception) { target.delete() + if (!stage.isDirectory || !stage.canWrite() || !target.exists()) throw StageUnavailableException(e) throw e } } diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt index 0779a378c..ddf8909a8 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt @@ -3,6 +3,7 @@ package com.droiddeck.launcher.session import com.droiddeck.launcher.runtime.LinuxRuntime import java.io.File import java.text.SimpleDateFormat +import java.util.UUID import java.util.Date import java.util.Locale import java.util.zip.ZipFile @@ -26,6 +27,18 @@ class SessionLogShareZipTest { @After fun resetSourceOpener() = resetSecureSourceOpener() + @Test fun startupRemovesAbandonedDiagnosticStagesAndPreservesActiveSnapshots() { + val abandoned = File(app.cacheDir, "share-${UUID.randomUUID()}.diagnostic-stage").apply { mkdirs() } + val active = EnhancedSessionLogShare.acquireDiagnosticStage(app, "share") + try { + EnhancedSessionLogShare.cleanupAbandonedStages(app) + assertFalse(abandoned.exists()) + assertTrue(active.directory.isDirectory) + } finally { + active.close() + } + } + @Test fun anArchiveCanBeWrittenOutsideTheShareFolder() { SessionLogShare.clear(app) val session = File(LinuxRuntime.logDir(app), "2026-11-03-01-steam").apply { mkdirs() } diff --git a/tools/tests/test_droiddeckctl.py b/tools/tests/test_droiddeckctl.py index 3598dea36..0c4bdbce3 100644 --- a/tools/tests/test_droiddeckctl.py +++ b/tools/tests/test_droiddeckctl.py @@ -667,7 +667,7 @@ class DroiddeckctlTest(FakeAdbTest): self.assertEqual("keep me", (destination / "notes.txt").read_text()) def test_logs_preserve_the_old_manifest_when_its_backup_fails(self): - destination = self.dir / "artifacts" + destination = (self.dir / "artifacts").resolve() destination.mkdir() archive = self.dir / "logs.zip" with zipfile.ZipFile(archive, "w") as z: From 671c4a623fb92bd61ef30981b9f71495a63ee6c5 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:51:17 -0500 Subject: [PATCH 06/11] test: assert cleanup after failed log pull --- tools/tests/test_droiddeckctl.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/tests/test_droiddeckctl.py b/tools/tests/test_droiddeckctl.py index 0c4bdbce3..531bcd891 100644 --- a/tools/tests/test_droiddeckctl.py +++ b/tools/tests/test_droiddeckctl.py @@ -589,6 +589,7 @@ class DroiddeckctlTest(FakeAdbTest): self.assertEqual(6, code) self.assertEqual("LOG_PULL_FAILED", out["error"]["code"]) self.assertIn("Permission denied", out["error"]["message"]) + self.assertIn(["shell", "rm", "-rf", EXPORTED_DIR], [call["args"] for call in self.calls()]) def test_logs_replace_the_previous_snapshot_and_preserve_unrelated_files(self): destination = self.dir / "artifacts" From 9f22f63222744a58501a53b1a3fa38b76b4fc544 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:59:37 -0500 Subject: [PATCH 07/11] test: verify independent log export paths --- .../com/droiddeck/launcher/session/SessionLogShareZipTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt index ddf8909a8..6af039ade 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt @@ -51,8 +51,8 @@ class SessionLogShareZipTest { assertEquals(out, zip.parentFile) assertFalse(File(app.cacheDir, "share").exists()) assertTrue(zip.isFile) + assertEquals(secondOut, secondZip.parentFile) assertTrue(secondZip.isFile) - assertTrue(zip.isFile) } finally { out.deleteRecursively() secondOut.deleteRecursively() From 285ff7956bbcd1fcd7326071d63b8aa2d9e860aa Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:09:04 -0500 Subject: [PATCH 08/11] test: cover independent agent log exports --- .../launcher/agent/AgentLogExportTest.kt | 65 +++++++++++++++++++ docs/agent-control.md | 4 ++ 2 files changed, 69 insertions(+) create mode 100644 app/src/test/java/com/droiddeck/launcher/agent/AgentLogExportTest.kt diff --git a/app/src/test/java/com/droiddeck/launcher/agent/AgentLogExportTest.kt b/app/src/test/java/com/droiddeck/launcher/agent/AgentLogExportTest.kt new file mode 100644 index 000000000..babdec83b --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/agent/AgentLogExportTest.kt @@ -0,0 +1,65 @@ +package com.droiddeck.launcher.agent + +import android.os.Bundle +import com.droiddeck.launcher.session.SessionState +import com.droiddeck.launcher.session.installJvmSecureSourceOpener +import com.droiddeck.launcher.session.resetSecureSourceOpener +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], manifest = Config.NONE) +class AgentLogExportTest { + private val context get() = RuntimeEnvironment.getApplication() + private var previousLogDirectory: File? = null + + @Before fun prepare() { + previousLogDirectory = SessionState.logDirectory + installJvmSecureSourceOpener() + } + + @After fun cleanup() { + SessionState.logDirectory = previousLogDirectory + resetSecureSourceOpener() + } + + @Test fun independentLogRequestsKeepBothExportDirectories() { + val session = File(context.cacheDir, "agent-session").apply { mkdirs() } + File(session, "session.log").writeText("booted\n") + SessionState.logDirectory = session + val provider = Robolectric.buildContentProvider(AgentBridgeProvider::class.java).create().get() + var firstDir: File? = null + var secondDir: File? = null + + try { + fun requestLogs() = JSONObject(provider.call("logs", null, Bundle()).getString("json")!!) + val first = requestLogs() + val second = requestLogs() + firstDir = File(first.getString("cleanupDir")) + secondDir = File(second.getString("cleanupDir")) + val firstZip = File(first.getString("zip")) + val secondZip = File(second.getString("zip")) + + assertNotEquals(firstDir, secondDir) + assertEquals(firstDir, firstZip.parentFile) + assertEquals(secondDir, secondZip.parentFile) + assertTrue(firstZip.isFile) + assertTrue(secondZip.isFile) + } finally { + firstDir?.deleteRecursively() + secondDir?.deleteRecursively() + session.deleteRecursively() + } + } +} diff --git a/docs/agent-control.md b/docs/agent-control.md index eba6ca773..7a1fed1d0 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -92,6 +92,10 @@ Every command writes one JSON object to stdout and its resolved serial to stderr success, 2 invalid or rejected command, 3 ADB or device error, 4 session error, 5 timeout, 6 artifact or file error, 7 debugging tools disabled. +When upgrading the CLI, use a new destination for the first pull if the old destination contains +archives created by an older CLI without a `.droiddeckctl-managed-logs.json` manifest and their +filenames overlap. The upgraded CLI refuses to overwrite those files to preserve unrelated data. + ## Commands ```sh From a431c40b1b2dc80d784ce5769482b1793da19fe0 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:14:23 -0500 Subject: [PATCH 09/11] fix: preserve per-file diagnostic failures --- .../session/EnhancedSessionLogShare.kt | 3 +- .../session/EnhancedSessionLogShareTest.kt | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt b/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt index 9c19489a2..17acd5f83 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt @@ -390,8 +390,9 @@ internal object EnhancedSessionLogShare { Snapshot(target, size, size > limit) } ?: target.delete().let { null } } catch (e: Exception) { + val targetWasPresent = target.exists() target.delete() - if (!stage.isDirectory || !stage.canWrite() || !target.exists()) throw StageUnavailableException(e) + if (!stage.isDirectory || !stage.canWrite() || !targetWasPresent) throw StageUnavailableException(e) throw e } } diff --git a/app/src/test/java/com/droiddeck/launcher/session/EnhancedSessionLogShareTest.kt b/app/src/test/java/com/droiddeck/launcher/session/EnhancedSessionLogShareTest.kt index c1e2e8a21..4a9085807 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/EnhancedSessionLogShareTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/EnhancedSessionLogShareTest.kt @@ -13,6 +13,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config +import java.io.IOException import java.io.RandomAccessFile import java.nio.ByteBuffer import java.nio.channels.FileChannel @@ -497,4 +498,31 @@ class EnhancedSessionLogShareTest { assertFalse(EnhancedSessionLogShare.hasSourceOpenerForTest()) SessionLogShare.clear(app) } + + @Test fun sourceReadFailureIsRecordedWithoutFailingTheArchive() { + val session = tmp.newFolder("2026-11-04-16-steam") + session.resolve("session.log").writeText("must not be shared\n") + EnhancedSessionLogShare.setSourceOpenerForTest(object : SecureSourceOpener { + override fun withChannel( + root: java.nio.file.Path, + relative: java.nio.file.Path, + block: (java.nio.channels.SeekableByteChannel) -> T, + ): T? = throw IOException("injected source read failure") + }) + try { + val sessionZip = EnhancedSessionLogShare.create(app, session).single { it.name.endsWith("-session.zip") } + ZipFile(sessionZip).use { archive -> + assertFalse(archive.entries().toList().any { it.name.endsWith("session.log") }) + val manifest = JSONObject(archive.getInputStream(archive.getEntry("diagnostics-manifest.json")) + .bufferedReader().readText()).getJSONArray("entries") + val record = (0 until manifest.length()).map { manifest.getJSONObject(it) } + .single { it.optString("entry").endsWith("session.log") } + assertEquals("error", record.getString("status")) + assertEquals("IOException", record.getString("reason")) + } + } finally { + resetSecureSourceOpener() + } + SessionLogShare.clear(app) + } } From 1ae37055c951c509f224f2d522c13e61766eb4a2 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:30:21 -0500 Subject: [PATCH 10/11] fix: preserve diagnostic staging failures --- .../session/EnhancedSessionLogShare.kt | 159 +++++++++++------- .../session/EnhancedSessionLogShareTest.kt | 6 +- 2 files changed, 101 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt b/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt index 17acd5f83..f838e71ac 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt @@ -89,6 +89,8 @@ internal object EnhancedSessionLogShare { private class StageUnavailableException(cause: Throwable) : IOException("Diagnostic staging directory is unavailable", cause) + private class SourceReadException(cause: Throwable) : IOException("Could not read diagnostic source", cause) + private data class CollectionResult( val candidates: List, val truncated: Boolean, @@ -325,7 +327,8 @@ internal object EnhancedSessionLogShare { throw error } catch (error: Exception) { if (!stage.isDirectory || !stage.canWrite()) throw StageUnavailableException(error) - return Record(candidate.entry, candidate.origin, "error", reason = error.javaClass.simpleName) + val reportedError = (error as? SourceReadException)?.cause ?: error + return Record(candidate.entry, candidate.origin, "error", reason = reportedError.javaClass.simpleName) } ?: return Record(candidate.entry, candidate.origin, "omitted", reason = "unsafe-or-unavailable-source") if (snapshot.file.length() == 0L && candidate.linePrefix != null) { snapshot.file.delete() @@ -369,27 +372,33 @@ internal object EnhancedSessionLogShare { try { val relative = rootPath.relativize(sourcePath) return withSecureChannel(rootPath, relative) { input -> - val size = input.size() + val size = readSource { input.size() } candidate.linePrefix?.let { prefix -> val truncated = filterChannel(input, target, prefix, size, TRACE_SCAN_BYTES, limit) return@withSecureChannel Snapshot(target, size, truncated) } - FileOutputStream(target).channel.use { output -> - if (size <= limit) { - copy(input, output, 0, size) - } else { - val marker = "\n\n".toByteArray() - val content = (limit - marker.size).coerceAtLeast(0) - copyCompleteHead(input, output, content / 2) - val markerBytes = minOf(marker.size.toLong(), limit - output.position()).toInt() - output.write(ByteBuffer.wrap(marker, 0, markerBytes)) - copyCompleteTail(input, output, size, (limit - output.position()).coerceAtLeast(0)) + writeStage { + FileOutputStream(target).channel.use { output -> + if (size <= limit) { + copy(input, output, 0, size) + } else { + val marker = "\n\n".toByteArray() + val content = (limit - marker.size).coerceAtLeast(0) + copyCompleteHead(input, output, content / 2) + val markerBytes = minOf(marker.size.toLong(), limit - output.position()).toInt() + output.write(ByteBuffer.wrap(marker, 0, markerBytes)) + copyCompleteTail(input, output, size, (limit - output.position()).coerceAtLeast(0)) + } + output.force(false) } - output.force(false) } Snapshot(target, size, size > limit) } ?: target.delete().let { null } } catch (e: Exception) { + if (e is StageUnavailableException) { + target.delete() + throw e + } val targetWasPresent = target.exists() target.delete() if (!stage.isDirectory || !stage.canWrite() || !targetWasPresent) throw StageUnavailableException(e) @@ -408,48 +417,51 @@ internal object EnhancedSessionLogShare { val wanted = prefix.toByteArray() var written = 0L var truncated = sourceSize > scanLimit - target.outputStream().buffered().use { output -> - fun scan(start: Long, count: Long, discardPartialFirstLine: Boolean) { - channel.position(start) - val input = BufferedInputStream(Channels.newInputStream(channel), COPY_BUFFER_BYTES) - val line = ByteArrayOutputStream(1024) - var oversized = false - var scanned = 0L - var discard = discardPartialFirstLine - fun emit() { - val bytes = line.toByteArray() - if (!discard && !oversized && bytes.size >= wanted.size && wanted.indices.all { bytes[it] == wanted[it] }) { - if (written + bytes.size + 1 <= outputLimit) { - output.write(bytes) - output.write('\n'.code) - written += bytes.size + 1 - } else truncated = true + writeStage { + target.outputStream().buffered().use { output -> + fun scan(start: Long, count: Long, discardPartialFirstLine: Boolean) { + readSource { channel.position(start) } + val input = BufferedInputStream(Channels.newInputStream(channel), COPY_BUFFER_BYTES) + val line = ByteArrayOutputStream(1024) + var oversized = false + var scanned = 0L + var discard = discardPartialFirstLine + fun emit() { + val bytes = line.toByteArray() + if (!discard && !oversized && bytes.size >= wanted.size && wanted.indices.all { bytes[it] == wanted[it] }) { + if (written + bytes.size + 1 <= outputLimit) { + output.write(bytes) + output.write('\n'.code) + written += bytes.size + 1 + } else truncated = true + } + line.reset() + oversized = false + discard = false } - line.reset() - oversized = false - discard = false - } - while (scanned < count) { - val value = input.read() - if (value < 0) break - scanned++ - if (value == '\n'.code) emit() - else if (!oversized && !discard) { - if (line.size() >= LogRedactor.MAX_LINE_LENGTH) { - oversized = true - line.reset() - } else line.write(value) + while (scanned < count) { + val value = readSource { input.read() } + if (value < 0) break + scanned++ + if (value == '\n'.code) emit() + else if (!oversized && !discard) { + if (line.size() >= LogRedactor.MAX_LINE_LENGTH) { + oversized = true + line.reset() + } else line.write(value) + } } + if (start + scanned >= sourceSize && (line.size() > 0 || oversized)) emit() + } + + if (sourceSize <= scanLimit) { + scan(0, sourceSize, false) + } else { + val head = scanLimit / 2 + val tail = scanLimit - head + scan(0, head, false) + scan((sourceSize - tail).coerceAtLeast(0), tail, true) } - if (start + scanned >= sourceSize && (line.size() > 0 || oversized)) emit() - } - if (sourceSize <= scanLimit) { - scan(0, sourceSize, false) - } else { - val head = scanLimit / 2 - val tail = scanLimit - head - scan(0, head, false) - scan((sourceSize - tail).coerceAtLeast(0), tail, true) } } return truncated @@ -462,6 +474,7 @@ internal object EnhancedSessionLogShare { private fun withAndroidSecureChannel(root: Path, relative: Path, block: (SeekableByteChannel) -> T): T? { val opened = ArrayList() + var invokingBlock = false return try { val parts = relative.map { it.toString() } if (parts.isEmpty()) return null @@ -474,11 +487,15 @@ internal object EnhancedSessionLogShare { val stat = Os.fstat(leafFd) if (!OsConstants.S_ISREG(stat.st_mode) || stat.st_nlink != 1L) return null // Android's FileInputStream(FileDescriptor) is deliberately not the descriptor owner. - FileInputStream(leafFd).use { stream -> block(stream.channel) } + FileInputStream(leafFd).use { stream -> + invokingBlock = true + block(stream.channel) + } } finally { runCatching { Os.close(leafFd) } } - } catch (_: Exception) { + } catch (error: Exception) { + if (invokingBlock) throw error null } finally { opened.asReversed().forEach { runCatching { it.close() } } @@ -507,16 +524,16 @@ internal object EnhancedSessionLogShare { "/proc/self/fd/${directory.fd}/$name" private fun copy(input: SeekableByteChannel, output: FileChannel, start: Long, count: Long) { - input.position(start) + readSource { input.position(start) } var left = count val buffer = ByteBuffer.allocate(64 * 1024) while (left > 0) { buffer.clear() buffer.limit(minOf(buffer.capacity().toLong(), left).toInt()) - val read = input.read(buffer) + val read = readSource { input.read(buffer) } if (read < 0) break buffer.flip() - while (buffer.hasRemaining()) output.write(buffer) + while (buffer.hasRemaining()) writeStage { output.write(buffer) } left -= read } } @@ -525,14 +542,14 @@ internal object EnhancedSessionLogShare { if (limit <= 0) return copy(input, output, 0, limit) val copied = output.position() - input.position(0) + readSource { input.position(0) } val buffer = ByteBuffer.allocate(64 * 1024) var scanned = 0L var lastComplete = 0L while (scanned < copied) { buffer.clear() buffer.limit(minOf(buffer.capacity().toLong(), copied - scanned).toInt()) - val read = input.read(buffer) + val read = readSource { input.read(buffer) } if (read < 0) break buffer.flip() repeat(read) { index -> @@ -540,8 +557,8 @@ internal object EnhancedSessionLogShare { } scanned += read } - output.truncate(lastComplete) - output.position(lastComplete) + writeStage { output.truncate(lastComplete) } + writeStage { output.position(lastComplete) } } private fun copyCompleteTail( @@ -553,10 +570,10 @@ internal object EnhancedSessionLogShare { if (limit <= 0 || sourceSize <= 0) return var start = (sourceSize - limit).coerceAtLeast(0) if (start > 0) { - input.position(start) + readSource { input.position(start) } val buffered = BufferedInputStream(Channels.newInputStream(input), COPY_BUFFER_BYTES) while (start < sourceSize) { - val value = buffered.read() + val value = readSource { buffered.read() } if (value < 0) return start++ if (value == '\n'.code) break @@ -565,6 +582,22 @@ internal object EnhancedSessionLogShare { copy(input, output, start, (sourceSize - start).coerceAtMost(limit)) } + private inline fun readSource(block: () -> T): T = try { + block() + } catch (error: Exception) { + throw SourceReadException(error) + } + + private inline fun writeStage(block: () -> T): T = try { + block() + } catch (error: SourceReadException) { + throw error + } catch (error: StageUnavailableException) { + throw error + } catch (error: Exception) { + throw StageUnavailableException(error) + } + private fun addManifest( archive: ZipOutputStream, sessionId: String, diff --git a/app/src/test/java/com/droiddeck/launcher/session/EnhancedSessionLogShareTest.kt b/app/src/test/java/com/droiddeck/launcher/session/EnhancedSessionLogShareTest.kt index 4a9085807..f9fea7c9b 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/EnhancedSessionLogShareTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/EnhancedSessionLogShareTest.kt @@ -507,7 +507,11 @@ class EnhancedSessionLogShareTest { root: java.nio.file.Path, relative: java.nio.file.Path, block: (java.nio.channels.SeekableByteChannel) -> T, - ): T? = throw IOException("injected source read failure") + ): T? = FileChannel.open(root.resolve(relative), StandardOpenOption.READ).use { channel -> + block(object : SeekableByteChannel by channel { + override fun read(dst: ByteBuffer): Int = throw IOException("injected source read failure") + }) + } }) try { val sessionZip = EnhancedSessionLogShare.create(app, session).single { it.name.endsWith("-session.zip") } From 604a01d3539b0bab1b57c15c1386b15e8fc7ce4e Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:41:47 -0500 Subject: [PATCH 11/11] fix: map log destination errors --- tools/droiddeckctl | 5 ++++- tools/tests/test_droiddeckctl.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/tools/droiddeckctl b/tools/droiddeckctl index 02fe7020c..65fb274ce 100755 --- a/tools/droiddeckctl +++ b/tools/droiddeckctl @@ -425,7 +425,10 @@ def start_session(adb: str, serial: str, request: dict) -> dict: def unpack_logs(archive: Path, destination: Path, session_id: str) -> None: """Unpack a Share logs zip so the session's own files sit at the top of destination.""" destination = destination.resolve() - destination.mkdir(parents=True, exist_ok=True) + try: + destination.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise ControlError(f"Could not unpack the session logs: {exc}", "LOG_PULL_FAILED") from exc manifest = destination / ".droiddeckctl-managed-logs.json" try: previous = json.loads(manifest.read_text(encoding="utf-8")) diff --git a/tools/tests/test_droiddeckctl.py b/tools/tests/test_droiddeckctl.py index 531bcd891..731e3aa38 100644 --- a/tools/tests/test_droiddeckctl.py +++ b/tools/tests/test_droiddeckctl.py @@ -610,6 +610,23 @@ class DroiddeckctlTest(FakeAdbTest): self.assertFalse((destination / "steam/console.log").exists()) self.assertEqual("keep me", (destination / "notes.txt").read_text()) + def test_logs_map_destination_creation_failure_to_pull_error(self): + destination = (self.dir / "artifacts").resolve() + archive = self.dir / "logs.zip" + real_mkdir = Path.mkdir + + def fail_destination(path, *args, **kwargs): + if path == destination: + raise PermissionError("injected destination failure") + return real_mkdir(path, *args, **kwargs) + + with unittest.mock.patch.object(Path, "mkdir", new=fail_destination): + with self.assertRaises(ctl.ControlError) as raised: + ctl.unpack_logs(archive, destination, "s1") + + self.assertEqual("LOG_PULL_FAILED", raised.exception.code) + self.assertIn("injected destination failure", str(raised.exception)) + def test_logs_do_not_follow_a_stale_managed_symlink(self): if os.name == "nt": self.skipTest("symlink creation is unavailable on this Windows host")