From ef261d9724b4d4957cbf12ce9dd2018dc3517149 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Wed, 7 Oct 2026 17:18:26 -0400 Subject: [PATCH] double down on PD --- app/src/main/AndroidManifest.xml | 1 - .../com/droiddeck/launcher/MainActivity.kt | 3 - .../launcher/agent/AgentBridgeProvider.kt | 14 +- .../droiddeck/launcher/core/LogRedactor.kt | 43 ++-- .../launcher/core/SessionLogCapture.kt | 2 +- .../launcher/files/FileManagerScreen.kt | 1 - .../launcher/runtime/GuestCommand.kt | 2 +- .../launcher/runtime/LinuxRuntime.java | 6 +- .../launcher/session/SessionArtifacts.kt | 212 ++++------------ .../launcher/session/SessionFiles.kt | 25 +- .../launcher/session/SessionLogFiles.kt | 107 ++++++++ .../launcher/session/SessionLogMigration.kt | 72 ++++++ .../launcher/session/SessionLogShare.kt | 111 ++++---- .../launcher/session/SessionPaths.kt | 49 +--- .../launcher/session/SessionService.kt | 3 - app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-ja/strings.xml | 2 +- app/src/main/res/values-ko/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values-zh-rHK/strings.xml | 2 +- app/src/main/res/values-zh-rTW/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../launcher/core/LogRedactorTest.kt | 33 +++ .../session/SessionLogMigrationTest.kt | 100 ++++++++ .../launcher/session/SessionLogShareTest.kt | 239 ++++++++++++------ docs/agent-control.md | 4 +- tools/droiddeckctl | 25 +- tools/tests/test_log_export.py | 51 ++++ 30 files changed, 689 insertions(+), 432 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/session/SessionLogFiles.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/session/SessionLogMigration.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/SessionLogMigrationTest.kt create mode 100644 tools/tests/test_log_export.py diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7fa9d195a..be6f73091 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,7 +4,6 @@ - diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 90a3a5d5f..350ab973c 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -822,9 +822,6 @@ class MainActivity : ComponentActivity() { if (savedInstanceState == null) ui.post { startSteamAtStartupIfEnabled() } - // The session's logs land in Downloads so a failed run can be handed over as a folder - // rather than dug out of app-private storage. targetSdk 28 means the old permission still - // grants exactly that. val wanted = ArrayList() if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { wanted.add(Manifest.permission.WRITE_EXTERNAL_STORAGE) 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 4e8b066eb..7cb4dd267 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt @@ -15,6 +15,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller import com.droiddeck.launcher.session.SessionArtifacts import com.droiddeck.launcher.session.SessionEvents +import com.droiddeck.launcher.session.SessionLogShare import com.droiddeck.launcher.session.SessionPhase import com.droiddeck.launcher.session.SessionPaths import com.droiddeck.launcher.session.SessionService @@ -50,6 +51,7 @@ class AgentBridgeProvider : ContentProvider() { val response = try { when (method) { "state" -> state(context).put("ok", true) + "logs" -> exportLogs(context) "start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity") "stop" -> stop(context) "resume" -> resume(context) @@ -63,7 +65,7 @@ class AgentBridgeProvider : ContentProvider() { private fun state(context: Context): JSONObject { val runtimeVersion = LinuxRuntimeInstaller.installedVersion(context) - val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: latestSessionDirectory() + val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: SessionPaths.sessionFolders(context).lastOrNull() val session = JSONObject() .put("id", SessionState.sessionId ?: dir?.name ?: JSONObject.NULL) .put("phase", SessionState.phase.name) @@ -101,10 +103,12 @@ class AgentBridgeProvider : ContentProvider() { .put("session", session) } - private fun latestSessionDirectory(): File? { - val parent = LinuxRuntime.debugLogDir() - return parent.listFiles { file -> file.isDirectory && file.name.startsWith("session-") } - ?.maxWithOrNull(compareBy { it.lastModified() }.thenBy { it.name }) + private fun exportLogs(context: Context): JSONObject { + val dir = SessionState.logDirectory ?: SessionLogShare.latest(context) + ?: return error("NO_SESSION_LOGS", "No session logs are available") + val zip = SessionLogShare.zipFolder(context, dir) + ?: return error("NO_SESSION_LOGS", "No diagnostic logs are available") + return JSONObject().put("ok", true).put("sessionId", dir.name).put("path", zip.absolutePath) } private fun stop(context: Context): JSONObject { diff --git a/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt b/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt index f9c03fefb..5c89aeaa2 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt @@ -36,11 +36,16 @@ object LogRedactor { * WebAPI key is caught by [WEBAPI_KEY]. The value class excludes `<` and `>` so running this * twice over an already-scrubbed line changes nothing. */ - private val SECRET_KV = Regex( - "(?i)\\b(access[_-]?token|refresh[_-]?token|auth[_-]?token|authtoken|token|authcode|" + + private const val SECRET_KEYS = + "access[_-]?token|refresh[_-]?token|auth[_-]?token|authtoken|token|authcode|" + "auth[_-]?ticket|ticket|sessionid|steamloginsecure|webapikey|api[_-]?key|" + - "machine[_-]?auth(?:[_-]?token)?|machineauth|password|passwd|pwd|secret)" + - "(\\s*[=:]\\s*|=)([^\\s\"'<>&;,]{4,})" + "machine[_-]?auth(?:[_-]?token)?|machineauth|password|passwd|pwd|secret" + private val SECRET_KV = Regex( + "(?i)\\b($SECRET_KEYS)" + + "([\"']?\\s*[=:]\\s*[\"']?)([^\\s\"'<>&;,]{1,})" + ) + private val QUOTED_SECRET = Regex( + "(?i)\\b($SECRET_KEYS)([\"']?\\s*[=:]\\s*)(\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*')" ) /** A Steam Guard code, only where the text around it says that is what it is. */ private val GUARD_CODE = Regex( @@ -53,6 +58,7 @@ object LogRedactor { "(?i)\\b(jwt|token|ticket|sessionid|steamloginsecure|machineauth)\\b[\\s=:]*([A-Za-z0-9+/=_\\-]{12,})" ) private val LONG_TOKEN = Regex("[A-Za-z0-9_\\-]{88,}") + private val AUTHORIZATION = Regex("(?i)(\\b(?:proxy-)?authorization[\"']?\\s*[:=]\\s*[\"']?)[^\\r\\n\"']+") private val STEAMID64 = Regex("\\b(76561)(\\d{8})(\\d{4})\\b") private val STEAMID3 = Regex("\\[U:1:(\\d+)]") /** "external address 2607:..." / "external IP: 203.0.113.9" - the client stating ours. */ @@ -179,30 +185,6 @@ object LogRedactor { return out } - /** A file worth scrubbing: text (no NUL in its first 4 KB) and not huge. */ - fun isText(file: java.io.File): Boolean = try { - file.isFile && file.length() < 64L * 1024 * 1024 && file.inputStream().use { input -> - val buf = ByteArray(4096) - val n = input.read(buf) - n <= 0 || (0 until n).none { buf[it].toInt() == 0 } - } - } catch (e: Exception) { - false - } - - /** - * Bump whenever a pattern is added or tightened. A session folder records the version it was - * scrubbed under (SessionArtifacts.SCRUBBED_TREE_MARKER), and a share copies a file as it is - * only when that matches, so a folder scrubbed under older rules is scrubbed again on the way - * out. - */ - const val RULES_VERSION = 1 - - /** [src]'s lines, scrubbed, to [out]. */ - fun scrubTo(src: java.io.File, out: java.io.Writer) { - src.forEachLine { line -> out.write(redact(line)); out.write("\n") } - } - /** [line] with every credential shape replaced. Null- and exception-safe by construction. */ fun redact(line: String): String { if (line.isEmpty()) return line @@ -211,6 +193,11 @@ object LogRedactor { out = GUID.replace(out, "") out = JWT_LABELLED.replace(out) { "${it.groupValues[1]}" } out = JWT_BASE64.replace(out, "") + out = AUTHORIZATION.replace(out) { "${it.groupValues[1]}" } + out = QUOTED_SECRET.replace(out) { + val quote = it.groupValues[3].first() + "${it.groupValues[1]}${it.groupValues[2]}$quote$quote" + } out = SECRET_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}" } out = GUARD_CODE.replace(out) { "${it.groupValues[1]}" } out = WEBAPI_KEY.replace(out, "") diff --git a/app/src/main/java/com/droiddeck/launcher/core/SessionLogCapture.kt b/app/src/main/java/com/droiddeck/launcher/core/SessionLogCapture.kt index 67b924ec2..a76963508 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/SessionLogCapture.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/SessionLogCapture.kt @@ -49,7 +49,7 @@ object SessionLogCapture { ) { line -> synchronized(this) { try { - writer?.apply { write(line); newLine(); flush() } + if (writer === out) out.apply { write(line); newLine(); flush() } } catch (e: Exception) { // A full or unmounted card must not take the session with it. } diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt index 9f9361ad7..924b5dd81 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt @@ -1020,7 +1020,6 @@ fun FileManagerScreen( // The ROMs folder chosen on the main screen: what the session shows as /root/ROMs. SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory } ?.let { add(locItem(stringResource(R.string.fm_rail_roms), Icons.Filled.SportsEsports, it)) } - File("/storage/emulated/0/Download/DroidDeck").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) } File("/storage/emulated/0/Pictures").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_pictures), Icons.Filled.Image, it)) } } val favItems = remember(favTick) { FavoritesStore.list(context).map(::File).filter { it.exists() } } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt index a5a653c3e..e8e919089 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt @@ -25,7 +25,7 @@ object GuestCommand { linkDir: File? = null, onLine: (String) -> Unit): Int { val log = logName?.let { try { - File(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter() + File(File(LinuxRuntime.debugLogDir(context), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter() } catch (e: Exception) { null } } log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}") diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java index 38a33980a..d9a552567 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java @@ -44,12 +44,10 @@ public final class LinuxRuntime { public static final String GUEST_RUNTIME_DIR = "/run/droiddeck"; private static final String SYSTEM_FONTS = "/system/fonts"; private static final String GUEST_SYSTEM_FONTS = "/usr/local/share/fonts/android"; - /** Where every Linux session's debug log lands: public, so a user can just hand the folder over. */ public static final String DEBUG_LOG_DIR = "DroidDeck"; - public static File debugLogDir() { - return new File(android.os.Environment.getExternalStoragePublicDirectory( - android.os.Environment.DIRECTORY_DOWNLOADS), DEBUG_LOG_DIR); + public static File debugLogDir(Context context) { + return new File(context.getFilesDir(), "logs"); } private LinuxRuntime() {} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt index 500c2f7d3..0d1fca04d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt @@ -31,21 +31,9 @@ object SessionArtifacts { /** Written last; a folder without it did not get its ending. */ const val COMPLETE_MARKER = ".complete" - /** Every file in the folder has been through the redactor, own addresses and accounts included (-2: accounts added). */ - private const val SCRUBBED_MARKER = ".scrubbed-2" - - /** - * Written by a session's own ending, and only when every file in the folder, subfolders - * included (whatever the session script copied into steam/ and droiddeck-esync/ as well as what - * the app wrote), went through the redactor without a failure. It lists each of those files - * with its size and modification time, under the redactor's rules version: a share copies a - * file as it is only when all three still match ([scrubbedFiles]), and scrubs anything else - - * a file added or changed since, a hidden one, or every file of a folder without it. - */ - const val SCRUBBED_TREE_MARKER = ".scrubbed-tree" - /** Everything the end of a session gathers, into [dir]. Safe to call for a dead session. */ fun collect(context: Context, dir: File, reason: String) { + SessionLogCapture.stopFor(dir) try { val wayland = File(dir, "wayland.log") if (!wayland.exists()) { @@ -54,11 +42,10 @@ object SessionArtifacts { } } LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context)) - val record = Record(dir) - copySteamLogs(context, dir, record) + copySteamLogs(context, dir) // A session the system killed leaves its trace here and nowhere else. SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log")) - markScrubbed(scrubTree(record)) + scrubTree(dir) SessionEvents.record("session.artifacts_collected", mapOf("reason" to reason), dir) File(dir, COMPLETE_MARKER).writeText("collected: $reason at ${now()}\n") } catch (e: Exception) { @@ -66,153 +53,52 @@ object SessionArtifacts { } } - /** - * Once, for session folders written before every file was scrubbed and before the device's own - * addresses were (network.txt listed them; the client's IPv6 check logs "external address" - * into steam/connection_log.txt): the whole folder, steam/ included, through the redactor. - * A marker records it. Runs at app start with [finishAbandoned]. - */ @Synchronized fun scrubOlder(context: Context) { - val current = SessionPaths.current() - val dirs = LinuxRuntime.debugLogDir().listFiles { f -> - SessionPaths.isSessionFolder(f) && f != current && !File(f, SCRUBBED_MARKER).exists() - } ?: return - if (dirs.isEmpty()) return LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context)) - dirs.forEach { dir -> - Record(dir).let { r -> scrubFolder(dir, r); File(dir, "steam").takeIf { it.isDirectory }?.let { scrubFolder(it, r) } } - try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {} - } - Log.i(TAG, "scrubbed ${dirs.size} older session folder(s)") + SessionPaths.sessionFolders(context).filter { + it.parentFile == LinuxRuntime.debugLogDir(context) && it != SessionPaths.current() && + !File(it, COMPLETE_MARKER).exists() + }.forEach { scrubTree(it) } } - /** - * The session's own files (session.log, app.log, desktop.log, the Steam desktop client's - * steam-desktop.log, ...) were written as they happened, unscrubbed; this pass puts every one - * through the redactor before the folder can be shared. A file is rewritten only if a line - * changed. steam/ was scrubbed on the way in. - */ - /** A file's size and modification time as they stood right after it was scrubbed. */ - private class Scrubbed(val length: Long, val modified: Long) - - /** Files of a folder as they stood right after scrubbing, filled by [scrubTree] and [copySteamLogs]. */ - private class Record(val dir: File) { - val files = HashMap() - var failed = false - } - - private fun scrubFolder(dir: File, record: Record) { - dir.listFiles { f -> f.isFile && !f.name.startsWith(".") && f !in record.files }?.forEach { f -> - if (!LogRedactor.isText(f)) return@forEach - val done = scrubFile(dir, f) - if (done == null) record.failed = true else record.files[f] = done - } - } - - /** - * Puts [f] through the redactor in place. Returns its size and modification time as it then - * stands, or null if it could not, and [f] may still hold the original. A writer still - * appending to [f] (the app's own log capture) changes its size afterwards, and a share then - * scrubs it again. - */ - private fun scrubFile(dir: File, f: File): Scrubbed? { - val tmp = File(dir, ".${f.name}.scrub") - return try { - val before = Scrubbed(f.length(), f.lastModified()) - tmp.bufferedWriter().use { w -> LogRedactor.scrubTo(f, w) } - val scrubbed = tmp.readBytes() - if (scrubbed.contentEquals(f.readBytes())) { - tmp.delete() - // Unchanged by the redactor, and by anything else while it read. - before.takeIf { f.length() == it.length && f.lastModified() == it.modified } - } else { - // Shared storage can refuse a rename; then the scrubbed bytes are written over the - // original instead, so it never stays behind unscrubbed. - if (!tmp.renameTo(f)) { f.writeBytes(scrubbed); tmp.delete() } - Scrubbed(scrubbed.size.toLong(), f.lastModified()).takeIf { f.length() == it.length } - } - } catch (e: Exception) { - Log.w(TAG, "could not scrub ${f.name}", e) - tmp.delete() - null - } - } - - /** - * Records [record]'s folder as scrubbed - every file went through without a failure - or - * leaves it unmarked, so the next share and the pass over older folders scrub it again. - */ - private fun markScrubbed(record: Record) { - val dir = record.dir - val tree = File(dir, SCRUBBED_TREE_MARKER) - if (record.failed) { - tree.delete() - Log.w(TAG, "$dir is not fully scrubbed; it is scrubbed again when shared") - return - } - try { - File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") - val lines = StringBuilder("rules ${LogRedactor.RULES_VERSION}\n") - record.files.forEach { (f, s) -> - lines.append(s.length).append('\t').append(s.modified).append('\t') - .append(f.relativeTo(dir).path).append('\n') - } - tree.writeText(lines.toString()) - } catch (e: Exception) { - tree.delete() - } - } - - /** - * The files of [dir] that its own ending scrubbed and that are unchanged since (same size and - * modification time), by path relative to [dir]. Empty when the folder has no such record, or - * one made under other redactor rules. - */ - fun scrubbedFiles(dir: File): Set { - val lines = try { File(dir, SCRUBBED_TREE_MARKER).takeIf { it.isFile }?.readLines() } catch (e: Exception) { null } - ?: return emptySet() - if (lines.firstOrNull() != "rules ${LogRedactor.RULES_VERSION}") return emptySet() - return lines.drop(1).mapNotNull { line -> - val parts = line.split('\t', limit = 3) - if (parts.size != 3) return@mapNotNull null - val f = File(dir, parts[2]) - parts[2].takeIf { f.isFile && f.length().toString() == parts[0] && f.lastModified().toString() == parts[1] } - }.toSet() - } - - /** The end-of-session scrub of [dir] on its own (steam/ already in place), for tests. */ - @androidx.annotation.VisibleForTesting - internal fun scrubAndMark(dir: File) = markScrubbed(scrubTree(Record(dir))) - - /** - * [record]'s folder and every folder under it (steam/, droiddeck-esync/): the session script - * copied files there verbatim, and a Steam log the app did not copy over again (gone from the - * runtime, or past its size limit) stayed as the script left it. Files [copySteamLogs] already - * redacted on the way in are in [record] and left alone. - */ - private fun scrubTree(record: Record): Record { - record.dir.walkTopDown().filter { it.isDirectory }.forEach { scrubFolder(it, record) } - return record - } - - /** Steam's logs: redacted into steam/, never copied verbatim. What it wrote goes in [record]. */ - private fun copySteamLogs(context: Context, dir: File, record: Record) { - val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs") - if (!logs.isDirectory) return - val out = File(dir, "steam").apply { mkdirs() } - logs.listFiles { f -> f.isFile && f.length() < 8L * 1024 * 1024 }?.forEach { src -> + internal fun scrubTree(dir: File) { + SessionLogFiles.candidates(dir).forEach { file -> + if (!SessionLogFiles.safeFile(dir, file)) return@forEach + var tmp: File? = null try { - val dst = File(out, src.name) - dst.bufferedWriter().use { w -> - src.forEachLine { line -> w.write(LogRedactor.redact(line)); w.newLine() } + tmp = File.createTempFile(".redacted-", ".tmp", dir) + val size = file.length() + val modified = file.lastModified() + val limit = if (file.parentFile?.name == "steam") SessionLogFiles.STEAM_MAX_BYTES else SessionLogFiles.MAX_BYTES + tmp.bufferedWriter().use { SessionLogFiles.scrubTo(dir, file, it, limit) } + if (file.length() == size && file.lastModified() == modified) { + check(tmp.renameTo(file)) } - record.files[dst] = Scrubbed(dst.length(), dst.lastModified()) + } catch (e: Exception) { + Log.w(TAG, "could not scrub ${file.name}", e) + } finally { + tmp?.delete() + } + } + } + + private fun copySteamLogs(context: Context, dir: File) { + val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs") + if (!logs.isDirectory || java.nio.file.Files.isSymbolicLink(logs.toPath())) return + val out = File(dir, "steam") + if (java.nio.file.Files.isSymbolicLink(out.toPath()) || !(out.isDirectory || out.mkdirs())) return + SessionLogFiles.steamNames.map { File(logs, it) }.filter { it.exists() }.forEach { src -> + val tmp = File.createTempFile(".redacted-", ".tmp", out) + try { + tmp.bufferedWriter().use { SessionLogFiles.scrubTo(logs, src, it, SessionLogFiles.STEAM_MAX_BYTES) } + check(tmp.renameTo(File(out, src.name))) } catch (e: Exception) { Log.w(TAG, "could not scrub ${src.name}", e) + } finally { + tmp?.delete() } } - Log.i(TAG, "collected ${out.listFiles()?.size ?: 0} Steam log(s), scrubbed, into $out") } /** @@ -223,10 +109,10 @@ object SessionArtifacts { */ @Synchronized fun finishAbandoned(context: Context) { - val parent = LinuxRuntime.debugLogDir() - val abandoned = parent.listFiles { f -> - SessionPaths.isSessionFolder(f) && !File(f, COMPLETE_MARKER).exists() - }?.sortedWith(SessionPaths.chronological) ?: return + SessionLogMigration.run(context) + val abandoned = SessionPaths.sessionFolders(context).filter { + it.parentFile == LinuxRuntime.debugLogDir(context) && !File(it, COMPLETE_MARKER).exists() + } if (abandoned.isEmpty()) return val current = SessionPaths.current() LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context)) @@ -244,11 +130,10 @@ object SessionArtifacts { "crash.log holds Android's crash buffer as of the next app start - if this\n" + "session died of a crash, the entry is in there unless the device rebooted.\n" ) - val record = Record(dir) - if (newest) copySteamLogs(context, dir, record) + if (newest) copySteamLogs(context, dir) SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log")) SessionEvents.record("session.artifacts_recovered", mapOf("newest" to newest), dir) - markScrubbed(scrubTree(record)) + scrubTree(dir) File(dir, COMPLETE_MARKER).writeText("collected: late, at next app start, ${now()}\n") Log.i(TAG, "finished the abandoned session folder $dir") } catch (e: Exception) { @@ -268,7 +153,7 @@ object SessionArtifacts { val current = SessionPaths.current() val finished = SessionPaths.sessionFolders(context).filter { it != current && File(it, COMPLETE_MARKER).exists() } val old = finished.dropLast(SessionPaths.KEEP_SESSIONS) - old.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) } + old.forEach { SessionLogFiles.deleteTree(it) } if (old.isNotEmpty()) Log.i(TAG, "deleted ${old.size} session folder(s) past the newest ${SessionPaths.KEEP_SESSIONS}") } @@ -281,10 +166,11 @@ object SessionArtifacts { fun clearAll(context: Context): Int { val current = SessionPaths.current() val gone = SessionPaths.sessionFolders(context).filter { it != current } - gone.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) } - com.droiddeck.launcher.core.FileUtils.delete(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR)) + gone.forEach { SessionLogFiles.deleteTree(it) } + LinuxRuntime.debugLogDir(context).listFiles { f -> f.name == SessionPaths.TOOLS_DIR || f.name.matches(Regex("tools-\\d+")) } + ?.forEach { SessionLogFiles.deleteTree(it) } // Before tools/, those logs sat loose beside the session folders. - LinuxRuntime.debugLogDir().listFiles { f -> f.isFile && f.name.endsWith(".log") }?.forEach { it.delete() } + LinuxRuntime.debugLogDir(context).listFiles { f -> f.isFile && f.name.endsWith(".log") }?.forEach { it.delete() } Log.i(TAG, "cleared ${gone.size} session folder(s)") return gone.size } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index afc798db3..e02b729d2 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -349,26 +349,9 @@ object SessionFiles { indent + "}", ).joinToString(newline) - /** - * Where the session writes its log. Downloads is the point - a failed run is handed over as a - * folder rather than dug out of app-private storage - but the session script redirects its own - * output there with `exec`, and a redirection a non-interactive shell cannot open ends that - * shell. So a public directory is used only once it is proven writable; otherwise the app's - * own files directory, which is bound into the session anyway, stands in. - */ - fun logDirectory(context: Context): File { - val public = LinuxRuntime.debugLogDir() - if (public.isDirectory || public.mkdirs()) { - val probe = File(public, ".writable") - try { - if (probe.createNewFile() || probe.isFile) { - probe.delete() - return public - } - } catch (ignored: Exception) { - } + fun logDirectory(context: Context): File = + LinuxRuntime.debugLogDir(context).apply { + check(!java.nio.file.Files.isSymbolicLink(toPath())) + check(isDirectory || mkdirs()) } - Log.w(TAG, "$public is not writable (storage permission?); logging to files/logs") - return File(context.filesDir, "logs").apply { mkdirs() } - } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionLogFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionLogFiles.kt new file mode 100644 index 000000000..4231bda1f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionLogFiles.kt @@ -0,0 +1,107 @@ +package com.droiddeck.launcher.session + +import com.droiddeck.launcher.core.LogRedactor +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader +import java.io.Writer +import java.nio.channels.Channels +import java.nio.charset.CodingErrorAction +import java.nio.file.Files +import java.nio.file.LinkOption.NOFOLLOW_LINKS +import java.nio.file.StandardOpenOption.READ + +internal object SessionLogFiles { + const val MAX_BYTES = 64L * 1024 * 1024 + const val STEAM_MAX_BYTES = 8L * 1024 * 1024 + private const val MAX_LINE_CHARS = 64 * 1024 + + val steamNames = setOf( + "bootstrap_log.txt", "connection_log.txt", "console_log.txt", "content_log.txt", + "controller.txt", "compat_log.txt", "cef_log.txt", "cef_log.previous.txt", + "shader_log.txt", "systemaudiomanager.txt", "steamwebhelper.log", "webhelper.txt", + "steamui_html.txt", "steamui_system.txt", "steamui_library.txt", "steamui_login.txt", + "remote_connections.txt", "streaming_log.txt", "video.txt", "vrclient_steam.txt" + ) + private val paths = setOf( + "session.log", "session.log.1", "app.log", "crash.log", "crash-app.txt", + "wayland.log", "audio.log", "device.txt", "network.txt", "events.jsonl", + "desktop.log", "steam-desktop.log", "steam.log", "pad.log", "storage.log", + "ended-without-teardown.txt", "droiddeck-esync/launches.log", + "droiddeck-esync/tools.tsv", "droiddeck-esync/wanted.tsv", + "droiddeck-esync/prefixes.json", "droiddeck-fex/status.json", "droiddeck-fex/Config.json" + ) + steamNames.map { "steam/$it" } + + fun candidates(folder: File): List = paths.map { File(folder, it) } + .filter { Files.exists(it.toPath(), NOFOLLOW_LINKS) } + + fun deleteTree(file: File) { + if (!Files.exists(file.toPath(), NOFOLLOW_LINKS)) return + Files.walk(file.toPath()).use { stream -> + stream.iterator().asSequence().toList().asReversed().forEach { Files.deleteIfExists(it) } + } + } + + fun safeFile(root: File, file: File): Boolean { + val base = root.toPath().toAbsolutePath().normalize() + var path = file.toPath().toAbsolutePath().normalize() + if (!path.startsWith(base) || path == base) return false + while (path != base) { + if (Files.isSymbolicLink(path)) return false + path = path.parent ?: return false + } + return !Files.isSymbolicLink(base) && Files.isRegularFile(file.toPath(), NOFOLLOW_LINKS) + } + + fun scrubTo(root: File, file: File, out: Writer, maxBytes: Long = MAX_BYTES) { + if (!safeFile(root, file)) throw IOException("unsafe path") + Files.newByteChannel(file.toPath(), READ, NOFOLLOW_LINKS).use { channel -> + var remaining = channel.size() + if (remaining >= maxBytes) throw IOException("size limit") + val source = Channels.newInputStream(channel) + val snapshot = object : InputStream() { + override fun read(): Int { + val one = ByteArray(1) + return if (read(one, 0, 1) < 0) -1 else one[0].toInt() and 255 + } + + override fun read(bytes: ByteArray, offset: Int, length: Int): Int { + if (length == 0) return 0 + if (remaining == 0L) return -1 + val count = source.read(bytes, offset, minOf(length.toLong(), remaining).toInt()) + if (count < 0) throw IOException("truncated snapshot") + remaining -= count + return count + } + } + val decoder = Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + val reader = InputStreamReader(snapshot, decoder) + val line = StringBuilder() + val buffer = CharArray(4096) + fun writeLine() { + out.write(LogRedactor.redact(line.toString().removeSuffix("\r"))) + out.write("\n") + line.setLength(0) + } + while (true) { + val count = reader.read(buffer) + if (count < 0) break + for (i in 0 until count) { + val char = buffer[i] + if (char == '\u0000') throw IOException("binary data") + if (char == '\n') writeLine() else { + if (line.length >= MAX_LINE_CHARS) throw IOException("line limit") + line.append(char) + } + } + } + if (line.isNotEmpty()) { + if (channel.size() != channel.position()) out.write("\n") + else writeLine() + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionLogMigration.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionLogMigration.kt new file mode 100644 index 000000000..759aa0df0 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionLogMigration.kt @@ -0,0 +1,72 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.os.Environment +import android.util.Log +import com.droiddeck.launcher.runtime.LinuxRuntime +import java.io.File +import java.nio.file.Files +import java.nio.file.LinkOption.NOFOLLOW_LINKS + +internal object SessionLogMigration { + fun run(context: Context) { + val old = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), LinuxRuntime.DEBUG_LOG_DIR) + val target = LinuxRuntime.debugLogDir(context) + if (!old.isDirectory || Files.isSymbolicLink(old.toPath())) return + check(target.isDirectory || target.mkdirs()) + old.listFiles().orEmpty().filter { + SessionPaths.isSessionFolder(it) || it.name == SessionPaths.TOOLS_DIR || + (it.isFile && it.name.endsWith(".log")) + }.forEach { source -> + try { + move(source, target) + } catch (e: Exception) { + Log.w("SessionLogMigration", "could not move legacy logs into private storage", e) + } + } + } + + internal fun move(source: File, target: File) { + check(!Files.isSymbolicLink(target.toPath())) + val paths = Files.walk(source.toPath()).use { it.iterator().asSequence().toList() } + check(paths.all { !Files.isSymbolicLink(it) && + (Files.isRegularFile(it, NOFOLLOW_LINKS) || Files.isDirectory(it, NOFOLLOW_LINKS)) }) + var destination = File(target, source.name) + var number = 1 + val stem = Regex("^session-\\d{8}-\\d{6}").find(source.name)?.value ?: source.name + while (Files.exists(destination.toPath(), NOFOLLOW_LINKS)) { + val name = if (source.isFile) "${source.nameWithoutExtension}-${number++}.${source.extension}" + else "$stem-${number++}" + destination = File(target, name) + } + if (source.renameTo(destination)) return + val stage = File.createTempFile(".migrating-", ".tmp", target) + val versions = paths.associate { it.toFile().let { file -> file to (file.length() to file.lastModified()) } } + try { + if (source.isDirectory) { check(stage.delete()); check(stage.mkdir()) } + paths.forEach { path -> + val file = path.toFile() + val out = if (file == source) stage else File(stage, file.relativeTo(source).path) + if (Files.isDirectory(path, NOFOLLOW_LINKS)) { + check(out.isDirectory || out.mkdirs()) + } else { + check(file == source || SessionLogFiles.safeFile(source, file)) + Files.newByteChannel(path, java.nio.file.StandardOpenOption.READ, NOFOLLOW_LINKS).use { input -> + out.outputStream().use { output -> java.nio.channels.Channels.newInputStream(input).copyTo(output) } + } + check(out.length() == versions.getValue(file).first) + } + } + check(versions.all { (file, version) -> file.length() == version.first && file.lastModified() == version.second }) + paths.asReversed().forEach { path -> + val file = path.toFile() + val out = if (file == source) stage else File(stage, file.relativeTo(source).path) + check(out.setLastModified(versions.getValue(file).second)) + } + check(stage.renameTo(destination)) + paths.asReversed().forEach { Files.delete(it) } + } finally { + stage.deleteRecursively() + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt index 10bfd3b04..db5c65660 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt @@ -17,11 +17,6 @@ import java.io.File import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream -/** - * Zips the most recent session's log folder and hands it to Android's share sheet. Works where - * the folder could not be written to Downloads (no storage permission, or a device that refuses - * it) and landed in the app's private files instead, which a user cannot otherwise reach. - */ object SessionLogShare { private val main = Handler(Looper.getMainLooper()) @@ -51,71 +46,57 @@ object SessionLogShare { /** The newest session folder in either place logs are written, or null if there is none. */ fun latest(context: Context): File? = SessionPaths.sessionFolders(context).lastOrNull() - /** Builds the zip (blocking). Returns null when there is no session to share. */ - - /** Builds a zip for one specific session folder (blocking). */ + @Synchronized fun zipFolder(context: Context, folder: File, onProgress: ((Float) -> Unit)? = null): File? { - if (!folder.isDirectory) return null - // The client's logs past STEAM_LOG_MAX_BYTES stay out, as they do from the app's own copy: a - // CEF debug log or an old *.previous.txt reached 17 MB, every share scrubbed them line by - // line, and the share sheet took ten seconds to appear. - val files = folder.walkTopDown().filter { it.isFile } - .filterNot { it.parentFile?.name == "steam" && it.length() > STEAM_LOG_MAX_BYTES }.toList() - if (files.isEmpty()) return null - val out = File(context.cacheDir, "shared-logs").apply { deleteRecursively(); mkdirs() } - val zip = File(out, "DroidDeck-${folder.name}.zip") - // Scrubbed on the way into the zip: a session shared while it runs has not had its end-of- - // session pass yet, and the redactor changes nothing in a line that is already clean. + if (!folder.isDirectory || java.nio.file.Files.isSymbolicLink(folder.toPath())) return null + val files = SessionLogFiles.candidates(folder).map { Triple(folder, it, it.relativeTo(folder).invariantSeparatorsPath) } + val liveRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs") + val live = if (folder == SessionPaths.current()) SessionLogFiles.steamNames + .filter { name -> files.none { it.third == "steam/$name" } } + .map { File(liveRoot, it) }.filter { java.nio.file.Files.exists(it.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS) } + .map { Triple(liveRoot, it, "steam/${it.name}") } else emptyList() + val sources = files + live + if (sources.isEmpty()) return null + val out = File(context.cacheDir, "shared-logs").apply { SessionLogFiles.deleteTree(this); check(mkdirs()) } + val name = folder.name.replace(Regex("[^A-Za-z0-9._-]"), "_") + val zip = File(out, "DroidDeck-$name.zip") + val partial = File(out, ".export") + val stage = File.createTempFile("redacted-", ".tmp", context.cacheDir) + val omitted = ArrayList() LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context)) - // A finished folder was scrubbed whole when it ended. A file its record lists, unchanged - // since, goes in as it is; anything else (added or changed since, hidden, or any file of a - // session still running) is scrubbed again. Scrubbing tens of MB of the client's logs on - // every share kept the share sheet from appearing for ten seconds or more. - val scrubbed = SessionArtifacts.scrubbedFiles(folder) - val live = liveSteamLogs(context, folder) - val total = (files + live).sumOf { it.length() }.coerceAtLeast(1L) - var done = 0L - var shown = -1 - fun advance(f: File) { - done += f.length() - val percent = (done * 100 / total).toInt() - if (percent != shown) { shown = percent; onProgress?.invoke(percent / 100f) } + try { + ZipOutputStream(partial.outputStream().buffered()).use { z -> + sources.forEachIndexed { index, (root, src, relative) -> + val limit = if (relative.startsWith("steam/")) SessionLogFiles.STEAM_MAX_BYTES else SessionLogFiles.MAX_BYTES + val failure = runCatching { + stage.bufferedWriter().use { SessionLogFiles.scrubTo(root, src, it, limit) } + }.exceptionOrNull() + if (failure == null) { + z.putNextEntry(ZipEntry("$name/$relative")) + stage.inputStream().use { it.copyTo(z) } + z.closeEntry() + } else { + omitted += "$relative: omitted because it could not be safely sanitized." + } + onProgress?.invoke((index + 1).toFloat() / sources.size) + } + if (omitted.isNotEmpty()) { + z.putNextEntry(ZipEntry("$name/omitted.txt")) + z.write((omitted.joinToString("\n") + "\n").toByteArray(Charsets.UTF_8)) + z.closeEntry() + } + } + check(partial.renameTo(zip)) + return zip + } catch (e: Exception) { + partial.delete() + zip.delete() + throw e + } finally { + stage.delete() } - ZipOutputStream(zip.outputStream().buffered()).use { z -> - files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f, f.relativeTo(folder).path in scrubbed); advance(f) } - live.forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f, false); advance(f) } - } - return zip } - private fun addEntry(z: ZipOutputStream, name: String, f: File, scrubbed: Boolean) { - z.putNextEntry(ZipEntry(name)) - if (!scrubbed && LogRedactor.isText(f)) { - val w = z.bufferedWriter() - LogRedactor.scrubTo(f, w) - w.flush() - } else { - f.inputStream().use { it.copyTo(z) } - } - z.closeEntry() - } - - /** - * The client's own logs as they stand, for a session shared while it runs. The session script - * copies them into steam/ only as it exits, so a zip made from the drawer had none - and - * controller.txt (which pad the client opened, the touch mode it set) is what a controller or - * touch report needs most. Only for the running session: an older folder would get this - * session's logs. The same files the script copies; nothing holding credentials is in logs/. - */ - private fun liveSteamLogs(context: Context, folder: File): List { - if (folder != SessionPaths.current() || File(folder, "steam").exists()) return emptyList() - val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs") - return logs.listFiles()?.filter { it.isFile && it.length() <= STEAM_LOG_MAX_BYTES }.orEmpty() - } - - /** The client's content and bootstrap logs grow large over months and say nothing about a session. */ - private const val STEAM_LOG_MAX_BYTES = 8L * 1024 * 1024 - fun shareIntent(context: Context, zip: File): Intent { val uri = FileProvider.getUriForFile(context, context.packageName + ".logs", zip) val send = Intent(Intent.ACTION_SEND) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt index 896d08a19..5cc9a526e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt @@ -7,29 +7,6 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -/** - * One folder per session under `Download/DroidDeck/`, holding everything that session recorded, - * named for the day, that day's session number and what was run, so the one a report is about can - * be picked out without opening any: - * - * ``` - * Download/DroidDeck/2026-09-30-03-steam/ - * device.txt what this device is, and every setting the session ran with - * session.log the guest session: proot, gamescope, the client's stdout - * wayland.log the app's compositor - * steam.log the Steam client's own log, scrubbed (Steam mode) - * steam/ the rest of the client's logs, scrubbed (Steam mode) - * desktop.log labwc, the panel and the programs on it (desktop mode) - * Download/DroidDeck/tools/ one-off commands: Flatpak installs, AppImage imports - * ``` - * - * Folders from before this naming (`session-20260930-180642`) are still recognised, shared and - * pruned with the rest. - * - * The folder is claimed by whoever starts first - the activity starts the compositor before the - * service starts the session - so both write into the same one, and a recreated activity (a - * foldable opening mid-session) joins the folder in progress instead of opening another. - */ object SessionPaths { private const val TAG = "SessionPaths" @@ -50,12 +27,6 @@ object SessionPaths { @Volatile private var ended: File? = null - /** - * The folder for the session now starting, or the one already in progress. With logs turned - * off on the main screen the folder lives in the app's cache instead of Downloads - the - * scripts and the compositor still need somewhere to write - and [release] deletes it. - * [label] says what is being run (see [label]); without one it comes from the session's state. - */ @Synchronized fun beginOrCurrent(context: Context, label: String? = null): File { dir?.let { if (it.isDirectory) return it } @@ -104,8 +75,8 @@ object SessionPaths { // next session may have claimed its own by the time the last one's collector gets here. if (dir == ended) dir = null // A folder kept in the cache was never meant to outlive its session. - if (ended != null && ended.path.startsWith(context.cacheDir.path)) { - com.droiddeck.launcher.core.FileUtils.delete(ended) + if (ended.parentFile == File(context.cacheDir, "session-logs")) { + SessionLogFiles.deleteTree(ended) } } @@ -123,22 +94,28 @@ object SessionPaths { /** Whether [f] is a session's folder, in either naming. */ fun isSessionFolder(f: File): Boolean = - f.isDirectory && (NAMED.matches(f.name) || LEGACY.matches(f.name)) + f.isDirectory && !java.nio.file.Files.isSymbolicLink(f.toPath()) && + (NAMED.matches(f.name) || LEGACY.matches(f.name)) /** * Oldest first: by day, then by when in the day. A folder from before the new naming sorts * ahead of a new one from the same day, which it always predates. */ - val chronological: Comparator = compareBy { f -> + val chronological: Comparator = compareBy { f -> NAMED.matchEntire(f.name)?.groupValues?.let { g -> "${g[1]}${g[2]}${g[3]}1${g[4].padStart(6, '0')}" } ?: LEGACY.matchEntire(f.name)?.groupValues?.let { g -> "${g[1]}0${g[2]}${g[3].removePrefix("-").padStart(2, '0')}" } ?: f.name - } + }.thenBy { it.lastModified() } /** Every session folder in the places logs are written, oldest first. */ fun sessionFolders(context: Context): List = - listOf(com.droiddeck.launcher.runtime.LinuxRuntime.debugLogDir(), File(context.filesDir, "logs")) - .flatMap { parent -> parent.listFiles { f -> isSessionFolder(f) }?.toList() ?: emptyList() } + listOf(com.droiddeck.launcher.runtime.LinuxRuntime.debugLogDir(context), + File(android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS), + com.droiddeck.launcher.runtime.LinuxRuntime.DEBUG_LOG_DIR)) + .flatMap { parent -> + if (java.nio.file.Files.isSymbolicLink(parent.toPath())) emptyList() + else parent.listFiles { f -> isSessionFolder(f) }?.toList() ?: emptyList() + } .sortedWith(chronological) internal fun slug(text: String): String = diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 96d02cee6..6c81b2a2c 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -277,9 +277,6 @@ class SessionService : Service() { try { SessionArtifacts.collect(this, dir, "session stopped") } finally { - // Last, so everything above is in the file it is about - and only this session's: - // a session that replaced this one may already own the capture and the folder. - SessionLogCapture.stopFor(dir) SessionPaths.release(this, dir) } } diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 797d3e358..97bad0d21 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -640,7 +640,7 @@ Elegir Apps de Linux (beta) Tienda Flathub - En el menú. Algunas apps pueden no iniciarse; los registros están en Download/DroidDeck. + En el menú. Algunas apps pueden no iniciarse. Instala apps y juegos de Linux desde Flathub Archivos Explorar y gestionar archivos diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 61e76e8f7..670378ced 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -534,7 +534,7 @@ Choisir Applications Linux (bêta) Boutique Flathub - Affiché dans le menu. Certaines applications peuvent ne pas se lancer ; les journaux se trouvent dans Download/DroidDeck. + Affiché dans le menu. Certaines applications peuvent ne pas se lancer. Installer des applications et des jeux Linux depuis Flathub Fichiers Parcourir et gérer les fichiers diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 12f3435ab..23d1f4841 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -638,7 +638,7 @@ 選択 Linuxアプリ(ベータ版) Flathubストア - メニューに表示されます。一部のアプリは起動できない場合があります。ログはDownload/DroidDeckにあります。 + メニューに表示されます。一部のアプリは起動できない場合があります。 FlathubからLinuxアプリとゲームをインストール ファイル ファイルの閲覧と管理 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ee6c278dc..4e703b3f1 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -638,7 +638,7 @@ 선택 Linux 앱(베타) Flathub 스토어 - 메뉴에 표시됩니다. 일부 앱은 실행되지 않을 수 있습니다. 로그는 Download/DroidDeck에 있습니다. + 메뉴에 표시됩니다. 일부 앱은 실행되지 않을 수 있습니다. Flathub에서 Linux 앱 및 게임 설치 파일 파일 탐색 및 관리 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index e0a1a5b04..a1fc2f5ec 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -536,7 +536,7 @@ Выбрать Приложения Linux (бета) Магазин Flathub - Показывается в меню. Некоторые приложения могут не запускаться; журналы в Download/DroidDeck. + Показывается в меню. Некоторые приложения могут не запускаться. Устанавливать приложения и игры Linux из Flathub Файлы Просмотр и управление файлами diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 6139fecb8..e784e6f81 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -638,7 +638,7 @@ 选择 Linux 应用(测试版) Flathub 商店 - 显示在菜单中。部分应用可能无法启动;日志位于 Download/DroidDeck。 + 显示在菜单中。部分应用可能无法启动。 从 Flathub 安装 Linux 应用和游戏 文件 浏览和管理文件 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 6fda24b77..6c8f9519a 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -638,7 +638,7 @@ 選擇 Linux 應用程式(測試版) Flathub 商店 - 顯示在選單中。部分應用程式可能無法啟動;記錄位於 Download/DroidDeck。 + 顯示在選單中。部分應用程式可能無法啟動。 從 Flathub 安裝 Linux 應用程式和遊戲 檔案 瀏覽及管理檔案 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 6af7eb6cf..55bd98848 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -638,7 +638,7 @@ 選擇 Linux 應用程式(測試版) Flathub 商店 - 顯示在選單中。部分應用程式可能無法啟動;日誌位於 Download/DroidDeck。 + 顯示在選單中。部分應用程式可能無法啟動。 從 Flathub 安裝 Linux 應用程式和遊戲 檔案 瀏覽和管理檔案 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 71799ae61..2b169810e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -643,7 +643,7 @@ Choose Linux apps (beta) Flathub Store - In the menu. Some apps may not start; logs are in Download/DroidDeck. + In the menu. Some apps may not start. Install Linux apps and games from Flathub Files Browse and manage files diff --git a/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt b/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt index 88f2a8703..542fedec0 100644 --- a/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt @@ -55,4 +55,37 @@ class LogRedactorTest { assertEquals(once, LogRedactor.redact(once)) } } + @Test fun quotedAndShortSecretsAreRedacted() { + for (line in listOf("\"access_token\": \"short-secret\"", "\"password\"=\"abc\"", "pwd=x")) { + val out = LogRedactor.redact(line) + assertTrue(out.contains("")) + assertEquals(out, LogRedactor.redact(out)) + } + } + + @Test fun authorizationHeadersAreRedacted() { + for (line in listOf("Authorization: Bearer secret-value", "Proxy-Authorization: Basic dXNlcjpwYXNz", + "\"Authorization\":\"Bearer secret-value\"")) { + val out = LogRedactor.redact(line) + assertFalse(out.contains("secret-value")) + assertFalse(out.contains("dXNlcjpwYXNz")) + assertTrue(out.contains("")) + assertEquals(out, LogRedactor.redact(out)) + } + } + + @Test fun quotedSecretsWithSpacesAndEscapedQuotesAreFullyWithheld() { + val lines = listOf( + "password=\"my secret phrase\"", + "password='my secret phrase'", + "\"access_token\":\"my \\\"secret\\\" phrase\"", + ) + for (line in lines) { + val out = LogRedactor.redact(line) + assertFalse(out.contains("phrase")) + assertFalse(out.contains("secret")) + assertTrue(out.contains("")) + assertEquals(out, LogRedactor.redact(out)) + } + } } diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionLogMigrationTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionLogMigrationTest.kt new file mode 100644 index 000000000..0f216f281 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionLogMigrationTest.kt @@ -0,0 +1,100 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.file.Files + +class SessionLogMigrationTest { + @get:Rule val tmp = TemporaryFolder() + + private fun source(name: String = "2026-10-06-01-steam"): File = tmp.newFolder(name).apply { + File(this, "steam").mkdir() + File(this, "session.log").writeText("Using JWT 25484942796017334\n") + File(this, "steam/console_log.txt").writeText("password=private\n") + File(this, ".complete").writeText("done") + } + + @Test fun legacySessionsMoveIntoPrivateStorage() { + val source = source() + val target = tmp.newFolder("private") + SessionLogMigration.move(source, target) + assertFalse(source.exists()) + val moved = File(target, source.name) + assertTrue(SessionPaths.isSessionFolder(moved)) + assertTrue(File(moved, "steam/console_log.txt").readText().contains("password=private")) + assertTrue(File(moved, ".complete").isFile) + } + + @Test fun crossFilesystemFallbackCopiesEverythingBeforeRemovingTheOriginal() { + val original = source() + val source = object : File(original.path) { + override fun renameTo(dest: File): Boolean = false + } + val target = tmp.newFolder("private") + SessionLogMigration.move(source, target) + assertFalse(original.exists()) + assertEquals("Using JWT 25484942796017334\n", File(target, "${original.name}/session.log").readText()) + assertEquals(listOf(original.name), target.list()!!.toList()) + } + + @Test fun aCopyFailurePreservesAllOriginalFiles() { + val original = source() + val source = object : File(original.path) { + override fun renameTo(dest: File): Boolean = false + } + val target = tmp.newFolder("private") + target.setWritable(false) + try { + assertThrows(java.io.IOException::class.java) { SessionLogMigration.move(source, target) } + } finally { + target.setWritable(true) + } + assertTrue(File(original, "session.log").isFile) + assertTrue(File(original, "steam/console_log.txt").isFile) + assertTrue(target.list()!!.isEmpty()) + } + + @Test fun collisionsPreserveBothSessionsAndKeepLegacyNamesRecognizable() { + val original = source("session-20260930-180642-2") + val target = tmp.newFolder("private") + File(target, original.name).mkdir() + SessionLogMigration.move(original, target) + assertEquals(2, target.listFiles()!!.size) + assertTrue(target.listFiles()!!.all { SessionPaths.isSessionFolder(it) }) + } + + @Test fun linksAreRejectedWithoutReadingOrDeletingTheirTargets() { + val source = source() + val outside = tmp.newFile("outside").apply { writeText("outside data") } + Files.createSymbolicLink(File(source, "steam/connection_log.txt").toPath(), outside.toPath()) + val target = tmp.newFolder("private") + assertThrows(IllegalStateException::class.java) { SessionLogMigration.move(source, target) } + assertTrue(File(source, "session.log").exists()) + assertTrue(target.list()!!.isEmpty()) + assertEquals("outside data", outside.readText()) + } + + @Test fun cleanupDoesNotFollowNestedDirectoryLinks() { + val source = source() + val outside = tmp.newFolder("outside") + val preserved = File(outside, "session.log").apply { writeText("outside data") } + Files.createSymbolicLink(File(source, "linked").toPath(), outside.toPath()) + SessionLogFiles.deleteTree(source) + assertFalse(source.exists()) + assertEquals("outside data", preserved.readText()) + } + @Test fun aCopiedCollisionKeepsTheNewerSessionAsTheLatest() { + val original = source() + original.setLastModified(1000L) + val source = object : File(original.path) { + override fun renameTo(dest: File): Boolean = false + } + val target = tmp.newFolder("private") + val newer = File(target, original.name).apply { mkdir(); setLastModified(2000L) } + SessionLogMigration.move(source, target) + assertEquals(newer, target.listFiles()!!.sortedWith(SessionPaths.chronological).last()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareTest.kt index ea9250520..70849befe 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareTest.kt @@ -1,9 +1,7 @@ package com.droiddeck.launcher.session -import com.droiddeck.launcher.core.LogRedactor -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue +import com.droiddeck.launcher.runtime.LinuxRuntime +import org.junit.Assert.* import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder @@ -12,114 +10,197 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config import java.io.File +import java.io.RandomAccessFile +import java.io.StringWriter +import java.io.Writer +import java.nio.file.Files import java.util.zip.ZipFile @RunWith(RobolectricTestRunner::class) @Config(sdk = [28], manifest = Config.NONE) class SessionLogShareTest { @get:Rule val tmp = TemporaryFolder() + private val context get() = RuntimeEnvironment.getApplication() + private val secret = "Using JWT 25484942796017334 sessionid=abcdef123456 password=secret123" + private val leaked = listOf("25484942796017334", "abcdef123456", "secret123") - private val secret = "GET /x?sessionid=abcdef123456 200" - private val leaked = "abcdef123456" + private fun write(folder: File, path: String, text: String = "$secret\n"): File = + File(folder, path).apply { parentFile!!.mkdirs(); writeText(text) } private fun zip(folder: File): Map = - ZipFile(SessionLogShare.zipFolder(RuntimeEnvironment.getApplication(), folder)!!).use { z -> + ZipFile(SessionLogShare.zipFolder(context, folder)!!).use { z -> z.entries().toList().associate { it.name.substringAfter('/') to z.getInputStream(it).bufferedReader().readText() } } - /** A record as the session's ending writes it, listing [files] as they stand now. */ - private fun record(folder: File, vararg files: String, rules: Int = LogRedactor.RULES_VERSION) { - File(folder, SessionArtifacts.SCRUBBED_TREE_MARKER).writeText( - "rules $rules\n" + files.joinToString("") { val f = File(folder, it); "${f.length()}\t${f.lastModified()}\t$it\n" }) + private fun assertSafe(files: Map) { + files.forEach { (name, text) -> leaked.forEach { assertFalse(name, text.contains(it)) } } } - @Test fun aRecordedUnchangedFileGoesInAsItIs() { + @Test fun workingLogsAndCommandLogsUsePrivateStorage() { + assertEquals(File(context.filesDir, "logs"), SessionFiles.logDirectory(context)) + assertEquals(SessionFiles.logDirectory(context), LinuxRuntime.debugLogDir(context)) + assertTrue(File(LinuxRuntime.debugLogDir(context), SessionPaths.TOOLS_DIR).path.startsWith(context.filesDir.path + "/")) + val dir = SessionPaths.beginOrCurrent(context, "privacy-test") + try { + assertTrue(dir.path.startsWith(context.filesDir.path + "/") || dir.path.startsWith(context.cacheDir.path + "/")) + } finally { + SessionPaths.take() + SessionPaths.release(context, dir) + dir.deleteRecursively() + } + } + + @Test fun forgedAndStaleScrubRecordsNeverBypassRedaction() { val folder = tmp.newFolder("2026-10-06-01-steam") - // Stands in for a file the ending scrubbed; the share must not pass it through again. - File(folder, "session.log").writeText("$secret\n") - record(folder, "session.log") - assertTrue(zip(folder).getValue("session.log").contains(leaked)) + val file = write(folder, "session.log") + write(folder, ".scrubbed-tree", "rules 1\n${file.length()}\t${file.lastModified()}\tsession.log\n") + write(folder, ".complete", "done") + assertSafe(zip(folder)) + val modified = file.lastModified() + file.writeText("$secret\n") + file.setLastModified(modified) + assertSafe(zip(folder)) + assertEquals(setOf("session.log"), zip(folder).keys) } - @Test fun anythingTheRecordDoesNotVouchForIsScrubbed() { + @Test fun allowlistIncludesDiagnosticsAndExcludesCredentialStoresAndUnknownFiles() { val folder = tmp.newFolder("2026-10-06-02-steam") - File(folder, "app.log").writeText("ok\n") - File(folder, "session.log").writeText("ok\n") - File(folder, "steam/deep").mkdirs() - File(folder, "steam/deep/nested.txt").writeText("$secret\n") - File(folder, ".hidden.txt").writeText("$secret\n") - record(folder, "app.log", "session.log") - // Appended to after the ending (the app's log capture), and touched with an older time. - File(folder, "app.log").appendText("$secret\n") - File(folder, "session.log").apply { writeText("$secret\n".padEnd(3, ' ')); setLastModified(1_000L) } - File(folder, "events.jsonl").writeText("$secret\n") - - val out = zip(folder) - for (name in listOf("app.log", "session.log", "steam/deep/nested.txt", ".hidden.txt", "events.jsonl")) - assertFalse(name, out.getValue(name).contains(leaked)) + val allowed = listOf("session.log", "app.log", "crash-app.txt", "events.jsonl", + "steam/console_log.txt", "droiddeck-esync/launches.log", "droiddeck-fex/status.json") + val excluded = listOf(".hidden.txt", ".session.log.scrub", "steam/deep/nested.txt", + "steam/loginusers.vdf", "steam/config.vdf", "ssfn123", "arbitrary.txt", + "steam/token.txt", "droiddeck-fex/config/secrets.json") + (allowed + excluded).forEach { write(folder, it) } + val files = zip(folder) + assertEquals(allowed.toSet(), files.keys) + assertSafe(files) } - @Test fun aRecordUnderOtherRedactorRulesVouchesForNothing() { - val folder = tmp.newFolder("2026-10-06-03-steam") - File(folder, "session.log").writeText("$secret\n") - record(folder, "session.log", rules = LogRedactor.RULES_VERSION - 1) - assertFalse(zip(folder).getValue("session.log").contains(leaked)) + @Test fun oversizedLiveAndFinishedLogsAreOmittedWithoutRawFallback() { + for (finished in listOf(false, true)) for (size in listOf(SessionLogFiles.MAX_BYTES, SessionLogFiles.MAX_BYTES + 1)) { + val folder = tmp.newFolder("2026-10-06-${if (finished) "03" else "04"}-steam-$size") + write(folder, "app.log") + val large = write(folder, "session.log") + RandomAccessFile(large, "rw").use { it.setLength(size) } + if (finished) write(folder, ".complete", "done") + val files = zip(folder) + assertFalse(files.containsKey("session.log")) + assertTrue(files.getValue("omitted.txt").contains("session.log")) + assertSafe(files) + } } - @Test fun withoutARecordEveryFileIsScrubbed() { - val folder = tmp.newFolder("2026-10-06-04-steam") - File(folder, "session.log").writeText("$secret\n") - File(folder, "steam").mkdirs() - File(folder, "steam/console_log.txt").writeText("$secret\n") - val out = zip(folder) - assertFalse(out.getValue("session.log").contains(leaked)) - assertFalse(out.getValue("steam/console_log.txt").contains(leaked)) - } - - @Test fun theEndingScrubsEveryFolderAndRecordsWhatItScrubbed() { + @Test fun binaryDataPastTheProbeMalformedUtf8AndLongLinesAreOmitted() { val folder = tmp.newFolder("2026-10-06-05-steam") - File(folder, "session.log").writeText("$secret\n") - File(folder, "droiddeck-esync").mkdirs() - File(folder, "droiddeck-esync/launches.log").writeText("$secret\n") - File(folder, "steam/deep").mkdirs() - File(folder, "steam/deep/nested.txt").writeText("$secret\n") - - SessionArtifacts.scrubAndMark(folder) - - for (name in listOf("session.log", "droiddeck-esync/launches.log", "steam/deep/nested.txt")) - assertFalse(name, File(folder, name).readText().contains(leaked)) - assertEquals(setOf("session.log", "droiddeck-esync/launches.log", "steam/deep/nested.txt"), - SessionArtifacts.scrubbedFiles(folder)) - assertTrue(zip(folder).values.none { it.contains(leaked) }) + write(folder, "session.log", "safe\n".repeat(2000) + "$secret\n\u0000") + File(folder, "app.log").writeBytes(byteArrayOf(0xc3.toByte(), 0x28)) + write(folder, "crash.log", "x".repeat(65537) + secret) + val files = zip(folder) + assertEquals(setOf("omitted.txt"), files.keys) + assertSafe(files) } - @Test fun aFileThatCouldNotBeScrubbedLeavesTheFolderUnrecorded() { + @Test fun symlinkFilesAndDirectoriesCannotExportOutsideData() { val folder = tmp.newFolder("2026-10-06-06-steam") - File(folder, "session.log").writeText("$secret\n") - // A folder the scrubbed copy cannot be written into: the scrub fails, the original stays. + val outside = tmp.newFolder("outside") + val target = write(outside, "console_log.txt", "outside data\n") + Files.createSymbolicLink(File(folder, "session.log").toPath(), target.toPath()) + Files.createSymbolicLink(File(folder, "steam").toPath(), outside.toPath()) + val files = zip(folder) + assertEquals(setOf("omitted.txt"), files.keys) + assertTrue(files.values.none { it.contains("outside data") }) + val alias = File(tmp.root, "2026-10-06-07-steam") + Files.createSymbolicLink(alias.toPath(), folder.toPath()) + assertFalse(SessionPaths.isSessionFolder(alias)) + assertNull(SessionLogShare.zipFolder(context, alias)) + } + + @Test fun failedTeardownScrubbingStillRedactsExports() { + val folder = tmp.newFolder("2026-10-06-08-steam") + val file = write(folder, "session.log") folder.setWritable(false) try { - SessionArtifacts.scrubAndMark(folder) + SessionArtifacts.scrubTree(folder) } finally { folder.setWritable(true) } - assertTrue(File(folder, "session.log").readText().contains(leaked)) - assertFalse(File(folder, SessionArtifacts.SCRUBBED_TREE_MARKER).exists()) - assertTrue(SessionArtifacts.scrubbedFiles(folder).isEmpty()) - assertTrue(zip(folder).values.none { it.contains(leaked) }) + assertTrue(file.readText().contains(leaked.first())) + assertSafe(zip(folder)) } - @Test fun steamLogsPastTheSizeLimitStayOut() { - val folder = tmp.newFolder("2026-10-06-07-steam") - File(folder, "session.log").writeText("ok\n") - File(folder, "steam").mkdirs() - File(folder, "steam/console_log.txt").writeText("ok\n") - File(folder, "steam/cef_log.previous.txt").outputStream().use { out -> - val line = ByteArray(1024) { 'x'.code.toByte() }.also { it[it.size - 1] = '\n'.code.toByte() } - repeat(9 * 1024) { out.write(line) } + @Test fun unreadableAllowedFilesAreOmitted() { + val folder = tmp.newFolder("2026-10-06-09-steam") + val file = write(folder, "session.log") + file.setReadable(false) + try { + val files = zip(folder) + assertEquals(setOf("omitted.txt"), files.keys) + assertSafe(files) + } finally { + file.setReadable(true) } - val names = zip(folder).keys - assertTrue("steam/console_log.txt" in names) - assertFalse("steam/cef_log.previous.txt" in names) + } + + @Test fun successfulTeardownScrubsNestedDiagnosticsWithoutRecords() { + val folder = tmp.newFolder("2026-10-06-10-steam") + val paths = listOf("session.log", "crash-app.txt", "droiddeck-esync/launches.log", "steam/console_log.txt") + paths.forEach { write(folder, it) } + SessionArtifacts.scrubTree(folder) + paths.forEach { path -> leaked.forEach { assertFalse(File(folder, path).readText().contains(it)) } } + assertSafe(zip(folder)) + assertTrue(folder.walkTopDown().none { it.name.startsWith(".redacted-") }) + } + + @Test fun steamLogsAtTheSizeLimitStayOut() { + val folder = tmp.newFolder("2026-10-06-11-steam") + write(folder, "session.log") + val file = write(folder, "steam/console_log.txt") + RandomAccessFile(file, "rw").use { it.setLength(SessionLogFiles.STEAM_MAX_BYTES) } + val files = zip(folder) + assertFalse(files.containsKey("steam/console_log.txt")) + assertTrue(files.containsKey("omitted.txt")) + assertSafe(files) + } + + @Test fun aGrowingFileIsLimitedToItsOpeningSnapshot() { + val folder = tmp.newFolder("2026-10-06-12-steam") + val file = write(folder, "session.log", "safe\n".repeat(2000) + "$secret\nunfinished") + val output = StringWriter() + var appended = false + val writer = object : Writer() { + override fun write(chars: CharArray, offset: Int, length: Int) { + if (!appended) { appended = true; file.appendText("appended-marker $secret\n") } + output.write(chars, offset, length) + } + override fun flush() = Unit + override fun close() = Unit + } + SessionLogFiles.scrubTo(folder, file, writer) + assertFalse(output.toString().contains("appended-marker")) + assertFalse(output.toString().contains("unfinished")) + assertTrue(output.toString().contains("")) + leaked.forEach { assertFalse(output.toString().contains(it)) } + } + + @Test fun aTruncatedFileFailsInsteadOfExportingAPartialSnapshot() { + val folder = tmp.newFolder("2026-10-06-13-steam") + val file = write(folder, "session.log", "safe\n".repeat(20000)) + val writer = object : Writer() { + override fun write(chars: CharArray, offset: Int, length: Int) { file.writeText("") } + override fun flush() = Unit + override fun close() = Unit + } + assertThrows(java.io.IOException::class.java) { SessionLogFiles.scrubTo(folder, file, writer) } + } + + @Test fun anOutputFailureDoesNotLeaveAShareableArchive() { + val folder = tmp.newFolder("2026-10-06-14-steam") + write(folder, "session.log") + assertThrows(IllegalStateException::class.java) { + SessionLogShare.zipFolder(context, folder) { throw IllegalStateException("failed output") } + } + val out = File(context.cacheDir, "shared-logs") + assertTrue(out.listFiles().orEmpty().isEmpty()) + assertTrue(context.cacheDir.listFiles().orEmpty().none { it.name.startsWith("redacted-") }) } } diff --git a/docs/agent-control.md b/docs/agent-control.md index 77e4ce583..fdd793a60 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -28,7 +28,7 @@ tools/droiddeckctl start desktop tools/droiddeckctl run /usr/bin/foo -- arg1 arg2 tools/droiddeckctl stop tools/droiddeckctl resume -tools/droiddeckctl logs latest ./session-artifacts +tools/droiddeckctl logs latest ./session-logs.zip tools/droiddeckctl screenshot ./screen.png ``` @@ -39,3 +39,5 @@ Every command writes JSON to stdout and reports its resolved ADB serial to stder The `state` response uses schema 1 and includes the build label, runtime version, session ID and phase, mode, requested program, suspend and first-frame state, output size, guest PID, failure details, and artifact paths. Phases are `IDLE`, `PREPARING`, `INSTALLING_RUNTIME`, `STARTING_COMPOSITOR`, `STARTING_GUEST`, `STARTING_STEAM`, `READY`, `SUSPENDED`, `STOPPING`, and `FAILED`. Each session also writes `events.jsonl` alongside its existing artifacts. `tools/deploy_local.sh` uses the same device resolver. If multiple distinct devices are connected, select one with `ADB_SERIAL` or `ANDROID_SERIAL`. + +`logs latest` exports the same sanitized ZIP as Share logs. Working logs stay in app-private storage; the command retrieves the ZIP through `run-as` on debug builds. diff --git a/tools/droiddeckctl b/tools/droiddeckctl index b73c5ac29..00277ef21 100755 --- a/tools/droiddeckctl +++ b/tools/droiddeckctl @@ -71,14 +71,14 @@ def _content_result(output: str) -> dict: raise ControlError("Could not decode the agent provider response", "INVALID_PROVIDER_RESPONSE") from exc -def provider_call(adb: str, serial: str, method: str, arg: str | None = None, request: dict | None = None) -> dict: +def provider_call(adb: str, serial: str, method: str, arg: str | None = None, request: dict | None = None, timeout: int = 30) -> dict: command = ["shell", "content", "call", "--uri", AUTHORITY, "--method", method] if arg is not None: command.extend(["--arg", arg]) if request is not None: encoded = base64.b64encode(json.dumps(request, separators=(",", ":")).encode()).decode() command.extend(["--extra", f"request:s:{encoded}"]) - result = run_adb(adb, serial, *command) + result = run_adb(adb, serial, *command, timeout=timeout) output = (result.stdout + result.stderr).decode(errors="replace") response = _content_result(output) if result.returncode != 0 and response.get("ok") is not False: @@ -248,15 +248,18 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict: if not session.get("running") and session.get("artifactsAvailable") and not session.get("artifactsComplete"): state = wait_for_artifacts(adb, serial, state, args.timeout) session = state.get("session", {}) - remote_dir = session.get("logDir") - if not remote_dir or not session.get("id") or not session.get("artifactsAvailable"): - raise ControlError("No session artifact directory is available", "NO_SESSION_LOGS") - destination = Path(args.destination or session["id"]).expanduser().resolve() - result = run_adb(adb, serial, "pull", remote_dir, str(destination), timeout=120) + response = provider_call(adb, serial, "logs", timeout=120) + if response.get("ok") is False: + error = response.get("error", {}) + raise ControlError(error.get("message", "log export failed"), error.get("code", "LOG_EXPORT_FAILED")) + destination = Path(args.destination or f"{response['sessionId']}.zip").expanduser().resolve() + result = run_adb(adb, serial, "exec-out", "run-as", "com.droiddeck.launcher", "cat", response["path"], timeout=120) if result.returncode != 0: - detail = (result.stderr or result.stdout).decode(errors="replace").strip() - raise ControlError(detail or "adb pull failed", "LOG_PULL_FAILED") - return add_serial({"ok": True, "command": "logs", "sessionId": session["id"], "path": str(destination)}, serial) + detail = result.stderr.decode(errors="replace").strip() + raise ControlError(detail or "log export failed", "LOG_PULL_FAILED") + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(result.stdout) + return add_serial({"ok": True, "command": "logs", "sessionId": response["sessionId"], "path": str(destination)}, serial) if args.command == "screenshot": destination = Path(args.path).expanduser().resolve() @@ -302,7 +305,7 @@ def build_parser() -> argparse.ArgumentParser: stop.add_argument("--timeout", type=float, default=90) commands.add_parser("resume", help="resume a suspended session") - logs = commands.add_parser("logs", help="pull the latest session artifact directory") + logs = commands.add_parser("logs", help="export sanitized session logs as a ZIP") logs.add_argument("which", choices=["latest"]) logs.add_argument("destination", nargs="?") logs.add_argument("--timeout", type=float, default=90) diff --git a/tools/tests/test_log_export.py b/tools/tests/test_log_export.py new file mode 100644 index 000000000..1455567cb --- /dev/null +++ b/tools/tests/test_log_export.py @@ -0,0 +1,51 @@ +import argparse +import importlib.machinery +import importlib.util +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +TOOLS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(TOOLS)) +loader = importlib.machinery.SourceFileLoader("droiddeckctl", str(TOOLS / "droiddeckctl")) +spec = importlib.util.spec_from_loader(loader.name, loader) +ctl = importlib.util.module_from_spec(spec) +loader.exec_module(ctl) + + +class LogExportTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.destination = Path(self.tmp.name) / "session.zip" + self.args = argparse.Namespace(command="logs", timeout=90, destination=str(self.destination)) + self.state = {"session": {"running": True, "id": "session1", "artifactsAvailable": True}} + + def test_logs_fetch_only_the_provider_sanitized_zip(self): + response = {"ok": True, "sessionId": "session1", "path": "/data/user/0/com.droiddeck.launcher/cache/shared-logs/session.zip"} + result = subprocess.CompletedProcess([], 0, b"sanitized ZIP", b"") + with patch.object(ctl, "get_state", return_value=self.state), patch.object(ctl, "provider_call", return_value=response) as provider, patch.object(ctl, "run_adb", return_value=result) as adb: + output = ctl.execute(self.args, "adb", "device") + provider.assert_called_once_with("adb", "device", "logs", timeout=120) + adb.assert_called_once_with("adb", "device", "exec-out", "run-as", "com.droiddeck.launcher", "cat", response["path"], timeout=120) + self.assertEqual(b"sanitized ZIP", self.destination.read_bytes()) + self.assertEqual(str(self.destination.resolve()), output["path"]) + + def test_export_failure_never_falls_back_to_pulling_raw_logs(self): + response = {"ok": False, "error": {"code": "NO_SESSION_LOGS", "message": "unavailable"}} + with patch.object(ctl, "get_state", return_value=self.state), patch.object(ctl, "provider_call", return_value=response), patch.object(ctl, "run_adb") as adb: + with self.assertRaises(ctl.ControlError): + ctl.execute(self.args, "adb", "device") + adb.assert_not_called() + self.assertFalse(self.destination.exists()) + + def test_failed_retrieval_does_not_write_a_partial_zip(self): + response = {"ok": True, "sessionId": "session1", "path": "/private/session.zip"} + result = subprocess.CompletedProcess([], 1, b"partial data", b"read failure") + with patch.object(ctl, "get_state", return_value=self.state), patch.object(ctl, "provider_call", return_value=response), patch.object(ctl, "run_adb", return_value=result): + with self.assertRaises(ctl.ControlError): + ctl.execute(self.args, "adb", "device") + self.assertFalse(self.destination.exists())