diff --git a/README.md b/README.md index 1473653c8..22fe4e442 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Before Steam launches, you must turn off **Restrict child processes** in Develop ## Community -Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, Preview builds, and device reports. Bug reports go in its **#bug-reports** forum; attach the session folder from `Download/DroidDeck/` so the logs come with it. +Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, Preview builds, and device reports. Bug reports go in its **#bug-reports** forum; attach the zip from **Share logs** (Setup, or the session drawer) so the logs come with it. ## Build @@ -29,7 +29,7 @@ Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, and `zstd` ## Limits -Compatibility and performance vary by device; hardware validation is limited. Desktop compositing uses software rendering. Firefox sandboxing is reduced under proot. See the session logs in `Download/DroidDeck/` when diagnosing problems. +Compatibility and performance vary by device; hardware validation is limited. Desktop compositing uses software rendering. Firefox sandboxing is reduced under proot. Logs are kept app-private (`files/logs/`, the last 30 sessions); **Share logs** packs the newest session, with the stores and tool logs, into one scrubbed zip. ## Credits and licence diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index aff3514d1..0ef2f323b 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -868,6 +868,8 @@ class MainActivity : ComponentActivity() { if (wanted.isNotEmpty()) requestPermissions(wanted.toTypedArray(), 1) // A session folder left without its ending - the process was killed - gets it now. if (!SessionState.running) Thread({ + com.droiddeck.launcher.session.LogMigration.run(this) + SessionLogShare.clear(this) SessionArtifacts.finishAbandoned(this) SessionArtifacts.scrubOlder(this) SessionArtifacts.prune(this) 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 80648e887..2b8ba775b 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.agent +import com.droiddeck.launcher.session.SessionPaths import android.Manifest import android.content.ContentProvider import android.content.ContentValues @@ -107,7 +108,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) @@ -163,12 +164,6 @@ 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 stop(context: Context): JSONObject { if (SessionState.running) { SessionService.stop(context) 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 bac157b82..ad48f2664 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt @@ -75,9 +75,24 @@ object LogRedactor { * user who signs in with a plain account name, not an email, had it in every log. The single * space then a non-space keeps "OnLoginStateChange 0 1 0 0" (no account yet) as it is. */ - private val LOGIN_STATE = Regex("(OnLoginStateChange )(\\S+)") + private val LOGIN_STATE = Regex("(?i)(OnLoginStateChange:? )([^\\s<]\\S*)") private val LOGIN_USERS = Regex("(OnLoginUsersChanged )(\\S.*)$") + /** + * An account name as a field: `AccountName`, `account_name`, `username` (`"AccountName" + * "someone"` in a VDF, `account_name=...`, `"username": "..."`) and `login` - the last only + * as `login=`, a quoted key or a VDF pair, so "Login: OnLoginStateChange ..." keeps its label. + * A quoted value is blanked whole; one already blanked is left as it is. + */ + private const val ACCOUNT_VALUE = "(\"[^\"<\\r\\n]+\"|'[^'<\\r\\n]+'|[^\\s\"'<>&;,}]+)" + private val ACCOUNT_KV = Regex( + "(?i)(? = emptyList() @@ -205,13 +220,49 @@ object LogRedactor { * only when that matches, so a folder scrubbed under older rules is scrubbed again on the way * out. */ - const val RULES_VERSION = 2 + const val RULES_VERSION = 3 + + /** + * A private IPv4 address (10/8, 172.16/12, 192.168/16, link-local 169.254/16): the home + * network's layout, e.g. its router as the resolver. Blanked in a shared zip only; public + * server addresses stay. Not part of a longer dotted number (a version like 10.0.19041.1). + */ + private val LAN_IPV4 = Regex( + "(? out.write(redact(line)); out.write("\n") } } + /** + * The last pass for a file going into a shared zip: [redact], then the rules the stores' log + * uses too ([SecretScrub]) - URLs without query, fragment and user:password and with any + * token-bearing path segment blanked, token values and Authorization / Cookie headers blanked + * - and private IPv4 addresses as ``. A line already clean comes out unchanged. + */ + fun redactForShare(line: String): String { + if (line.isEmpty()) return line + return try { + val out = SecretScrub.scrub(redact(line), SecretScrub.Urls.KEEP_PATH, "") + LAN_IPV4.replace(out, "") + } catch (t: Throwable) { + "" + } + } + + /** [src]'s lines through [redactForShare], to [out]: the pass every text file in a shared zip gets. */ + fun scrubForShare(src: java.io.File, out: java.io.Writer) { + src.forEachLine { line -> out.write(redactForShare(line)); out.write("\n") } + } + + /** ``, inside [value]'s quotes when it had them. */ + private fun quoted(value: String): String { + val q = value.first().takeIf { it == '"' || it == '\'' }?.toString().orEmpty() + return "$q$q" + } + /** [line] with every credential shape replaced. Null- and exception-safe by construction. */ fun redact(line: String): String { if (line.isEmpty()) return line @@ -240,6 +291,8 @@ object LogRedactor { for (r in own) out = r.replace(out, "") out = LOGIN_STATE.replace(out) { "${it.groupValues[1]}" } out = LOGIN_USERS.replace(out) { "${it.groupValues[1]}" } + out = ACCOUNT_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" } + out = LOGIN_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" } for (r in accounts) out = r.replace(out, "") out = EMAIL.replace(out, "") out = RESIDUAL.replace(out) { "${it.groupValues[1]}=" } diff --git a/app/src/main/java/com/droiddeck/launcher/core/SecretScrub.kt b/app/src/main/java/com/droiddeck/launcher/core/SecretScrub.kt new file mode 100644 index 000000000..c91ca60ef --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/core/SecretScrub.kt @@ -0,0 +1,72 @@ +package com.droiddeck.launcher.core + +/** + * The one set of rules that keeps URLs and tokens out of anything the app writes or shares: the + * stores' log ([com.droiddeck.launcher.stores.StoreLog.redactLine]) and the last pass over every + * text file in a shared log zip ([LogRedactor.redactForShare]). + * + * - Every URL loses its query, fragment and `user:password@`. [Urls.SHORT] then keeps only the host + * and a plain first path segment (the stores' lines, where a GOG secure link carries its token in + * the path); [Urls.KEEP_PATH] keeps the path but blanks any segment that carries a token. + * - Outside URLs, the values of token-like keys (`token`, `__token__`, `f_token`, `hdnts`, access / + * refresh / id tokens; exchange and authorization codes of four characters or more, and a bare + * `code=` where it is one: in a query string or on a line with OAuth keys such as `client_id`, + * `redirect_uri` or `state`, never an 8-hex exception code like Proton's `code=c0000005`) and of + * Authorization / Cookie headers are blanked with [mark]. + * + * A line already scrubbed comes out unchanged. + */ +object SecretScrub { + enum class Urls { SHORT, KEEP_PATH } + + private val URL = Regex("""[A-Za-z][A-Za-z0-9+.\-]*://[^\s"'<>]+""") + private val SAFE_SEGMENT = Regex("""[A-Za-z0-9._\-]{1,40}""") + private val TOKEN_SEGMENT = Regex("""(?i)(token|hdnts|hmac|signature|sig|exp)=""") + private val TOKEN = Regex("""(?i)(?}]+""") + /** Codes that are only ever OAuth grants. */ + private val GRANT_CODE = Regex("""(?i)(?}]{4,}""") + /** A bare `code=`: a grant only right after `?` / `&` (a query string) or beside OAuth keys ([OAUTH_CONTEXT]). */ + private val CODE = Regex("""(?i)(?}]{4,})""") + private val OAUTH_CONTEXT = Regex("""(?i)(? if (urls == Urls.SHORT) short(m.value) else keepPath(m.value, mark) } + out = TOKEN.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + mark } + out = GRANT_CODE.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + mark } + val oauth = OAUTH_CONTEXT.containsMatchIn(text) + val src = out + out = CODE.replace(src) { m -> + val inQuery = m.range.first > 0 && src[m.range.first - 1] in "?&" + val grant = !STATUS_CODE.matches(m.groupValues[3]) && (oauth || inQuery) + if (grant) m.groupValues[1] + m.groupValues[2] + mark else m.value + } + out = HEADER.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + mark } + return out + } + + private fun split(url: String): Triple { + val schemeEnd = url.indexOf("://") + val rest = url.substring(schemeEnd + 3).substringBefore('#').substringBefore('?') + val slash = rest.indexOf('/') + val authority = (if (slash >= 0) rest.substring(0, slash) else rest).substringAfterLast('@') + return Triple(url.substring(0, schemeEnd), authority, if (slash >= 0) rest.substring(slash + 1) else null) + } + + private fun short(url: String): String { + val (scheme, authority, path) = split(url) + if (path == null) return "$scheme://$authority" + val first = path.substringBefore('/') + if (first.isEmpty()) return "$scheme://$authority/" + val shown = if (SAFE_SEGMENT.matches(first)) first else "…" + return "$scheme://$authority/$shown" + if (path.length > first.length && shown != "…") "/…" else "" + } + + private fun keepPath(url: String, mark: String): String { + val (scheme, authority, path) = split(url) + val clean = path?.split('/')?.joinToString("/") { seg -> if (TOKEN_SEGMENT.containsMatchIn(seg)) mark else seg } + return "$scheme://$authority" + (clean?.let { "/$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..46f2f708b 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,8 @@ 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)) } + // The app's own logs folder: private, which this file manager (the app itself) can read. + com.droiddeck.launcher.runtime.LinuxRuntime.logDir(context).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/FlatpakManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt index 8f31e2d2f..c5c1fa6db 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt @@ -87,7 +87,7 @@ object FlatpakManager { ?.maxByOrNull { it.first }?.second } - /** Runs [argv] in the runtime, its output also in Download/DroidDeck/flatpak-.log. */ + /** Runs [argv] in the runtime, its output also in files/logs/tools/flatpak-.log. */ private fun runGuest(context: Context, argv: List, fakeRoot: Boolean, onLine: (String) -> Unit): Int { val name = "flatpak-" + (argv.getOrNull(2)?.takeIf { argv.getOrNull(1) == HELPER } ?: "setup") return GuestCommand.run(context, argv, fakeRoot, name, onLine = onLine) 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..286059588 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt @@ -17,7 +17,7 @@ object GuestCommand { /** * Runs [argv] and hands each output line to [onLine]; returns the exit status. [fakeRoot] is * for the package tools, which refuse any uid but 0. With [logName], everything the command - * says also goes to Download/DroidDeck/tools/.log beside the session logs, so a problem can + * says also goes to files/logs/tools/.log beside the session logs, so a problem can * be handed over like a session's. With [linkDir], hard links (which Android denies apps) * become symlinks to files proot keeps there (its link2symlink). */ @@ -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.logDir(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..082bf814e 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java @@ -44,10 +44,20 @@ 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. */ + /** The folder under Downloads where builds before private logs wrote them; read only to move them. */ public static final String DEBUG_LOG_DIR = "DroidDeck"; - public static File debugLogDir() { + /** + * Where every log lands: app-private, {@code files/logs}. Session folders, tools/ (one-off + * commands) and stores/ sit under it; the files directory is bound into every session at the + * same path, so the guest writes there too. Share logs is how a folder leaves the device. + */ + public static File logDir(Context context) { + return new File(context.getFilesDir(), "logs"); + } + + /** {@code Download/DroidDeck}, where earlier builds wrote logs. Only LogMigration reads it. */ + public static File legacyLogDir() { return new File(android.os.Environment.getExternalStoragePublicDirectory( android.os.Environment.DIRECTORY_DOWNLOADS), DEBUG_LOG_DIR); } diff --git a/app/src/main/java/com/droiddeck/launcher/session/LogMigration.kt b/app/src/main/java/com/droiddeck/launcher/session/LogMigration.kt new file mode 100644 index 000000000..c2b34f816 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/LogMigration.kt @@ -0,0 +1,73 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.util.Log +import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.runtime.LinuxRuntime +import java.io.File + +/** + * Once, on the first start of a build with private logs: the session folders, tools/ and the loose + * tool logs that earlier builds left in `Download/DroidDeck/` move into `files/logs/`. Best effort: + * an item that cannot be moved stays where it is. Moved session folders lose their old scrub markers + * ([SessionArtifacts.unmarkMoved]), so [SessionArtifacts.scrubOlder] scrubs them under the current rules. Nothing else in that folder is touched - game + * save backups (Saves/) stay public - and after this pass the app never reads it again. + */ +object LogMigration { + private const val TAG = "LogMigration" + private const val MARKER = ".moved-from-downloads" + + @Synchronized + fun run(context: Context) { + val target = LinuxRuntime.logDir(context).apply { mkdirs() } + val marker = File(target, MARKER) + if (marker.exists()) return + val legacy = LinuxRuntime.legacyLogDir() + val items = if (legacy.isDirectory) legacy.listFiles() else emptyArray() + // Unreadable (no storage access yet): try again next start rather than give up on them. + if (items == null) { Log.w(TAG, "$legacy cannot be listed; logs there stay until it can"); return } + var moved = 0 + val movedFolders = ArrayList() + for (f in items) { + val isLog = SessionPaths.isSessionFolder(f) || + (f.isDirectory && f.name == SessionPaths.TOOLS_DIR) || + (f.isFile && f.name.endsWith(".log")) + if (!isLog) continue + val dest = File(if (f.isFile) File(target, SessionPaths.TOOLS_DIR).apply { mkdirs() } else target, f.name) + if (dest.exists() && f.name != SessionPaths.TOOLS_DIR) continue + if (move(f, dest)) { + moved++ + if (SessionPaths.isSessionFolder(dest)) movedFolders.add(dest) + } else Log.w(TAG, "could not move ${f.name}; left in place") + } + // Earlier builds scrubbed these under older rules (or not at all) and marked them done: + // without their markers, the pass over older folders that follows at the same start puts + // them through the current redactor. + SessionArtifacts.unmarkMoved(movedFolders) + try { marker.writeText("moved $moved item(s)\n") } catch (e: Exception) { Log.w(TAG, "could not mark the move: ${e.message}") } + if (moved > 0) Log.i(TAG, "moved $moved log item(s) from $legacy into $target") + } + + /** A rename where it works (same volume), else a copy then the source's removal; false = left as it was. */ + private fun move(src: File, dest: File): Boolean { + if (src.renameTo(dest)) return true + return try { + copy(src, dest) + FileUtils.delete(src) + true + } catch (e: Exception) { + Log.w(TAG, "copy of ${src.name} failed: ${e.message}") + false + } + } + + private fun copy(src: File, dest: File) { + if (src.isDirectory) { + dest.mkdirs() + src.listFiles()?.forEach { copy(it, File(dest, it.name)) } + } else { + dest.parentFile?.mkdirs() + src.inputStream().use { input -> dest.outputStream().use { input.copyTo(it) } } + } + } +} 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..cc0f5ad82 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt @@ -31,16 +31,20 @@ 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" + /** + * Every file in the folder has been through the redactor under its current rules. Named after + * [LogRedactor.RULES_VERSION], so a folder scrubbed under older rules is scrubbed again. + */ + private val SCRUBBED_MARKER = ".scrubbed-r${LogRedactor.RULES_VERSION}" + private val OLD_SCRUBBED_MARKER = Regex("""\.scrubbed-(r?\d+)""") /** * 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. + * with its size and modification time, under the redactor's rules version ([scrubbedFiles]). + * A share no longer relies on it: every text file goes through the share pass on the way into + * the zip regardless. */ const val SCRUBBED_TREE_MARKER = ".scrubbed-tree" @@ -67,24 +71,48 @@ 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]. + * Every session folder not yet scrubbed under the redactor's current rules - written before + * every file was scrubbed, before the device's own addresses or the Steam account were, or + * moved in from Download/ ([LogMigration] strips their markers first, [unmarkMoved]): the + * whole folder, steam/ and every other subfolder included, through the redactor, and the + * current marker written in place of the old ones. Runs at app start with [finishAbandoned]. + * + * A folder holds some 30 MB of Steam logs, about half a minute's work, so folders go through + * a few at a time; one the process does not get to keeps its old marker and is taken up at + * the next start. */ @Synchronized fun scrubOlder(context: Context) { val current = SessionPaths.current() - val dirs = LinuxRuntime.debugLogDir().listFiles { f -> + val dirs = LinuxRuntime.logDir(context).listFiles { f -> SessionPaths.isSessionFolder(f) && f != current && !File(f, SCRUBBED_MARKER).exists() - } ?: return + }?.toList() ?: 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) {} + val workers = (Runtime.getRuntime().availableProcessors() / 2).coerceIn(1, 4) + val pool = java.util.concurrent.Executors.newFixedThreadPool(workers) { r -> + Thread({ android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND); r.run() }, "scrub-logs") + } + val done = try { + pool.invokeAll(dirs.map { dir -> java.util.concurrent.Callable { scrubAndMark(dir) } }) + .count { f -> try { f.get() } catch (e: Exception) { false } } + } finally { + pool.shutdown() + } + val failed = dirs.size - done + Log.i(TAG, "logs: scrubbed $done folders under r${LogRedactor.RULES_VERSION}" + + if (failed > 0) ", $failed not fully (again at the next start)" else "") + } + + /** + * Folders [LogMigration] just moved in lose every scrub marker an earlier build left, so + * [scrubOlder], which runs right after it, puts them through the current rules whatever those + * markers claimed. + */ + fun unmarkMoved(dirs: List) { + dirs.filter { SessionPaths.isSessionFolder(it) }.forEach { dir -> + dir.listFiles { f -> OLD_SCRUBBED_MARKER.matches(f.name) || f.name == SCRUBBED_TREE_MARKER }?.forEach { it.delete() } } - Log.i(TAG, "scrubbed ${dirs.size} older session folder(s)") } /** @@ -143,15 +171,17 @@ object SessionArtifacts { * 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) { + private fun markScrubbed(record: Record): Boolean { val dir = record.dir val tree = File(dir, SCRUBBED_TREE_MARKER) + // A marker from older rules vouches for nothing now, whether or not this pass succeeded. + dir.listFiles { f -> OLD_SCRUBBED_MARKER.matches(f.name) }?.forEach { it.delete() } if (record.failed) { tree.delete() Log.w(TAG, "$dir is not fully scrubbed; it is scrubbed again when shared") - return + return false } - try { + return try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") val lines = StringBuilder("rules ${LogRedactor.RULES_VERSION}\n") record.files.forEach { (f, s) -> @@ -159,8 +189,11 @@ object SessionArtifacts { .append(f.relativeTo(dir).path).append('\n') } tree.writeText(lines.toString()) + true } catch (e: Exception) { tree.delete() + File(dir, SCRUBBED_MARKER).delete() + false } } @@ -181,9 +214,8 @@ object SessionArtifacts { }.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))) + /** The end-of-session scrub of [dir] on its own (steam/ already in place); also the pass over older and moved folders. */ + internal fun scrubAndMark(dir: File): Boolean = markScrubbed(scrubTree(Record(dir))) /** * [record]'s folder and every folder under it (steam/, droiddeck-esync/): the session script @@ -223,7 +255,7 @@ object SessionArtifacts { */ @Synchronized fun finishAbandoned(context: Context) { - val parent = LinuxRuntime.debugLogDir() + val parent = LinuxRuntime.logDir(context) val abandoned = parent.listFiles { f -> SessionPaths.isSessionFolder(f) && !File(f, COMPLETE_MARKER).exists() }?.sortedWith(SessionPaths.chronological) ?: return @@ -282,9 +314,7 @@ object SessionArtifacts { 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)) - // Before tools/, those logs sat loose beside the session folders. - LinuxRuntime.debugLogDir().listFiles { f -> f.isFile && f.name.endsWith(".log") }?.forEach { it.delete() } + com.droiddeck.launcher.core.FileUtils.delete(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR)) 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 04dc79260..e768b6f69 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -361,25 +361,9 @@ object SessionFiles { ).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. + * Where the session writes its log: app-private `files/logs`, bound into the session at the same + * path, so the session script's own redirection always opens. A folder leaves the device only + * through Share logs, which scrubs every file again on the way into the zip. */ - 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) { - } - } - Log.w(TAG, "$public is not writable (storage permission?); logging to files/logs") - return File(context.filesDir, "logs").apply { mkdirs() } - } + fun logDirectory(context: Context): File = LinuxRuntime.logDir(context).apply { mkdirs() } } 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 c12a69f39..85760c822 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt @@ -18,9 +18,11 @@ 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. + * Share logs: one zip, made when asked, of a session folder (the newest by default) with the last + * seven days of the stores' log and the one-off command logs beside it, handed to Android's share + * sheet. Logs live in app-private `files/logs`; this is how they leave the device. Every text file + * goes through [LogRedactor.redactForShare] on the way in, so even a file written before a rule + * existed is clean in the zip. The zip sits in `cache/share/` until the next share or app start. */ object SessionLogShare { private val main = Handler(Looper.getMainLooper()) @@ -62,7 +64,7 @@ object SessionLogShare { 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 out = shareDir(context).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. @@ -71,11 +73,11 @@ object SessionLogShare { // 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) - // The stores' own log (last 7 days, app-private, already redacted), under stores/. - val storeLogs = com.droiddeck.launcher.stores.StoreLogFiles.files(context) - val total = (files + live + storeLogs).sumOf { it.length() }.coerceAtLeast(1L) + val logs = LinuxRuntime.logDir(context) + val stores = recent(File(logs, STORES_DIR)) + val tools = recent(File(logs, SessionPaths.TOOLS_DIR)) + val total = (files + live + stores + tools).sumOf { it.length() }.coerceAtLeast(1L) var done = 0L var shown = -1 fun advance(f: File) { @@ -84,18 +86,20 @@ object SessionLogShare { if (percent != shown) { shown = percent; onProgress?.invoke(percent / 100f) } } 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) } - storeLogs.forEach { f -> addEntry(z, folder.name + "/stores/" + f.name, f, false); advance(f) } + files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f); advance(f) } + live.forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f); advance(f) } + stores.forEach { f -> addEntry(z, "$STORES_DIR/" + f.name, f); advance(f) } + tools.forEach { f -> addEntry(z, SessionPaths.TOOLS_DIR + "/" + f.name, f); advance(f) } } return zip } - private fun addEntry(z: ZipOutputStream, name: String, f: File, scrubbed: Boolean) { + private fun addEntry(z: ZipOutputStream, name: String, f: File) { z.putNextEntry(ZipEntry(name)) - if (!scrubbed && LogRedactor.isText(f)) { + // Every text file, every time: the folder's own scrub may predate a rule. + if (LogRedactor.isText(f)) { val w = z.bufferedWriter() - LogRedactor.scrubTo(f, w) + LogRedactor.scrubForShare(f, w) w.flush() } else { f.inputStream().use { it.copyTo(z) } @@ -119,6 +123,25 @@ object SessionLogShare { /** 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 + /** The stores' engine log, one file a day, under files/logs. */ + const val STORES_DIR = "stores" + private const val RECENT_DAYS = 7 + + private fun shareDir(context: Context): File = File(context.cacheDir, "share") + + /** The files in [dir] changed in the last [RECENT_DAYS] days. */ + private fun recent(dir: File): List { + val since = System.currentTimeMillis() - RECENT_DAYS * 86_400_000L + return dir.listFiles { f -> f.isFile && f.lastModified() >= since }?.sortedBy { it.name }.orEmpty() + } + + /** Deletes the last share's zip; at app start, so a zip never outlives the share it was made for. */ + fun clear(context: Context) { + shareDir(context).deleteRecursively() + // Where builds before this one put it. + File(context.cacheDir, "shared-logs").deleteRecursively() + } + 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 2b8f38eec..5b94c6a44 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt @@ -8,19 +8,20 @@ import java.util.Date import java.util.Locale /** - * One folder per session under `Download/DroidDeck/`, holding everything that session recorded, + * One folder per session under app-private `files/logs/`, 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/ + * files/logs/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 KWin, Plasma and the programs on it (desktop mode) - * Download/DroidDeck/tools/ one-off commands: Flatpak installs, AppImage imports + * files/logs/tools/ one-off commands: Flatpak installs, AppImage imports + * files/logs/stores/ the stores' engine log, one file a day * ``` * * Folders from before this naming (`session-20260930-180642`) are still recognised, shared and @@ -137,8 +138,7 @@ object SessionPaths { /** 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() } + (com.droiddeck.launcher.runtime.LinuxRuntime.logDir(context).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/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 2fd7b05c5..af23de85c 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -493,7 +493,7 @@ object SessionPrefs { } /** - * Whether a session writes its folder under Download/DroidDeck. Off, the same logs are kept in + * Whether a session keeps its folder under files/logs. Off, the same logs are kept in * the app's cache for the session's lifetime (the scripts need somewhere to write) and thrown * away at the end, so nothing accumulates in Downloads. */ diff --git a/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt b/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt index 850ef5059..b4a0ef751 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt @@ -398,7 +398,7 @@ object WinComponents { * Downloads the component's package (.msi) or installer (.exe) into the runtime and has * droiddeck-msi-install lay it out in [staging]. Returns the installer's result (product, * version, counts, notes), or null and why. Everything the installer prints also goes to - * Download/DroidDeck/tools. + * files/logs/tools. */ private fun installPackage(context: Context, c: Component, step: Step, staging: File, onProgress: (Progress) -> Unit): Pair { val root = LinuxRuntime.rootDir(context) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreNet.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreNet.kt index 1a94c847a..4eeba0cab 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreNet.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreNet.kt @@ -125,40 +125,12 @@ object StoreLog { } } - private val URL_PATTERN = Regex("""[A-Za-z][A-Za-z0-9+.\-]*://[^\s"'<>]+""") - private val SAFE_SEGMENT = Regex("""[A-Za-z0-9._\-]{1,40}""") - private val SECRET = Regex("""(?i)(? shortUrl(m.value) } - out = SECRET.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + "…" } - out = CODE.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + "…" } - out = AUTH_HEADER.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + "…" } - return out - } - - private fun shortUrl(url: String): String { - val schemeEnd = url.indexOf("://") - val scheme = url.substring(0, schemeEnd) - var rest = url.substring(schemeEnd + 3) - rest = rest.substringBefore('#').substringBefore('?') - val slash = rest.indexOf('/') - val authority = (if (slash >= 0) rest.substring(0, slash) else rest).substringAfterLast('@') - if (slash < 0) return "$scheme://$authority" - val path = rest.substring(slash + 1) - val first = path.substringBefore('/') - val more = path.length > first.length - if (first.isEmpty()) return "$scheme://$authority/" - val shown = if (SAFE_SEGMENT.matches(first)) first else "…" - return "$scheme://$authority/$shown" + if (more && shown != "…") "/…" else "" - } + fun redactLine(text: String): String = + com.droiddeck.launcher.core.SecretScrub.scrub(text, com.droiddeck.launcher.core.SecretScrub.Urls.SHORT, "…") } diff --git a/app/src/main/res/xml/log_share_paths.xml b/app/src/main/res/xml/log_share_paths.xml index cc98eee1e..9bed7b683 100644 --- a/app/src/main/res/xml/log_share_paths.xml +++ b/app/src/main/res/xml/log_share_paths.xml @@ -1,4 +1,4 @@ - + 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 8b2159097..b1c97962d 100644 --- a/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt @@ -87,4 +87,65 @@ class LogRedactorTest { assertEquals(once, LogRedactor.redact(once)) } } + + // The Steam UI's own login line (webhelper_js.txt), as the privacy scan found it. + @Test fun loginStateLineLosesAnEmailAccount() { + val line = "[2026-10-07 21:14:03] SteamUI: INFO: Login: OnLoginStateChange someone.masked@example.com 2 1 0 0" + val out = LogRedactor.redact(line) + assertFalse(out.contains("someone.masked")) + assertFalse(out.contains("example.com")) + assertEquals("[2026-10-07 21:14:03] SteamUI: INFO: Login: OnLoginStateChange 2 1 0 0", out) + assertEquals(out, LogRedactor.redactForShare(line)) + } + + @Test fun loginStateLineLosesAPlainAccountName() { + val line = "[2026-10-07 21:14:03] SteamUI: INFO: Login: OnLoginStateChange maskeduser42 2 1 0 0" + val out = LogRedactor.redact(line) + assertFalse(out.contains("maskeduser42")) + assertEquals("[2026-10-07 21:14:03] SteamUI: INFO: Login: OnLoginStateChange 2 1 0 0", out) + assertEquals(out, LogRedactor.redactForShare(line)) + } + + @Test fun loginStateWithoutAnAccountIsKept() { + val line = "SteamUI: INFO: Login: OnLoginStateChange 0 1 0 0" + assertEquals(line, LogRedactor.redact(line)) + } + + @Test fun accountNameFieldsAreRedacted() { + val lines = listOf( + "\t\"AccountName\"\t\t\"maskeduser42\"", + "account_name=maskeduser42 remember=1", + "{\"login\":\"maskeduser42\",\"persist\":1}", + "auth: login=maskeduser42&remember=1", + "accountName: maskeduser42", + "{\"username\": \"masked user 42\"}", + ) + for (line in lines) { + val out = LogRedactor.redactForShare(line) + assertFalse(out, out.contains("maskeduser42") || out.contains("masked user")) + assertTrue(out, out.contains("")) + assertEquals(out, LogRedactor.redactForShare(out)) + } + } + + @Test fun loginAsAWordIsNotAField() { + for (line in listOf("SteamUI: INFO: Login: OnLoginStateChange 0 1 0 0", "login refresh pending", "Login: state 5")) { + assertEquals(line, LogRedactor.redact(line)) + } + } + + @Test fun privateAddressesLeaveTheZipPublicOnesStay() { + assertEquals("I/LinuxNetworkLink: resolver: 8.8.8.8", + LogRedactor.redactForShare("I/LinuxNetworkLink: resolver: 192.168.1.1 10.0.0.138 8.8.8.8")) + assertEquals("gateway :53 link lan ", + LogRedactor.redactForShare("gateway 172.16.4.20:53 link 169.254.10.2 lan 172.31.255.254")) + for (line in listOf( + "connecting to CM 162.254.193.47:27017", + "public 172.32.0.1 and 11.0.0.1 and 193.168.1.1", + "Windows 10.0.19041.1 build 10.0.19045", + "Proton 10.0-3, driver 25.1.0.4", + )) assertEquals(line, LogRedactor.redactForShare(line)) + val once = LogRedactor.redactForShare("resolver: 192.168.0.1") + assertEquals(once, LogRedactor.redactForShare(once)) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/core/SecretScrubTest.kt b/app/src/test/java/com/droiddeck/launcher/core/SecretScrubTest.kt new file mode 100644 index 000000000..86ff6ece8 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/core/SecretScrubTest.kt @@ -0,0 +1,39 @@ +package com.droiddeck.launcher.core + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class SecretScrubTest { + private fun share(line: String) = SecretScrub.scrub(line, SecretScrub.Urls.KEEP_PATH, "") + + @Test fun windowsExceptionCodesAreKept() { + for (line in listOf( + "0024:trace:seh:dispatch_exception code=406d1388 flags=0 addr=00006FFFFFC0A6E8", + "0024:warn:seh:dispatch_exception code=c0000005 flags=0 addr=0000000140012345", + "0030:trace:seh:handle_syscall_fault code=80000003 flags=0", + "exception code=0xC0000005 at 0x140012345", + // An exception code stays one even on a line that also carries OAuth keys. + "client_id=abc code=c0000005", + )) assertEquals(line, share(line)) + } + + @Test fun anOAuthCodeIsBlanked() { + assertEquals("redirect client_id=34a02cf8 redirect_uri=x state=1 code=", + share("redirect client_id=34a02cf8 redirect_uri=x state=1 code=AUTHCODE123")) + assertEquals("GET /on_login_success?code=&scope=", + share("GET /on_login_success?code=AUTHCODE123&scope=")) + assertEquals("exchange_code=", share("exchange_code=0123456789abcdef")) + assertEquals("authorizationCode=", share("authorizationCode=ABCD1234XYZ")) + } + + @Test fun aCodeWithoutOAuthAroundItIsKept() { + for (line in listOf("exit code=1337", "status code=ERR_TIMEOUT", "code 403")) assertEquals(line, share(line)) + } + + @Test fun scrubbingTwiceChangesNothing() { + val once = share("client_id=1 code=AUTHCODE123 ?code=XYZW1234567") + assertFalse(once.contains("AUTHCODE123")) + assertEquals(once, share(once)) + } +} 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 39978490a..1d58f179f 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareTest.kt @@ -33,12 +33,13 @@ class SessionLogShareTest { "rules $rules\n" + files.joinToString("") { val f = File(folder, it); "${f.length()}\t${f.lastModified()}\t$it\n" }) } - @Test fun aRecordedUnchangedFileGoesInAsItIs() { + @Test fun aRecordedUnchangedFileIsScrubbedAgainOnTheWayIntoTheZip() { 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. + // A file the ending recorded as scrubbed still gets the share's final pass: the record + // may predate a rule, and a zip is the one way a log leaves the device. File(folder, "session.log").writeText("$secret\n") record(folder, "session.log") - assertTrue(zip(folder).getValue("session.log").contains(leaked)) + assertFalse(zip(folder).getValue("session.log").contains(leaked)) } @Test fun anythingTheRecordDoesNotVouchForIsScrubbed() { diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt new file mode 100644 index 000000000..81f230d66 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt @@ -0,0 +1,107 @@ +package com.droiddeck.launcher.session + +import com.droiddeck.launcher.runtime.LinuxRuntime +import java.io.File +import java.util.zip.ZipFile +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** A shared zip is scrubbed on the way in, whatever the files on disk still hold. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class SessionLogShareZipTest { + private val app get() = RuntimeEnvironment.getApplication() + + @Test fun everyTextFileInTheZipIsScrubbedAndTheStoresAndToolsLogsComeAlong() { + val logs = LinuxRuntime.logDir(app) + val session = File(logs, "2026-10-08-01-steam").apply { mkdirs() } + // Written raw, as a file from before a rule existed would be. + File(session, "session.log").writeText( + "fetch https://gog-cdn-fastly.gog.com/token=nva=1791500000~dirs=/x~token=0a1b2c3d4e5f/content-system/v2/store/1/ab/cd/f\n" + + "fetch https://epicgames-download1.akamaized.net/Builds/Org/a/ChunksV4/12/ABCD.chunk?__token__=exp=1~hmac=deadbeefcafe\n" + + "login refresh_token=abcdef123456 user someone@example.com code=AUTHCODE123\n" + + "Authorization: Bearer abc.def.ghi\n" + + "kept: EResult 5, app 1086940, steamapps/common/Game\n" + ) + File(logs, "stores").apply { mkdirs() }.resolve("stores-2026-10-08.log").writeText("epic: f_token=zzzzzzzz12 window 32\n") + File(logs, SessionPaths.TOOLS_DIR).apply { mkdirs() }.resolve("flatpak-install.log").writeText("== install\nCookie: steamLoginSecure=76561198000000000%7C%7Ceyabc\n") + + val zip = SessionLogShare.zipFolder(app, session)!! + assertTrue(zip.path.startsWith(File(app.cacheDir, "share").path)) + val text = ZipFile(zip).use { z -> z.entries().toList().associate { e -> e.name to z.getInputStream(e).bufferedReader().readText() } } + assertTrue(text.keys.containsAll(listOf("2026-10-08-01-steam/session.log", "stores/stores-2026-10-08.log", "tools/flatpak-install.log"))) + val all = text.values.joinToString("\n") + for (secret in listOf("0a1b2c3d4e5f", "deadbeefcafe", "abcdef123456", "someone@example.com", "AUTHCODE123", "abc.def.ghi", "zzzzzzzz12", "eyabc")) { + assertFalse("$secret survived", all.contains(secret)) + } + // What makes a log worth reading stays. + assertTrue(all.contains("kept: EResult 5, app 1086940, steamapps/common/Game")) + assertTrue(all.contains("https://gog-cdn-fastly.gog.com/")) + + SessionLogShare.clear(app) + assertFalse(zip.exists()) + } + + @Test fun theSharePassChangesNothingInACleanLine() { + val line = "dxvk: Device 0x5 (Adreno 750) https://github.com/doitsujin/dxvk/releases code 403" + assertEquals(line, com.droiddeck.launcher.core.LogRedactor.redactForShare(line)) + } + + // Folders an earlier build scrubbed and marked under older rules, their copied Steam UI log + // still naming the account: one app start (the move out of Download/, then the pass over + // older folders) leaves every one scrubbed on disk under the current rules. + private fun olderFolder(parent: File, name: String, marker: String = ".scrubbed-2"): File { + val dir = File(parent, name).apply { mkdirs() } + File(dir, marker).writeText("scrubbed earlier\n") + File(dir, SessionArtifacts.SCRUBBED_TREE_MARKER).writeText("rules 2\n") + File(dir, "steam").apply { mkdirs() }.resolve("webhelper_js.txt").writeText( + "[2026-10-01 20:00:00] SteamUI: INFO: Login: OnLoginStateChange someone.masked@example.com 2 1 0 0\n" + + "[2026-10-01 20:00:01] SteamUI: INFO: Login: OnLoginStateChange maskeduser42 2 1 0 0\n" + + "[2026-10-01 20:00:02] SteamUI: INFO: Login: OnLoginStateChange 0 1 0 0\n" + + "0024:trace:seh:dispatch_exception code=c0000005 flags=0\n" + ) + return dir + } + + private fun assertScrubbed(dir: File) { + val text = File(dir, "steam/webhelper_js.txt").readText() + for (secret in listOf("someone.masked", "example.com", "maskeduser42")) assertFalse("$secret survived in $dir", text.contains(secret)) + assertTrue(text.contains("Login: OnLoginStateChange 2 1 0 0")) + assertTrue(text.contains("Login: OnLoginStateChange 0 1 0 0")) + assertTrue(text.contains("code=c0000005")) + val markers = dir.list()!!.filter { it.startsWith(".scrubbed-") && it != SessionArtifacts.SCRUBBED_TREE_MARKER } + assertEquals("$dir", listOf(".scrubbed-r${com.droiddeck.launcher.core.LogRedactor.RULES_VERSION}"), markers) + assertTrue(File(dir, SessionArtifacts.SCRUBBED_TREE_MARKER).readText() + .startsWith("rules ${com.droiddeck.launcher.core.LogRedactor.RULES_VERSION}\n")) + } + + @Test fun aFolderMarkedUnderOlderRulesIsScrubbedAgainAtStart() { + val dir = olderFolder(LinuxRuntime.logDir(app), "2026-10-01-01-steam") + SessionArtifacts.scrubOlder(app) + assertScrubbed(dir) + } + + @Test fun everyMigratedFolderIsScrubbedUnderTheCurrentRulesInOneStart() { + val legacy = LinuxRuntime.legacyLogDir().apply { mkdirs() } + val names = (1..9).map { "2026-10-0${it}-01-steam" } + names.forEachIndexed { i, name -> + // Old markers of every kind an earlier build left, one even claiming the current rules. + olderFolder(legacy, name, marker = listOf(".scrubbed-2", ".scrubbed-1", ".scrubbed-r${com.droiddeck.launcher.core.LogRedactor.RULES_VERSION}")[i % 3]) + } + File(legacy, "Saves").apply { mkdirs() }.resolve("keep.txt").writeText("save\n") + + // What MainActivity runs at start. + LogMigration.run(app) + SessionArtifacts.scrubOlder(app) + + val logs = LinuxRuntime.logDir(app) + for (name in names) assertScrubbed(File(logs, name)) + assertEquals(listOf("Saves"), legacy.list()!!.toList()) + } +} diff --git a/docs/development/flatpak.md b/docs/development/flatpak.md index 7ed3ae725..dd7faae50 100644 --- a/docs/development/flatpak.md +++ b/docs/development/flatpak.md @@ -22,7 +22,7 @@ The script then unpacks them without hooks and checks what the binaries link. Li the Desktop package normally brings (PyGObject, json-glib, fuse3 and others) are fetched too, but only when their files are missing. Flathub is added as a per-user remote from a copy of its `.flatpakrepo` carried in the script, so that step needs no network. Every store command -writes its output to `Download/DroidDeck/flatpak-.log`, next to the session logs. The +writes its output to `files/logs/tools/flatpak-.log`, next to the session logs. The setup script also puts Flatpak's own error text in the failure message the app shows. ## bubblewrap without namespaces diff --git a/docs/progress-log.md b/docs/progress-log.md index 4c67247a6..23d1529b7 100644 --- a/docs/progress-log.md +++ b/docs/progress-log.md @@ -7,6 +7,60 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d --- +## 2026-10-08 - `feat/private-logs`: every log in app-private storage, shared as one scrubbed zip + +- **Where logs live:** `files/logs/` (`LinuxRuntime.logDir`), nothing under `Download/DroidDeck` + any more. Session folders (`-NN-/`), `tools/` (GuestCommand, Flatpak, Windows + component installers) and `stores/` (the stores' engine log, one file a day) all sit there. The + files directory is bound into every session at the same path, so the session script, gamescope + and the guest tools write there as before. Thirty sessions kept; the Logs switch off still means + a cache folder deleted at session end. Game save backups stay public in `Download/DroidDeck/Saves`. +- **Moving the old ones:** on the first start of this build, session folders, `tools/` and loose + `*.log` files move from `Download/DroidDeck/` into `files/logs/` (rename, else copy and delete; + an item that fails stays). A marker records the pass; `Saves/` and anything else there is never + touched, and the app does not read `Download/DroidDeck/` again. +- **Share logs:** one zip in `cache/share/`, made on demand: the newest session folder (or the one + the session screen shares), the last seven days of `stores/` and `tools/`. Every text file goes + through `LogRedactor.redactForShare` on the way in - the existing rules (Steam tokens, JWTs, + SteamIDs, account names, emails, own addresses, MACs, serials) plus URLs without query, fragment + or userinfo and with token-bearing path segments blanked (GOG secure links), `__token__`, + `f_token`, `hdnts`, OAuth `code=`, and Authorization / Cookie headers - so a file written before a + rule existed is clean in the zip. The zip is deleted at the next share or app start. + `SessionLogShareTest` covers it. Nothing was relaxed: the per-session scrub, `scrubOlder` and + every existing rule stay as they were. +- **File manager:** the Session logs shortcut opens `files/logs` (the app reads its own storage). +- **On main after Stores (#476) and the KDE Plasma desktop (#473):** the stores' log is already + `files/logs/stores/stores-.log` and the zip carries its last seven days under `stores/`. One + set of rules scrubs both the stores' log and the zip (`SecretScrub`): `StoreLog.redactLine` keeps + URLs to their host and first plain segment, the zip pass keeps paths with token segments blanked, + both blank token values and Authorization / Cookie headers; the two test suites pass on it. The + Plasma desktop writes nothing outside the session folder (its output is the session's + `desktop.log`), so there was nothing new to move. +- **Privacy scan fixes:** the Steam login account (an email, 374 times) was still in older + folders' copied `steam/webhelper_js.txt` (`SteamUI: INFO: Login: OnLoginStateChange `): + those folders carried a scrub marker from before the account rules, and the marker never + expired. The marker is now named after the redactor's rules version (`.scrubbed-r3`), so every + folder scrubbed under older rules goes through again at app start, steam/ and every subfolder + included; folders moved out of `Download/DroidDeck` lose every old marker + (`SessionArtifacts.unmarkMoved`), so the same pass takes them whatever those markers said. New rules: `OnLoginStateChange `, and + `AccountName` / `account_name` / `username` / `login` fields (`login` only as `login=`, a quoted + key or a VDF pair, so the "Login:" label stays). The `code=` rule no longer touches Proton's + exception codes (`code=c0000005`, `406d1388`, `80000003`): a bare `code=` is blanked only right + after `?`/`&` or on a line with OAuth keys (`client_id`, `redirect_uri`, `state`, tokens), and + never when it is 8 hex digits; exchange / authorization codes are always blanked. Tests: + `LogRedactorTest` (the login line with an email and with a plain name), `SecretScrubTest`, + `SessionLogShareZipTest` (older and moved folders scrubbed on disk). +- **Device check (31 migrated folders):** only 4 carried `.scrubbed-r3` a minute in. Nothing was + skipped - the pass was still running: one folder holds ~30 MB of Steam logs, ~30 s through the + redactor, one folder after another (about 15 minutes for 31). The start pass now scrubs a few + folders at a time (half the cores, at most 4, background priority), old markers go whether or + not a folder's pass succeeds, a folder the process does not finish is taken up at the next start, + and it logs one line: `logs: scrubbed N folders under r3`. Test: nine migrated folders carrying + `.scrubbed-2`, `.scrubbed-1` or a stale `.scrubbed-r3` all end up scrubbed under r3 after one + migration + start pass. +- **LAN addresses in the zip:** private IPv4 addresses (10/8, 172.16/12, 192.168/16, 169.254/16; + e.g. the router in `LinuxNetworkLink: resolver: ...`) become `` in the zip pass; + public server addresses and dotted version numbers stay (`LogRedactorTest`). ## 2026-10-08 - `feat/stores`: GOG, Epic Games and Amazon Games in the launcher (in progress) A new **Stores** section - the three storefronts' libraries and public catalogs, one download