Harden enhanced diagnostics collection and sharing

This commit is contained in:
wallentx authored and Kurt Himebauch committed 2026-11-05 13:32:46 -05:00
1 parent 44f8d5de70
commit 7053058db6
4 files changed
+307 -93

No files matched your search

@@ -2710,6 +2710,7 @@ class FrontEndActivity : ComponentActivity() {
!SessionPaths.currentSessionLogsRetained(this)
private fun shareEnhancedArchives(zips: List<java.io.File>) {
if (isFinishing || isDestroyed) return
val choices = zips.map { zip ->
val label = when {
zip.name.endsWith("-session.zip") -> getString(R.string.diagnostics_share_session)
@@ -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<Candidate>, val truncated: Boolean)
private data class CollectionResult(
val candidates: List<Candidate>,
val truncated: Boolean,
val partial: Boolean,
)
private data class ScanResult(val files: List<File>, val truncated: Boolean)
private data class ScanResult(
val files: List<File>,
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<LegacySource>,
val truncated: Boolean,
val partial: Boolean,
)
private class Budget(val limit: Long, var remaining: Long = limit)
private val sourceOpenerOverride = object : ThreadLocal<SecureSourceOpener?>() {
@@ -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<Category, Int>()
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<File>(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<Path>() {
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<File>(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<Pair<String, File>> {
@@ -630,25 +684,31 @@ internal object EnhancedSessionLogShare {
)
}
private fun legacyBattleNet(context: Context): List<LegacySource> {
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<String>? = null,
byDir: Boolean = false,
): List<LegacySource> = 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<Pair<File, File>>()
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<File> {
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<File>()
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
@@ -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 <T> 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"
+1 -1
View File
@@ -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