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