mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 19:41:10 +00:00
Harden enhanced diagnostics collection and sharing
This commit is contained in:
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user