diff --git a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt index cc9488b09..9e69cc974 100644 --- a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt @@ -2710,6 +2710,7 @@ class FrontEndActivity : ComponentActivity() { !SessionPaths.currentSessionLogsRetained(this) private fun shareEnhancedArchives(zips: List) { + if (isFinishing || isDestroyed) return val choices = zips.map { zip -> val label = when { zip.name.endsWith("-session.zip") -> getString(R.string.diagnostics_share_session) diff --git a/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt b/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt index 914e8c7b4..0317c4e2d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt @@ -9,17 +9,22 @@ import com.droiddeck.launcher.runtime.BattleNet import com.droiddeck.launcher.runtime.LinuxRuntime import org.json.JSONArray import org.json.JSONObject +import java.io.BufferedInputStream import java.io.ByteArrayOutputStream import java.io.File import java.io.FileOutputStream import java.io.FileInputStream +import java.io.IOException import java.nio.ByteBuffer import java.nio.channels.Channels import java.nio.channels.FileChannel import java.nio.channels.SeekableByteChannel +import java.nio.file.FileVisitResult import java.nio.file.Files import java.nio.file.LinkOption import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes import java.security.MessageDigest import java.util.Locale import java.util.zip.ZipEntry @@ -59,12 +64,26 @@ internal object EnhancedSessionLogShare { private data class Snapshot(val file: File, val sourceBytes: Long, val truncated: Boolean) - private data class CollectionResult(val candidates: List, val truncated: Boolean) + private data class CollectionResult( + val candidates: List, + val truncated: Boolean, + val partial: Boolean, + ) - private data class ScanResult(val files: List, val truncated: Boolean) + private data class ScanResult( + val files: List, + val truncated: Boolean, + val partial: Boolean = false, + ) private data class LegacySource(val file: File, val root: File, val entry: String) + private data class LegacyDiscovery( + val sources: List, + val truncated: Boolean, + val partial: Boolean, + ) + private class Budget(val limit: Long, var remaining: Long = limit) private val sourceOpenerOverride = object : ThreadLocal() { @@ -123,6 +142,7 @@ internal object EnhancedSessionLogShare { if (category == Category.SESSION) addTrustedActivation(context, folder, archive)?.let(records::add) if (selected.isEmpty()) records += Record("", category.suffix, "unavailable", reason = "no-matching-artifacts") if (discovery.truncated) records += Record("", "discovery", "truncated", reason = "candidate-limit") + if (discovery.partial) records += Record("", "discovery", "truncated", reason = "filesystem-error") addManifest( archive, folder.name, category.suffix, records, limits, budget.limit, redactionLearningComplete, ) @@ -173,6 +193,7 @@ internal object EnhancedSessionLogShare { } addTrustedActivation(context, folder, archive)?.let(records::add) if (discovery.truncated) records += Record("", "discovery", "truncated", reason = "candidate-limit") + if (discovery.partial) records += Record("", "discovery", "truncated", reason = "filesystem-error") addManifest(archive, folder.name, "all", records, limits, budget.limit, redactionLearningComplete) } zip @@ -212,7 +233,13 @@ internal object EnhancedSessionLogShare { } ?: return Record(candidate.entry, candidate.origin, "omitted", reason = "unsafe-or-unavailable-source") if (snapshot.file.length() == 0L && candidate.linePrefix != null) { snapshot.file.delete() - return Record(candidate.entry, candidate.origin, "unavailable", snapshot.sourceBytes, reason = "no-matching-lines") + return Record( + candidate.entry, + candidate.origin, + if (snapshot.truncated) "truncated" else "unavailable", + snapshot.sourceBytes, + reason = if (snapshot.truncated) "no-matching-lines-in-bounded-scan" else "no-matching-lines", + ) } return try { budget.remaining -= snapshot.file.length() @@ -282,7 +309,7 @@ internal object EnhancedSessionLogShare { target.outputStream().buffered().use { output -> fun scan(start: Long, count: Long, discardPartialFirstLine: Boolean) { channel.position(start) - val input = Channels.newInputStream(channel) + val input = BufferedInputStream(Channels.newInputStream(channel), COPY_BUFFER_BYTES) val line = ByteArrayOutputStream(1024) var oversized = false var scanned = 0L @@ -336,33 +363,19 @@ internal object EnhancedSessionLogShare { return try { val parts = relative.map { it.toString() } if (parts.isEmpty()) return null - val rootFd = openDescriptor(root.toString()) ?: return null - if (!OsConstants.S_ISDIR(Os.fstat(rootFd).st_mode)) { - Os.close(rootFd) - return null - } - opened += duplicateAndClose(rootFd) + opened += openDirectoryDescriptor(root.toString()) ?: return null for (part in parts.dropLast(1)) { - val childFd = openDescriptor(procChild(opened.last(), part)) ?: return null - if (!OsConstants.S_ISDIR(Os.fstat(childFd).st_mode)) { - Os.close(childFd) - return null - } - opened += duplicateAndClose(childFd) + opened += openDirectoryDescriptor(procChild(opened.last(), part)) ?: return null } val leafFd = openDescriptor(procChild(opened.last(), parts.last())) ?: return null - val stat = Os.fstat(leafFd) - if (!OsConstants.S_ISREG(stat.st_mode) || stat.st_nlink != 1L) { - Os.close(leafFd) - return null + try { + val stat = Os.fstat(leafFd) + if (!OsConstants.S_ISREG(stat.st_mode) || stat.st_nlink != 1L) return null + // Android's FileInputStream(FileDescriptor) is deliberately not the descriptor owner. + FileInputStream(leafFd).use { stream -> block(stream.channel) } + } finally { + runCatching { Os.close(leafFd) } } - val stream = try { - FileInputStream(leafFd) - } catch (error: Exception) { - Os.close(leafFd) - throw error - } - stream.use { block(it.channel) } } catch (_: Exception) { null } finally { @@ -378,12 +391,15 @@ internal object EnhancedSessionLogShare { ) }.getOrNull() - private fun duplicateAndClose(fd: java.io.FileDescriptor): ParcelFileDescriptor = - try { + private fun openDirectoryDescriptor(path: String): ParcelFileDescriptor? { + val fd = openDescriptor(path) ?: return null + return try { + if (!OsConstants.S_ISDIR(Os.fstat(fd).st_mode)) return null ParcelFileDescriptor.dup(fd) } finally { - Os.close(fd) + runCatching { Os.close(fd) } } + } private fun procChild(directory: ParcelFileDescriptor, name: String): String = "/proc/self/fd/${directory.fd}/$name" @@ -436,13 +452,12 @@ internal object EnhancedSessionLogShare { var start = (sourceSize - limit).coerceAtLeast(0) if (start > 0) { input.position(start) - val one = ByteBuffer.allocate(1) + val buffered = BufferedInputStream(Channels.newInputStream(input), COPY_BUFFER_BYTES) while (start < sourceSize) { - one.clear() - if (input.read(one) < 0) return + val value = buffered.read() + if (value < 0) return start++ - one.flip() - if (one.get() == '\n'.code.toByte()) break + if (value == '\n'.code) break } } copy(input, output, start, (sourceSize - start).coerceAtMost(limit)) @@ -502,6 +517,7 @@ internal object EnhancedSessionLogShare { val categoryCounts = HashMap() val perCategory = (maxFiles / Category.values().size).coerceAtLeast(1) var truncated = false + var partial = false fun add( file: File, root: File, @@ -511,7 +527,7 @@ internal object EnhancedSessionLogShare { linePrefix: String? = null, ) { val safe = safeEntry(entry) ?: return - val category = forcedCategory ?: category(entry) + val category = forcedCategory ?: category(entry.removePrefix("${folder.name}/")) if (out.size >= maxFiles || (categoryCounts[category] ?: 0) >= perCategory) { truncated = true return @@ -542,6 +558,7 @@ internal object EnhancedSessionLogShare { val steam = File(SessionPrefs.activeSteamRoot(context), "logs") val scan = directFiles(steam, remaining(maxFiles, out.size)) truncated = truncated || scan.truncated + partial = partial || scan.partial scan.files.forEach { add(it, steam, "${folder.name}/steam/${it.name}", "live-steam") } @@ -550,17 +567,22 @@ internal object EnhancedSessionLogShare { for ((name, root) in listOf(SessionLogShare.STORES_DIR to File(logs, SessionLogShare.STORES_DIR), SessionPaths.TOOLS_DIR to File(logs, SessionPaths.TOOLS_DIR))) { val scan = directFiles(root, remaining(maxFiles, out.size)) truncated = truncated || scan.truncated + partial = partial || scan.partial scan.files.asSequence().filter { recent(it) } .forEach { add(it, root, "$name/${it.name}", name) } } if (legacy) { - legacyBattleNet(context).forEach { source -> + val scan = legacyBattleNet(context) + truncated = truncated || scan.truncated + partial = partial || scan.partial + scan.sources.forEach { source -> add(source.file, source.root, source.entry, "battlenet") } } else { battleNetRoots(context).forEach { (prefix, root) -> val scan = walk(root, remaining(maxFiles, out.size)) truncated = truncated || scan.truncated + partial = partial || scan.partial scan.files.asSequence().filter { recent(it) && it.name.endsWith(".log", true) } .forEach { file -> val relative = root.toPath().toAbsolutePath().normalize().relativize(file.toPath().toAbsolutePath().normalize()).joinToString("/") @@ -570,13 +592,14 @@ internal object EnhancedSessionLogShare { } val sessionScan = walk(folder, remaining(maxFiles, out.size)) truncated = truncated || sessionScan.truncated + partial = partial || sessionScan.partial sessionScan.files.forEach { file -> val relative = file.toPath().toAbsolutePath().normalize() .let { folder.toPath().toAbsolutePath().normalize().relativize(it).joinToString("/") } if (relative == EnhancedDiagnostics.MANIFEST_FILE) return@forEach add(file, folder, "${folder.name}/$relative", "session") } - return CollectionResult(out.values.toList(), truncated) + return CollectionResult(out.values.toList(), truncated, partial) } private fun remaining(maxFiles: Int, found: Int): Int = (maxFiles - found + 1).coerceAtLeast(0) @@ -585,38 +608,69 @@ internal object EnhancedSessionLogShare { if (limit <= 0 || !root.isDirectory || Files.isSymbolicLink(root.toPath())) return ScanResult(emptyList(), false) val files = ArrayList(limit) var truncated = false - Files.walk(root.toPath()).use { paths -> - val iterator = paths.iterator() - var visited = 0 - val visitLimit = (limit * 4).coerceAtLeast(limit).coerceAtMost(MAX_DISCOVERY_VISITS) - while (iterator.hasNext() && files.size < limit && visited < visitLimit) { - val path = iterator.next() - visited++ - if (path != root.toPath() && - (Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(path))) { - files += path.toFile() + var partial = false + var visited = 0 + val rootPath = root.toPath() + val visitLimit = (limit * 4).coerceAtLeast(limit).coerceAtMost(MAX_DISCOVERY_VISITS) + try { + Files.walkFileTree(rootPath, object : SimpleFileVisitor() { + private fun countVisit(): FileVisitResult { + visited++ + return if (visited > visitLimit) { + truncated = true + FileVisitResult.TERMINATE + } else FileVisitResult.CONTINUE } - } - truncated = iterator.hasNext() + + override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult = + if (dir == rootPath) FileVisitResult.CONTINUE else countVisit() + + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + if (countVisit() == FileVisitResult.TERMINATE) return FileVisitResult.TERMINATE + if (files.size >= limit) { + truncated = true + return FileVisitResult.TERMINATE + } + if (attrs.isRegularFile || Files.isSymbolicLink(file)) files += file.toFile() + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult { + partial = true + return countVisit() + } + + override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { + if (exc != null) partial = true + return FileVisitResult.CONTINUE + } + }) + } catch (_: Exception) { + partial = true } - return ScanResult(files, truncated) + return ScanResult(files, truncated, partial) } private fun directFiles(root: File, limit: Int): ScanResult { if (limit <= 0 || !root.isDirectory || Files.isSymbolicLink(root.toPath())) return ScanResult(emptyList(), false) val files = ArrayList(limit) var truncated = false - Files.newDirectoryStream(root.toPath()).use { entries -> - val iterator = entries.iterator() - while (iterator.hasNext() && files.size < limit) { - val path = iterator.next() - if (Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(path)) { - files += path.toFile() + var partial = false + try { + Files.newDirectoryStream(root.toPath()).use { entries -> + val iterator = entries.iterator() + while (iterator.hasNext() && files.size < limit) { + val path = iterator.next() + if (Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(path)) { + files += path.toFile() + } } + truncated = iterator.hasNext() } - truncated = iterator.hasNext() + } catch (_: Exception) { + partial = true } - return ScanResult(files, truncated) + return ScanResult(files, truncated, partial) } private fun battleNetRoots(context: Context): List> { @@ -630,25 +684,31 @@ internal object EnhancedSessionLogShare { ) } - private fun legacyBattleNet(context: Context): List { + private fun legacyBattleNet(context: Context): LegacyDiscovery { val drive = File(BattleNet.prefix(context), "pfx/drive_c") val data = File(drive, "ProgramData/Battle.net") val agent = File(data, "Agent") val setup = File(data, "Setup") - return legacyNewest( - "droiddeck", - listOf(File(LinuxRuntime.rootDir(context), "root/.cache/droiddeck/battlenet")), - 2, - setOf("battlenet.log", "spawned.log"), - ) + legacyNewest( - "client", listOf(File(drive, "users/steamuser/AppData/Local/Battle.net/Logs")), 6, - ) + legacyNewest( - "agent", - listOf(File(agent, "Logs")) + childDirectories(agent, "Agent.").map { File(it, "Logs") }, - 10, - byDir = true, - ) + legacyNewest( - "setup", childDirectories(setup, "").map { File(it, "Logs") }, 3, byDir = true, + val agentChildren = childDirectories(agent, "Agent.") + val setupChildren = childDirectories(setup, "") + val scans = listOf( + legacyNewest( + "droiddeck", + listOf(File(LinuxRuntime.rootDir(context), "root/.cache/droiddeck/battlenet")), + 2, + setOf("battlenet.log", "spawned.log"), + ), + legacyNewest("client", listOf(File(drive, "users/steamuser/AppData/Local/Battle.net/Logs")), 6), + legacyNewest( + "agent", listOf(File(agent, "Logs")) + agentChildren.files.map { File(it, "Logs") }, 10, + byDir = true, + ), + legacyNewest("setup", setupChildren.files.map { File(it, "Logs") }, 3, byDir = true), + ) + return LegacyDiscovery( + scans.flatMap { it.sources }, + scans.any { it.truncated } || agentChildren.truncated || setupChildren.truncated, + scans.any { it.partial } || agentChildren.partial || setupChildren.partial, ) } @@ -658,29 +718,50 @@ internal object EnhancedSessionLogShare { count: Int, names: Set? = null, byDir: Boolean = false, - ): List = roots.flatMap { root -> - directFiles(root, MAX_BATTLENET_SCAN).files.asSequence() - .filter { recent(it) && it.name.endsWith(".log") && it.length() <= LEGACY_LOG_MAX_BYTES } - .filter { names == null || it.name in names } - .map { it to root } - .toList() - }.sortedByDescending { it.first.lastModified() }.take(count).map { (file, root) -> - val relative = if (byDir) "$folder/${file.parentFile?.parentFile?.name}/${file.name}" else "$folder/${file.name}" - LegacySource(file, root, "battlenet/$relative") + ): LegacyDiscovery { + val found = ArrayList>() + var truncated = false + var partial = false + roots.forEach { root -> + val scan = directFiles(root, MAX_BATTLENET_SCAN) + truncated = truncated || scan.truncated + partial = partial || scan.partial + found += scan.files.asSequence() + .filter { recent(it) && it.name.endsWith(".log") && it.length() <= LEGACY_LOG_MAX_BYTES } + .filter { names == null || it.name in names } + .map { it to root } + .toList() + } + val ordered = found.sortedByDescending { it.first.lastModified() } + return LegacyDiscovery( + ordered.take(count).map { (file, root) -> + val relative = if (byDir) "$folder/${file.parentFile?.parentFile?.name}/${file.name}" else "$folder/${file.name}" + LegacySource(file, root, "battlenet/$relative") + }, + truncated || ordered.size > count, + partial, + ) } - private fun childDirectories(root: File, prefix: String): List { - if (!root.isDirectory || Files.isSymbolicLink(root.toPath())) return emptyList() + private fun childDirectories(root: File, prefix: String): ScanResult { + if (!root.isDirectory || Files.isSymbolicLink(root.toPath())) return ScanResult(emptyList(), false) val found = ArrayList() - Files.newDirectoryStream(root.toPath()).use { entries -> - val iterator = entries.iterator() - while (iterator.hasNext() && found.size < MAX_BATTLENET_DIRECTORIES) { - val path = iterator.next() - if (path.fileName.toString().startsWith(prefix) && - Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(path)) found += path.toFile() + var truncated = false + var partial = false + try { + Files.newDirectoryStream(root.toPath()).use { entries -> + val iterator = entries.iterator() + while (iterator.hasNext() && found.size < MAX_BATTLENET_DIRECTORIES) { + val path = iterator.next() + if (path.fileName.toString().startsWith(prefix) && + Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(path)) found += path.toFile() + } + truncated = iterator.hasNext() } + } catch (_: Exception) { + partial = true } - return found + return ScanResult(found, truncated, partial) } private fun recent(file: File): Boolean = file.lastModified() >= System.currentTimeMillis() - RECENT_MILLIS @@ -741,6 +822,7 @@ internal object EnhancedSessionLogShare { "events.jsonl", "memory.csv", "capture.json", "crash.log", ) private const val RECENT_MILLIS = 7L * 24 * 60 * 60 * 1000 + private const val COPY_BUFFER_BYTES = 64 * 1024 private const val TRACE_SCAN_BYTES = 16L * 1024 * 1024 private const val LEGACY_LOG_MAX_BYTES = 8L * 1024 * 1024 private const val MAX_BATTLENET_SCAN = 64 diff --git a/app/src/test/java/com/droiddeck/launcher/session/EnhancedSessionLogShareTest.kt b/app/src/test/java/com/droiddeck/launcher/session/EnhancedSessionLogShareTest.kt index cd2157cad..1c15db119 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/EnhancedSessionLogShareTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/EnhancedSessionLogShareTest.kt @@ -14,7 +14,13 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.channels.FileChannel +import java.nio.channels.SeekableByteChannel import java.nio.file.Files +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.PosixFilePermissions +import java.util.concurrent.atomic.AtomicInteger import java.util.zip.ZipFile import org.junit.After import org.junit.Before @@ -29,6 +35,32 @@ class EnhancedSessionLogShareTest { @After fun resetSourceOpener() = resetSecureSourceOpener() + private class CountingChannel( + private val delegate: SeekableByteChannel, + private val reads: AtomicInteger, + ) : SeekableByteChannel by delegate { + override fun read(dst: ByteBuffer): Int { + reads.incrementAndGet() + return delegate.read(dst) + } + } + + private fun installCountingSourceOpener(reads: AtomicInteger) { + EnhancedSessionLogShare.setSourceOpenerForTest(object : SecureSourceOpener { + override fun withChannel( + root: java.nio.file.Path, + relative: java.nio.file.Path, + block: (SeekableByteChannel) -> T, + ): T? { + val source = root.resolve(relative).normalize() + if (!source.startsWith(root) || Files.isSymbolicLink(source)) return null + return FileChannel.open(source, StandardOpenOption.READ).use { channel -> + block(CountingChannel(channel, reads)) + } + } + }) + } + @Test fun diagnosticsAreSplitIntoManifestedArchivesWithOneSessionIdentity() { val session = tmp.newFolder("2026-11-04-01-steam") session.resolve("session.log").writeText("startup ok\n") @@ -150,6 +182,32 @@ class EnhancedSessionLogShareTest { SessionLogShare.clear(app) } + @Test fun byteOrientedTraceAndTailScansUseBufferedChannelReads() { + val traceSession = tmp.newFolder("2026-11-04-05-buffered-trace") + traceSession.resolve("session.log").writeText("x".repeat(1024 * 1024)) + val traceReads = AtomicInteger() + installCountingSourceOpener(traceReads) + + EnhancedSessionLogShare.create(app, traceSession) + + assertTrue("trace scan used ${traceReads.get()} channel reads", traceReads.get() < 200) + SessionLogShare.clear(app) + + val tailSession = tmp.newFolder("2026-11-04-05-buffered-tail") + tailSession.resolve("steam-7.log").writeText("x".repeat(1024 * 1024)) + val tailReads = AtomicInteger() + installCountingSourceOpener(tailReads) + + EnhancedSessionLogShare.create( + app, + tailSession, + limits = EnhancedShareLimits(maxBytesPerFile = 64 * 1024L, maxBytesTotal = 512 * 1024L), + ) + + assertTrue("tail scan used ${tailReads.get()} channel reads", tailReads.get() < 100) + SessionLogShare.clear(app) + } + @Test fun truncationNeverSharesASecretWhoseLabelWasCutOffAtTheBoundary() { val session = tmp.newFolder("2026-11-04-06-game") val secret = "abcdef1234567890" @@ -292,6 +350,79 @@ class EnhancedSessionLogShareTest { SessionLogShare.clear(app) } + @Test fun filteredTraceWithMatchesOnlyOutsideTheScannedRangesStaysTruncated() { + val session = tmp.newFolder("2026-11-04-12-partial-empty") + val source = session.resolve("session.log") + RandomAccessFile(source, "rw").use { file -> + file.setLength(17L * 1024 * 1024) + file.seek(8L * 1024 * 1024 + 256 * 1024) + file.write("DROIDDECK_FRAME_TRACE hidden-in-middle=1\n".toByteArray()) + } + + val graphics = EnhancedSessionLogShare.create(app, session).single { it.name.endsWith("-graphics.zip") } + + ZipFile(graphics).use { archive -> + assertTrue(archive.getEntry("${session.name}/graphics/frame-trace.log") == null) + val manifest = JSONObject(archive.getInputStream(archive.getEntry("diagnostics-manifest.json")) + .bufferedReader().readText()) + val entries = manifest.getJSONArray("entries") + val record = (0 until entries.length()).map { entries.getJSONObject(it) } + .single { it.optString("origin") == "guest-frame-trace" } + assertEquals("truncated", record.getString("status")) + assertEquals("no-matching-lines-in-bounded-scan", record.getString("reason")) + } + SessionLogShare.clear(app) + } + + @Test fun sessionNameCannotMisclassifyPrimarySessionAndGraphicsLogs() { + val session = tmp.newFolder("2026-11-04-12-wine") + session.resolve("session.log").writeText("session\n") + session.resolve("wayland.log").writeText("graphics\n") + + val zips = EnhancedSessionLogShare.create(app, session) + val sessionEntries = ZipFile(zips.single { it.name.endsWith("-session.zip") }).use { archive -> + archive.entries().toList().map { it.name } + } + val graphicsEntries = ZipFile(zips.single { it.name.endsWith("-graphics.zip") }).use { archive -> + archive.entries().toList().map { it.name } + } + val gameEntries = ZipFile(zips.single { it.name.endsWith("-games.zip") }).use { archive -> + archive.entries().toList().map { it.name } + } + + assertTrue(sessionEntries.contains("${session.name}/session.log")) + assertTrue(graphicsEntries.contains("${session.name}/wayland.log")) + assertFalse(gameEntries.contains("${session.name}/session.log")) + assertFalse(gameEntries.contains("${session.name}/wayland.log")) + SessionLogShare.clear(app) + } + + @Test fun unreadableDiscoverySubtreeKeepsOtherLogsAndMarksTheManifestPartial() { + assumeFalse(System.getProperty("os.name").startsWith("Windows")) + val session = tmp.newFolder("2026-11-04-12-partial-discovery") + session.resolve("session.log").writeText("safe\n") + val blocked = session.resolve("blocked").apply { mkdir(); resolve("hidden.log").writeText("hidden\n") } + val original = Files.getPosixFilePermissions(blocked.toPath()) + Files.setPosixFilePermissions(blocked.toPath(), PosixFilePermissions.fromString("---------")) + val sessionZip = try { + EnhancedSessionLogShare.create(app, session).single { it.name.endsWith("-session.zip") } + } finally { + Files.setPosixFilePermissions(blocked.toPath(), original) + } + + ZipFile(sessionZip).use { archive -> + assertTrue(archive.getEntry("${session.name}/session.log") != null) + val manifest = JSONObject(archive.getInputStream(archive.getEntry("diagnostics-manifest.json")) + .bufferedReader().readText()) + val entries = manifest.getJSONArray("entries") + assertTrue((0 until entries.length()).map { entries.getJSONObject(it) }.any { + it.optString("origin") == "discovery" && it.optString("status") == "truncated" && + it.optString("reason") == "filesystem-error" + }) + } + SessionLogShare.clear(app) + } + @Test fun diagnosticLookingGuestNamesCannotExposeEmbeddedSecrets() { val session = tmp.newFolder("2026-11-04-13-game") val tokenName = "dxvk-refresh_token_abcdef123456.log" diff --git a/docs/development/game-environment.md b/docs/development/game-environment.md index e0800784e..e7c7ca210 100644 --- a/docs/development/game-environment.md +++ b/docs/development/game-environment.md @@ -137,7 +137,7 @@ in-session share action. Enhanced sessions offer separate, correlated archives: Choose one archive when an issue calls for a narrow report, or **All bundles** to share the three archives together. Every archive contains a `diagnostics-manifest.json` with the common session ID and the state of each -artifact: captured, truncated, unavailable, omitted, or failed. Some captured +artifact: captured, truncated, unavailable, omitted, or error. Some captured logs may themselves explain that a permission-limited source was unavailable. Exported archives sample a bounded amount of input. Large text logs retain