mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
fix(agent): secure and refresh unrooted log exports (#2131)
## Summary - Keep raw diagnostic staging in private cache and give each exported archive a unique request directory. - Remove that request directory after the CLI pull, including failed transfers. - Replace files from the previous extracted snapshot while preserving unrelated destination files. - Document that the old rooted-device fallback is not scrubbed. Follow-up to #2061. The refreshed export tests now cover independent archive outputs and stale snapshot replacement. ## Validation - `wsl python3 -m unittest tools.tests.test_droiddeckctl` — 49 passed. - `:app:testDebugUnitTest -x buildVkBridgeServer --tests com.droiddeck.launcher.session.SessionLogShareZipTest.anArchiveCanBeWrittenOutsideTheShareFolder` — passed. - `git diff --check` — passed.
This commit is contained in:
9 files changed
+659
-98
No files matched your search
@@ -3,6 +3,7 @@ package com.droiddeck.launcher
|
||||
import android.app.Application
|
||||
import com.droiddeck.launcher.runtime.BwrapSpawner
|
||||
import com.droiddeck.launcher.session.CrashHandler
|
||||
import com.droiddeck.launcher.session.EnhancedSessionLogShare
|
||||
import com.droiddeck.launcher.session.GpuClockPin
|
||||
|
||||
class App : Application() {
|
||||
@@ -16,6 +17,7 @@ class App : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
runCatching { EnhancedSessionLogShare.cleanupAbandonedStages(this) }
|
||||
com.droiddeck.launcher.ui.Themes.loadGradient(this)
|
||||
com.droiddeck.launcher.ui.BuildWatermark.install(this)
|
||||
HomeApp.trackRecents(this)
|
||||
|
||||
@@ -25,6 +25,8 @@ import com.droiddeck.launcher.session.SessionState
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -202,13 +204,38 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
private fun logDir(context: Context): File? =
|
||||
SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: SessionPaths.sessionFolders(context).lastOrNull()
|
||||
|
||||
private fun cleanupExpiredLogExports(exports: File) {
|
||||
val now = System.currentTimeMillis()
|
||||
val canonicalRoot = exports.canonicalFile
|
||||
exports.listFiles()?.forEach { candidate ->
|
||||
val isUuid = runCatching { UUID.fromString(candidate.name).toString() == candidate.name }.getOrDefault(false)
|
||||
if (!isUuid || !candidate.isDirectory || Files.isSymbolicLink(candidate.toPath())) return@forEach
|
||||
if (candidate.canonicalFile.parentFile != canonicalRoot) return@forEach
|
||||
if (now - candidate.lastModified() >= LOG_EXPORT_RETENTION_MS) candidate.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
// The session folder is app-private, so adb pulls this copy from external files instead.
|
||||
private fun logs(context: Context): JSONObject {
|
||||
val dir = logDir(context)?.takeIf { it.isDirectory }
|
||||
?: return error("NO_SESSION_LOGS", "No session artifact directory is available")
|
||||
val zip = SessionLogShare.zipFolder(context, dir, File(context.getExternalFilesDir(null), "agent-logs"))
|
||||
val externalFiles = context.getExternalFilesDir(null)
|
||||
?: return error("NO_SESSION_LOGS", "External app storage is unavailable")
|
||||
val exports = File(externalFiles, "agent-logs")
|
||||
if ((!exports.exists() && !exports.mkdirs()) || !exports.isDirectory) {
|
||||
return error("NO_SESSION_LOGS", "Could not create the session export directory")
|
||||
}
|
||||
cleanupExpiredLogExports(exports)
|
||||
val output = File(exports, UUID.randomUUID().toString())
|
||||
val zip = try {
|
||||
SessionLogShare.zipFolder(context, dir, output)
|
||||
} catch (error: Exception) {
|
||||
output.deleteRecursively()
|
||||
throw error
|
||||
}
|
||||
?: return error("NO_SESSION_LOGS", "The session artifact directory is empty")
|
||||
return JSONObject().put("ok", true).put("command", "logs").put("sessionId", dir.name).put("zip", zip.absolutePath)
|
||||
return JSONObject().put("ok", true).put("command", "logs").put("sessionId", dir.name)
|
||||
.put("zip", zip.absolutePath).put("cleanupDir", output.absolutePath)
|
||||
}
|
||||
|
||||
private fun state(context: Context): JSONObject {
|
||||
@@ -365,6 +392,7 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
const val RESULT_JSON = "json"
|
||||
const val EXTRA_REQUEST = "request"
|
||||
const val SCHEMA = 2
|
||||
private const val LOG_EXPORT_RETENTION_MS = 24 * 60 * 60 * 1000L
|
||||
private val recoveryComplete = CountDownLatch(1)
|
||||
|
||||
fun awaitRecovery() {
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.FileInputStream
|
||||
@@ -18,15 +19,19 @@ import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.Channels
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.channels.FileLock
|
||||
import java.nio.channels.OverlappingFileLockException
|
||||
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.StandardOpenOption
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
@@ -44,6 +49,24 @@ internal interface SecureSourceOpener {
|
||||
internal object EnhancedSessionLogShare {
|
||||
private enum class Category(val suffix: String) { SESSION("session"), GRAPHICS("graphics"), GAMES("games") }
|
||||
|
||||
internal class DiagnosticStageLease internal constructor(
|
||||
val directory: File,
|
||||
private val channel: FileChannel,
|
||||
private val lock: FileLock,
|
||||
) : Closeable {
|
||||
override fun close() {
|
||||
try {
|
||||
lock.release()
|
||||
} finally {
|
||||
try {
|
||||
channel.close()
|
||||
} finally {
|
||||
directory.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class Candidate(
|
||||
val file: File,
|
||||
val anchor: File,
|
||||
@@ -64,6 +87,10 @@ internal object EnhancedSessionLogShare {
|
||||
|
||||
private data class Snapshot(val file: File, val sourceBytes: Long, val truncated: Boolean)
|
||||
|
||||
private class StageUnavailableException(cause: Throwable) : IOException("Diagnostic staging directory is unavailable", cause)
|
||||
|
||||
private class SourceReadException(cause: Throwable) : IOException("Could not read diagnostic source", cause)
|
||||
|
||||
private data class CollectionResult(
|
||||
val candidates: List<Candidate>,
|
||||
val truncated: Boolean,
|
||||
@@ -89,6 +116,70 @@ internal object EnhancedSessionLogShare {
|
||||
private val sourceOpenerOverride = object : ThreadLocal<SecureSourceOpener?>() {
|
||||
override fun initialValue(): SecureSourceOpener? = null
|
||||
}
|
||||
private val stageLifecycleMonitor = Any()
|
||||
|
||||
internal fun acquireDiagnosticStage(context: Context, outputName: String): DiagnosticStageLease {
|
||||
val cache = context.cacheDir
|
||||
require(outputName.isNotBlank() && File(outputName).name == outputName)
|
||||
return withStageLifecycleLock(cache) {
|
||||
val stage = File(cache, "$outputName-${UUID.randomUUID()}.diagnostic-stage")
|
||||
if (!stage.mkdir()) throw IOException("Could not create the diagnostic staging directory")
|
||||
val channel = try {
|
||||
FileChannel.open(File(stage, ".active").toPath(), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)
|
||||
} catch (error: Exception) {
|
||||
stage.deleteRecursively()
|
||||
throw IOException("Could not create the diagnostic staging lock", error)
|
||||
}
|
||||
try {
|
||||
val lock = channel.tryLock()
|
||||
?: throw IOException("Could not lock the diagnostic staging directory")
|
||||
DiagnosticStageLease(stage, channel, lock)
|
||||
} catch (error: Exception) {
|
||||
channel.close()
|
||||
stage.deleteRecursively()
|
||||
throw IOException("Could not lock the diagnostic staging directory", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun cleanupAbandonedStages(context: Context) {
|
||||
val cache = context.cacheDir
|
||||
val canonicalCache = cache.canonicalFile
|
||||
withStageLifecycleLock(cache) {
|
||||
cache.listFiles()?.forEach { stage ->
|
||||
if (!stage.isDirectory || Files.isSymbolicLink(stage.toPath()) ||
|
||||
stage.canonicalFile.parentFile != canonicalCache || !isDiagnosticStage(stage.name)) return@forEach
|
||||
val active = File(stage, ".active")
|
||||
if (Files.isSymbolicLink(active.toPath())) return@forEach
|
||||
val channel = runCatching {
|
||||
FileChannel.open(active.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE)
|
||||
}.getOrNull() ?: return@forEach
|
||||
val abandoned = try {
|
||||
channel.tryLock()?.let { lock -> lock.release(); true } ?: false
|
||||
} catch (_: OverlappingFileLockException) {
|
||||
false
|
||||
} finally {
|
||||
channel.close()
|
||||
}
|
||||
if (abandoned) stage.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDiagnosticStage(name: String): Boolean {
|
||||
val suffix = ".diagnostic-stage"
|
||||
if (!name.endsWith(suffix)) return false
|
||||
val base = name.removeSuffix(suffix)
|
||||
val uuidStart = base.length - 36
|
||||
if (uuidStart < 2 || base[uuidStart - 1] != '-') return false
|
||||
return runCatching { UUID.fromString(base.substring(uuidStart)).toString() == base.substring(uuidStart) }
|
||||
.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun <T> withStageLifecycleLock(cache: File, block: () -> T): T = synchronized(stageLifecycleMonitor) {
|
||||
FileChannel.open(File(cache, ".diagnostic-stages.lock").toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE)
|
||||
.use { channel -> channel.lock().use { block() } }
|
||||
}
|
||||
|
||||
internal fun setSourceOpenerForTest(opener: SecureSourceOpener?) {
|
||||
if (opener == null) sourceOpenerOverride.remove() else sourceOpenerOverride.set(opener)
|
||||
@@ -107,17 +198,18 @@ internal object EnhancedSessionLogShare {
|
||||
return emptyList()
|
||||
}
|
||||
val out = SessionLogShare.shareDir(context).apply { deleteRecursively(); mkdirs() }
|
||||
val stage = File(out, ".diagnostic-stage").apply { mkdirs() }
|
||||
val redactionLearningComplete = LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
val discovery = collect(context, folder, limits.maxDiscoveredFiles, legacy = false)
|
||||
val candidates = discovery.candidates
|
||||
val totalCandidates = candidates.size.coerceAtLeast(1)
|
||||
val share = limits.maxBytesTotal / Category.values().size
|
||||
val budgets = Category.values().associateWith { category ->
|
||||
Budget(if (category == Category.GAMES) limits.maxBytesTotal - share * 2 else share)
|
||||
}
|
||||
var completed = 0
|
||||
val stageLease = acquireDiagnosticStage(context, out.name)
|
||||
val stage = stageLease.directory
|
||||
return try {
|
||||
val redactionLearningComplete = LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
val discovery = collect(context, folder, limits.maxDiscoveredFiles, legacy = false)
|
||||
val candidates = discovery.candidates
|
||||
val totalCandidates = candidates.size.coerceAtLeast(1)
|
||||
val share = limits.maxBytesTotal / Category.values().size
|
||||
val budgets = Category.values().associateWith { category ->
|
||||
Budget(if (category == Category.GAMES) limits.maxBytesTotal - share * 2 else share)
|
||||
}
|
||||
var completed = 0
|
||||
Category.values().map { category ->
|
||||
val budget = budgets.getValue(category)
|
||||
val records = ArrayList<Record>()
|
||||
@@ -150,7 +242,7 @@ internal object EnhancedSessionLogShare {
|
||||
zip
|
||||
}
|
||||
} finally {
|
||||
stage.deleteRecursively()
|
||||
stageLease.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +265,8 @@ internal object EnhancedSessionLogShare {
|
||||
out.deleteRecursively()
|
||||
return null
|
||||
}
|
||||
val stage = File(out, ".diagnostic-stage").apply { mkdirs() }
|
||||
val stageLease = acquireDiagnosticStage(context, out.name)
|
||||
val stage = stageLease.directory
|
||||
val records = ArrayList<Record>()
|
||||
val budget = Budget(limits.maxBytesTotal)
|
||||
val zip = File(out, "DroidDeck-${folder.name}.zip")
|
||||
@@ -199,7 +292,7 @@ internal object EnhancedSessionLogShare {
|
||||
}
|
||||
zip
|
||||
} finally {
|
||||
stage.deleteRecursively()
|
||||
stageLease.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,10 +320,15 @@ internal object EnhancedSessionLogShare {
|
||||
if (budget.remaining <= 0) {
|
||||
return Record(candidate.entry, candidate.origin, "omitted", reason = "bundle-byte-limit")
|
||||
}
|
||||
val snapshot = runCatching {
|
||||
if (!stage.isDirectory || !stage.canWrite()) throw StageUnavailableException(IOException("staging path is not writable"))
|
||||
val snapshot = try {
|
||||
snapshot(candidate, stage, minOf(limits.maxBytesPerFile, budget.remaining))
|
||||
}.getOrElse {
|
||||
return Record(candidate.entry, candidate.origin, "error", reason = it.javaClass.simpleName)
|
||||
} catch (error: StageUnavailableException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
if (!stage.isDirectory || !stage.canWrite()) throw StageUnavailableException(error)
|
||||
val reportedError = (error as? SourceReadException)?.cause ?: error
|
||||
return Record(candidate.entry, candidate.origin, "error", reason = reportedError.javaClass.simpleName)
|
||||
} ?: return Record(candidate.entry, candidate.origin, "omitted", reason = "unsafe-or-unavailable-source")
|
||||
if (snapshot.file.length() == 0L && candidate.linePrefix != null) {
|
||||
snapshot.file.delete()
|
||||
@@ -266,32 +364,44 @@ internal object EnhancedSessionLogShare {
|
||||
val rootPath = candidate.anchor.toPath().toAbsolutePath().normalize()
|
||||
val sourcePath = candidate.file.toPath().toAbsolutePath().normalize()
|
||||
if (!sourcePath.startsWith(rootPath) || sourcePath == rootPath || limit < 1) return null
|
||||
val target = File.createTempFile("diagnostic-", ".log", stage)
|
||||
val target = try {
|
||||
File.createTempFile("diagnostic-", ".log", stage)
|
||||
} catch (error: Exception) {
|
||||
throw StageUnavailableException(error)
|
||||
}
|
||||
try {
|
||||
val relative = rootPath.relativize(sourcePath)
|
||||
return withSecureChannel(rootPath, relative) { input ->
|
||||
val size = input.size()
|
||||
val size = readSource { input.size() }
|
||||
candidate.linePrefix?.let { prefix ->
|
||||
val truncated = filterChannel(input, target, prefix, size, TRACE_SCAN_BYTES, limit)
|
||||
return@withSecureChannel Snapshot(target, size, truncated)
|
||||
}
|
||||
FileOutputStream(target).channel.use { output ->
|
||||
if (size <= limit) {
|
||||
copy(input, output, 0, size)
|
||||
} else {
|
||||
val marker = "\n<diagnostic file truncated: beginning and tail retained>\n".toByteArray()
|
||||
val content = (limit - marker.size).coerceAtLeast(0)
|
||||
copyCompleteHead(input, output, content / 2)
|
||||
val markerBytes = minOf(marker.size.toLong(), limit - output.position()).toInt()
|
||||
output.write(ByteBuffer.wrap(marker, 0, markerBytes))
|
||||
copyCompleteTail(input, output, size, (limit - output.position()).coerceAtLeast(0))
|
||||
writeStage {
|
||||
FileOutputStream(target).channel.use { output ->
|
||||
if (size <= limit) {
|
||||
copy(input, output, 0, size)
|
||||
} else {
|
||||
val marker = "\n<diagnostic file truncated: beginning and tail retained>\n".toByteArray()
|
||||
val content = (limit - marker.size).coerceAtLeast(0)
|
||||
copyCompleteHead(input, output, content / 2)
|
||||
val markerBytes = minOf(marker.size.toLong(), limit - output.position()).toInt()
|
||||
output.write(ByteBuffer.wrap(marker, 0, markerBytes))
|
||||
copyCompleteTail(input, output, size, (limit - output.position()).coerceAtLeast(0))
|
||||
}
|
||||
output.force(false)
|
||||
}
|
||||
output.force(false)
|
||||
}
|
||||
Snapshot(target, size, size > limit)
|
||||
} ?: target.delete().let { null }
|
||||
} catch (e: Exception) {
|
||||
if (e is StageUnavailableException) {
|
||||
target.delete()
|
||||
throw e
|
||||
}
|
||||
val targetWasPresent = target.exists()
|
||||
target.delete()
|
||||
if (!stage.isDirectory || !stage.canWrite() || !targetWasPresent) throw StageUnavailableException(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
@@ -307,48 +417,51 @@ internal object EnhancedSessionLogShare {
|
||||
val wanted = prefix.toByteArray()
|
||||
var written = 0L
|
||||
var truncated = sourceSize > scanLimit
|
||||
target.outputStream().buffered().use { output ->
|
||||
fun scan(start: Long, count: Long, discardPartialFirstLine: Boolean) {
|
||||
channel.position(start)
|
||||
val input = BufferedInputStream(Channels.newInputStream(channel), COPY_BUFFER_BYTES)
|
||||
val line = ByteArrayOutputStream(1024)
|
||||
var oversized = false
|
||||
var scanned = 0L
|
||||
var discard = discardPartialFirstLine
|
||||
fun emit() {
|
||||
val bytes = line.toByteArray()
|
||||
if (!discard && !oversized && bytes.size >= wanted.size && wanted.indices.all { bytes[it] == wanted[it] }) {
|
||||
if (written + bytes.size + 1 <= outputLimit) {
|
||||
output.write(bytes)
|
||||
output.write('\n'.code)
|
||||
written += bytes.size + 1
|
||||
} else truncated = true
|
||||
writeStage {
|
||||
target.outputStream().buffered().use { output ->
|
||||
fun scan(start: Long, count: Long, discardPartialFirstLine: Boolean) {
|
||||
readSource { channel.position(start) }
|
||||
val input = BufferedInputStream(Channels.newInputStream(channel), COPY_BUFFER_BYTES)
|
||||
val line = ByteArrayOutputStream(1024)
|
||||
var oversized = false
|
||||
var scanned = 0L
|
||||
var discard = discardPartialFirstLine
|
||||
fun emit() {
|
||||
val bytes = line.toByteArray()
|
||||
if (!discard && !oversized && bytes.size >= wanted.size && wanted.indices.all { bytes[it] == wanted[it] }) {
|
||||
if (written + bytes.size + 1 <= outputLimit) {
|
||||
output.write(bytes)
|
||||
output.write('\n'.code)
|
||||
written += bytes.size + 1
|
||||
} else truncated = true
|
||||
}
|
||||
line.reset()
|
||||
oversized = false
|
||||
discard = false
|
||||
}
|
||||
line.reset()
|
||||
oversized = false
|
||||
discard = false
|
||||
}
|
||||
while (scanned < count) {
|
||||
val value = input.read()
|
||||
if (value < 0) break
|
||||
scanned++
|
||||
if (value == '\n'.code) emit()
|
||||
else if (!oversized && !discard) {
|
||||
if (line.size() >= LogRedactor.MAX_LINE_LENGTH) {
|
||||
oversized = true
|
||||
line.reset()
|
||||
} else line.write(value)
|
||||
while (scanned < count) {
|
||||
val value = readSource { input.read() }
|
||||
if (value < 0) break
|
||||
scanned++
|
||||
if (value == '\n'.code) emit()
|
||||
else if (!oversized && !discard) {
|
||||
if (line.size() >= LogRedactor.MAX_LINE_LENGTH) {
|
||||
oversized = true
|
||||
line.reset()
|
||||
} else line.write(value)
|
||||
}
|
||||
}
|
||||
if (start + scanned >= sourceSize && (line.size() > 0 || oversized)) emit()
|
||||
}
|
||||
|
||||
if (sourceSize <= scanLimit) {
|
||||
scan(0, sourceSize, false)
|
||||
} else {
|
||||
val head = scanLimit / 2
|
||||
val tail = scanLimit - head
|
||||
scan(0, head, false)
|
||||
scan((sourceSize - tail).coerceAtLeast(0), tail, true)
|
||||
}
|
||||
if (start + scanned >= sourceSize && (line.size() > 0 || oversized)) emit()
|
||||
}
|
||||
if (sourceSize <= scanLimit) {
|
||||
scan(0, sourceSize, false)
|
||||
} else {
|
||||
val head = scanLimit / 2
|
||||
val tail = scanLimit - head
|
||||
scan(0, head, false)
|
||||
scan((sourceSize - tail).coerceAtLeast(0), tail, true)
|
||||
}
|
||||
}
|
||||
return truncated
|
||||
@@ -361,6 +474,7 @@ internal object EnhancedSessionLogShare {
|
||||
|
||||
private fun <T> withAndroidSecureChannel(root: Path, relative: Path, block: (SeekableByteChannel) -> T): T? {
|
||||
val opened = ArrayList<ParcelFileDescriptor>()
|
||||
var invokingBlock = false
|
||||
return try {
|
||||
val parts = relative.map { it.toString() }
|
||||
if (parts.isEmpty()) return null
|
||||
@@ -373,11 +487,15 @@ internal object EnhancedSessionLogShare {
|
||||
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) }
|
||||
FileInputStream(leafFd).use { stream ->
|
||||
invokingBlock = true
|
||||
block(stream.channel)
|
||||
}
|
||||
} finally {
|
||||
runCatching { Os.close(leafFd) }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
} catch (error: Exception) {
|
||||
if (invokingBlock) throw error
|
||||
null
|
||||
} finally {
|
||||
opened.asReversed().forEach { runCatching { it.close() } }
|
||||
@@ -406,16 +524,16 @@ internal object EnhancedSessionLogShare {
|
||||
"/proc/self/fd/${directory.fd}/$name"
|
||||
|
||||
private fun copy(input: SeekableByteChannel, output: FileChannel, start: Long, count: Long) {
|
||||
input.position(start)
|
||||
readSource { input.position(start) }
|
||||
var left = count
|
||||
val buffer = ByteBuffer.allocate(64 * 1024)
|
||||
while (left > 0) {
|
||||
buffer.clear()
|
||||
buffer.limit(minOf(buffer.capacity().toLong(), left).toInt())
|
||||
val read = input.read(buffer)
|
||||
val read = readSource { input.read(buffer) }
|
||||
if (read < 0) break
|
||||
buffer.flip()
|
||||
while (buffer.hasRemaining()) output.write(buffer)
|
||||
while (buffer.hasRemaining()) writeStage { output.write(buffer) }
|
||||
left -= read
|
||||
}
|
||||
}
|
||||
@@ -424,14 +542,14 @@ internal object EnhancedSessionLogShare {
|
||||
if (limit <= 0) return
|
||||
copy(input, output, 0, limit)
|
||||
val copied = output.position()
|
||||
input.position(0)
|
||||
readSource { input.position(0) }
|
||||
val buffer = ByteBuffer.allocate(64 * 1024)
|
||||
var scanned = 0L
|
||||
var lastComplete = 0L
|
||||
while (scanned < copied) {
|
||||
buffer.clear()
|
||||
buffer.limit(minOf(buffer.capacity().toLong(), copied - scanned).toInt())
|
||||
val read = input.read(buffer)
|
||||
val read = readSource { input.read(buffer) }
|
||||
if (read < 0) break
|
||||
buffer.flip()
|
||||
repeat(read) { index ->
|
||||
@@ -439,8 +557,8 @@ internal object EnhancedSessionLogShare {
|
||||
}
|
||||
scanned += read
|
||||
}
|
||||
output.truncate(lastComplete)
|
||||
output.position(lastComplete)
|
||||
writeStage { output.truncate(lastComplete) }
|
||||
writeStage { output.position(lastComplete) }
|
||||
}
|
||||
|
||||
private fun copyCompleteTail(
|
||||
@@ -452,10 +570,10 @@ internal object EnhancedSessionLogShare {
|
||||
if (limit <= 0 || sourceSize <= 0) return
|
||||
var start = (sourceSize - limit).coerceAtLeast(0)
|
||||
if (start > 0) {
|
||||
input.position(start)
|
||||
readSource { input.position(start) }
|
||||
val buffered = BufferedInputStream(Channels.newInputStream(input), COPY_BUFFER_BYTES)
|
||||
while (start < sourceSize) {
|
||||
val value = buffered.read()
|
||||
val value = readSource { buffered.read() }
|
||||
if (value < 0) return
|
||||
start++
|
||||
if (value == '\n'.code) break
|
||||
@@ -464,6 +582,22 @@ internal object EnhancedSessionLogShare {
|
||||
copy(input, output, start, (sourceSize - start).coerceAtMost(limit))
|
||||
}
|
||||
|
||||
private inline fun <T> readSource(block: () -> T): T = try {
|
||||
block()
|
||||
} catch (error: Exception) {
|
||||
throw SourceReadException(error)
|
||||
}
|
||||
|
||||
private inline fun <T> writeStage(block: () -> T): T = try {
|
||||
block()
|
||||
} catch (error: SourceReadException) {
|
||||
throw error
|
||||
} catch (error: StageUnavailableException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
throw StageUnavailableException(error)
|
||||
}
|
||||
|
||||
private fun addManifest(
|
||||
archive: ZipOutputStream,
|
||||
sessionId: String,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.os.Bundle
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.session.installJvmSecureSourceOpener
|
||||
import com.droiddeck.launcher.session.resetSecureSourceOpener
|
||||
import org.json.JSONObject
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33], manifest = Config.NONE)
|
||||
class AgentLogExportTest {
|
||||
private val context get() = RuntimeEnvironment.getApplication()
|
||||
private var previousLogDirectory: File? = null
|
||||
|
||||
@Before fun prepare() {
|
||||
previousLogDirectory = SessionState.logDirectory
|
||||
installJvmSecureSourceOpener()
|
||||
}
|
||||
|
||||
@After fun cleanup() {
|
||||
SessionState.logDirectory = previousLogDirectory
|
||||
resetSecureSourceOpener()
|
||||
}
|
||||
|
||||
@Test fun independentLogRequestsKeepBothExportDirectories() {
|
||||
val session = File(context.cacheDir, "agent-session").apply { mkdirs() }
|
||||
File(session, "session.log").writeText("booted\n")
|
||||
SessionState.logDirectory = session
|
||||
val provider = Robolectric.buildContentProvider(AgentBridgeProvider::class.java).create().get()
|
||||
var firstDir: File? = null
|
||||
var secondDir: File? = null
|
||||
|
||||
try {
|
||||
fun requestLogs() = JSONObject(provider.call("logs", null, Bundle()).getString("json")!!)
|
||||
val first = requestLogs()
|
||||
val second = requestLogs()
|
||||
firstDir = File(first.getString("cleanupDir"))
|
||||
secondDir = File(second.getString("cleanupDir"))
|
||||
val firstZip = File(first.getString("zip"))
|
||||
val secondZip = File(second.getString("zip"))
|
||||
|
||||
assertNotEquals(firstDir, secondDir)
|
||||
assertEquals(firstDir, firstZip.parentFile)
|
||||
assertEquals(secondDir, secondZip.parentFile)
|
||||
assertTrue(firstZip.isFile)
|
||||
assertTrue(secondZip.isFile)
|
||||
} finally {
|
||||
firstDir?.deleteRecursively()
|
||||
secondDir?.deleteRecursively()
|
||||
session.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.IOException
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
@@ -497,4 +498,35 @@ class EnhancedSessionLogShareTest {
|
||||
assertFalse(EnhancedSessionLogShare.hasSourceOpenerForTest())
|
||||
SessionLogShare.clear(app)
|
||||
}
|
||||
|
||||
@Test fun sourceReadFailureIsRecordedWithoutFailingTheArchive() {
|
||||
val session = tmp.newFolder("2026-11-04-16-steam")
|
||||
session.resolve("session.log").writeText("must not be shared\n")
|
||||
EnhancedSessionLogShare.setSourceOpenerForTest(object : SecureSourceOpener {
|
||||
override fun <T> withChannel(
|
||||
root: java.nio.file.Path,
|
||||
relative: java.nio.file.Path,
|
||||
block: (java.nio.channels.SeekableByteChannel) -> T,
|
||||
): T? = FileChannel.open(root.resolve(relative), StandardOpenOption.READ).use { channel ->
|
||||
block(object : SeekableByteChannel by channel {
|
||||
override fun read(dst: ByteBuffer): Int = throw IOException("injected source read failure")
|
||||
})
|
||||
}
|
||||
})
|
||||
try {
|
||||
val sessionZip = EnhancedSessionLogShare.create(app, session).single { it.name.endsWith("-session.zip") }
|
||||
ZipFile(sessionZip).use { archive ->
|
||||
assertFalse(archive.entries().toList().any { it.name.endsWith("session.log") })
|
||||
val manifest = JSONObject(archive.getInputStream(archive.getEntry("diagnostics-manifest.json"))
|
||||
.bufferedReader().readText()).getJSONArray("entries")
|
||||
val record = (0 until manifest.length()).map { manifest.getJSONObject(it) }
|
||||
.single { it.optString("entry").endsWith("session.log") }
|
||||
assertEquals("error", record.getString("status"))
|
||||
assertEquals("IOException", record.getString("reason"))
|
||||
}
|
||||
} finally {
|
||||
resetSecureSourceOpener()
|
||||
}
|
||||
SessionLogShare.clear(app)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.session
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.UUID
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.zip.ZipFile
|
||||
@@ -26,18 +27,35 @@ class SessionLogShareZipTest {
|
||||
|
||||
@After fun resetSourceOpener() = resetSecureSourceOpener()
|
||||
|
||||
@Test fun startupRemovesAbandonedDiagnosticStagesAndPreservesActiveSnapshots() {
|
||||
val abandoned = File(app.cacheDir, "share-${UUID.randomUUID()}.diagnostic-stage").apply { mkdirs() }
|
||||
val active = EnhancedSessionLogShare.acquireDiagnosticStage(app, "share")
|
||||
try {
|
||||
EnhancedSessionLogShare.cleanupAbandonedStages(app)
|
||||
assertFalse(abandoned.exists())
|
||||
assertTrue(active.directory.isDirectory)
|
||||
} finally {
|
||||
active.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun anArchiveCanBeWrittenOutsideTheShareFolder() {
|
||||
SessionLogShare.clear(app)
|
||||
val session = File(LinuxRuntime.logDir(app), "2026-11-03-01-steam").apply { mkdirs() }
|
||||
val out = File(app.getExternalFilesDir(null), "agent-logs")
|
||||
val out = File(app.getExternalFilesDir(null), "agent-logs/request-1")
|
||||
val secondOut = File(app.getExternalFilesDir(null), "agent-logs/request-2")
|
||||
try {
|
||||
File(session, "session.log").writeText("booted\n")
|
||||
val zip = SessionLogShare.zipFolder(app, session, out)!!
|
||||
val secondZip = SessionLogShare.zipFolder(app, session, secondOut)!!
|
||||
assertEquals(out, zip.parentFile)
|
||||
assertFalse(File(app.cacheDir, "share").exists())
|
||||
assertTrue(zip.isFile)
|
||||
assertEquals(secondOut, secondZip.parentFile)
|
||||
assertTrue(secondZip.isFile)
|
||||
} finally {
|
||||
out.deleteRecursively()
|
||||
secondOut.deleteRecursively()
|
||||
session.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ text. This does not redact arbitrary authorized command results, CDP data, prefe
|
||||
captures. Scenario directories are owner-only and JSON reports are written with mode 0600. Review
|
||||
artifacts before sharing. `logs` downloads the archive Share logs builds (scrubbed, Steam logs over
|
||||
8 MiB left out) from the app's external files folder, since adb cannot read the session folder itself.
|
||||
When the installed app predates archive export, the CLI falls back to pulling the private session folder;
|
||||
that legacy path is available only with root and is not scrubbed before download.
|
||||
|
||||
## Device selection and output
|
||||
|
||||
@@ -90,6 +92,10 @@ Every command writes one JSON object to stdout and its resolved serial to stderr
|
||||
success, 2 invalid or rejected command, 3 ADB or device error, 4 session error, 5 timeout, 6
|
||||
artifact or file error, 7 debugging tools disabled.
|
||||
|
||||
When upgrading the CLI, use a new destination for the first pull if the old destination contains
|
||||
archives created by an older CLI without a `.droiddeckctl-managed-logs.json` manifest and their
|
||||
filenames overlap. The upgraded CLI refuses to overwrite those files to preserve unrelated data.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
|
||||
+134
-11
@@ -18,7 +18,7 @@ import time
|
||||
import uuid
|
||||
import zipfile
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from adb_device import DeviceResolutionError, resolve_serial
|
||||
@@ -424,18 +424,106 @@ def start_session(adb: str, serial: str, request: dict) -> dict:
|
||||
|
||||
def unpack_logs(archive: Path, destination: Path, session_id: str) -> None:
|
||||
"""Unpack a Share logs zip so the session's own files sit at the top of destination."""
|
||||
destination = destination.resolve()
|
||||
try:
|
||||
with zipfile.ZipFile(archive) as z:
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise ControlError(f"Could not unpack the session logs: {exc}", "LOG_PULL_FAILED") from exc
|
||||
manifest = destination / ".droiddeckctl-managed-logs.json"
|
||||
try:
|
||||
previous = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
if not isinstance(previous, list) or any(not isinstance(name, str) for name in previous):
|
||||
previous = []
|
||||
except (OSError, ValueError):
|
||||
previous = []
|
||||
previous = set(previous)
|
||||
previous.discard(manifest.name)
|
||||
extracted = set()
|
||||
|
||||
def managed_target(name: str) -> Path | None:
|
||||
relative = Path(name)
|
||||
if relative.is_absolute() or not relative.parts or any(part in {".", ".."} for part in relative.parts):
|
||||
return None
|
||||
target = destination
|
||||
for part in relative.parts:
|
||||
target = target / part
|
||||
if target.is_symlink():
|
||||
return None
|
||||
return target if target.resolve().is_relative_to(destination) else None
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix=".droiddeckctl-logs-", dir=destination) as temp, zipfile.ZipFile(archive) as z:
|
||||
staged = Path(temp)
|
||||
staged_files = staged / "new"
|
||||
backups = staged / "old"
|
||||
staged_files.mkdir()
|
||||
for entry in z.infolist():
|
||||
name = entry.filename
|
||||
if name.startswith(session_id + "/"):
|
||||
name = name[len(session_id) + 1:]
|
||||
target = (destination / name).resolve()
|
||||
if entry.is_dir() or not name or not target.is_relative_to(destination):
|
||||
target = managed_target(name)
|
||||
if entry.is_dir() or target is None or target == manifest:
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with z.open(entry) as source, target.open("wb") as copy:
|
||||
relative = target.relative_to(destination).as_posix()
|
||||
staged_file = staged_files / relative
|
||||
staged_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with z.open(entry) as source, staged_file.open("wb") as copy:
|
||||
shutil.copyfileobj(source, copy)
|
||||
extracted.add(relative)
|
||||
for name in extracted - previous:
|
||||
target = managed_target(name)
|
||||
if target is None or target.exists():
|
||||
raise ControlError(f"Refusing to overwrite an unrelated file: {name}", "LOG_PULL_FAILED")
|
||||
if manifest.is_symlink() or (manifest.exists() and not manifest.is_file()):
|
||||
raise ControlError("The managed log list is not a regular file", "LOG_PULL_FAILED")
|
||||
backed_up = {}
|
||||
installed = set()
|
||||
manifest_backup = backups / manifest.name
|
||||
manifest_fd, manifest_path = tempfile.mkstemp(
|
||||
prefix=".droiddeckctl-managed-logs-", suffix=".tmp", dir=destination
|
||||
)
|
||||
temp_manifest = Path(manifest_path)
|
||||
try:
|
||||
with os.fdopen(manifest_fd, "w", encoding="utf-8") as manifest_file:
|
||||
manifest_file.write(json.dumps(sorted(extracted), indent=2) + "\n")
|
||||
except Exception:
|
||||
temp_manifest.unlink(missing_ok=True)
|
||||
raise
|
||||
try:
|
||||
for name in sorted(previous):
|
||||
target = managed_target(name)
|
||||
if target is not None and target.is_file():
|
||||
backup = backups / name
|
||||
backup.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(target, backup)
|
||||
backed_up[name] = backup
|
||||
if manifest.is_file():
|
||||
manifest_backup.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(manifest, manifest_backup)
|
||||
for name in sorted(extracted):
|
||||
target = managed_target(name)
|
||||
if target is None:
|
||||
raise ControlError(f"A log destination became unsafe: {name}", "LOG_PULL_FAILED")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(staged_files / name, target)
|
||||
installed.add(name)
|
||||
os.replace(temp_manifest, manifest)
|
||||
except Exception:
|
||||
temp_manifest.unlink(missing_ok=True)
|
||||
for name in installed:
|
||||
target = managed_target(name)
|
||||
if target is not None and target.is_file():
|
||||
target.unlink()
|
||||
if manifest_backup.is_file() and manifest.exists() and not manifest.is_symlink():
|
||||
manifest.unlink()
|
||||
for name, backup in backed_up.items():
|
||||
target = managed_target(name)
|
||||
if target is not None:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(backup, target)
|
||||
if manifest_backup.is_file():
|
||||
os.replace(manifest_backup, manifest)
|
||||
raise
|
||||
except (OSError, zipfile.BadZipFile) as exc:
|
||||
raise ControlError(f"Could not unpack the session logs: {exc}", "LOG_PULL_FAILED") from exc
|
||||
|
||||
@@ -761,11 +849,37 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict:
|
||||
raise ControlError("No session artifact directory is available", "NO_SESSION_LOGS")
|
||||
destination = Path(args.destination or session["id"]).expanduser().resolve()
|
||||
exported = provider_call(adb, serial, "logs", timeout=args.timeout + 40)
|
||||
cleanup_error = None
|
||||
if (exported.get("error") or {}).get("code") == "UNKNOWN_COMMAND":
|
||||
# Older builds have no logs method; their private folder only pulls on a rooted device.
|
||||
result = run_adb(adb, serial, "pull", remote_dir, str(destination), timeout=120)
|
||||
else:
|
||||
zip_path = checked(exported, "LOG_PULL_FAILED")["zip"]
|
||||
remote_zip = PurePosixPath(zip_path)
|
||||
if (not remote_zip.is_absolute() or ".." in remote_zip.parts or
|
||||
not re.fullmatch(r"/[A-Za-z0-9_./-]+", zip_path)):
|
||||
raise ControlError("The exported logs path is invalid", "LOG_PULL_FAILED")
|
||||
requested_dir = exported.get("cleanupDir")
|
||||
request_dir = PurePosixPath(requested_dir) if isinstance(requested_dir, str) else None
|
||||
cleanup = ["shell", "rm", "-f", zip_path]
|
||||
if (request_dir and request_dir.is_absolute() and ".." not in request_dir.parts and
|
||||
request_dir == remote_zip.parent and request_dir.parent.name == "agent-logs" and
|
||||
re.fullmatch(r"[0-9a-fA-F-]{36}", request_dir.name)):
|
||||
cleanup = ["shell", "rm", "-rf", str(request_dir)]
|
||||
def cleanup_export():
|
||||
try:
|
||||
cleaned = run_adb(adb, serial, *cleanup, timeout=30)
|
||||
if cleaned.returncode == 0:
|
||||
return None
|
||||
detail = (cleaned.stderr or cleaned.stdout).decode(errors="replace").strip()
|
||||
except Exception as error:
|
||||
detail = str(error)
|
||||
return ControlError(
|
||||
"Could not remove the exported log archive; it may remain in shared storage" +
|
||||
(f": {detail}" if detail else ""),
|
||||
"LOG_CLEANUP_FAILED",
|
||||
)
|
||||
|
||||
try:
|
||||
if exported.get("sessionId", session["id"]) != session["id"]:
|
||||
raise ControlError("A new session started while the logs were exported; run logs again",
|
||||
@@ -773,14 +887,23 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
local_zip = Path(temp) / "logs.zip"
|
||||
result = run_adb(adb, serial, "pull", zip_path, str(local_zip), timeout=120)
|
||||
if result.returncode == 0:
|
||||
unpack_logs(local_zip, destination, session["id"])
|
||||
finally:
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout).decode(errors="replace").strip()
|
||||
raise ControlError(detail or "adb pull failed", "LOG_PULL_FAILED")
|
||||
unpack_logs(local_zip, destination, session["id"])
|
||||
except Exception:
|
||||
cleanup_error = cleanup_export()
|
||||
if cleanup_error:
|
||||
print(cleanup_error, file=sys.stderr)
|
||||
raise
|
||||
else:
|
||||
# The copy sits in shared storage, so it does not outlive the pull.
|
||||
run_adb(adb, serial, "shell", "rm", "-f", zip_path, timeout=30)
|
||||
cleanup_error = cleanup_export()
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout).decode(errors="replace").strip()
|
||||
raise ControlError(detail or "adb pull failed", "LOG_PULL_FAILED")
|
||||
if cleanup_error:
|
||||
raise cleanup_error
|
||||
return add_serial({"ok": True, "command": "logs", "sessionId": session["id"], "path": str(destination)}, serial)
|
||||
|
||||
if command == "screenshot":
|
||||
@@ -1011,7 +1134,7 @@ EXIT_SESSION = {
|
||||
"NO_STEAM_SESSION", "SESSION_NOT_READY", "AGENT_UNAVAILABLE", "LAUNCH_REJECTED", "NO_PAD", "NO_FRAMES",
|
||||
}
|
||||
EXIT_TIMEOUT = {"TIMEOUT", "ARTIFACT_TIMEOUT"}
|
||||
EXIT_FILE = {"NO_SESSION_LOGS", "LOG_PULL_FAILED", "SCREENSHOT_FAILED", "FILE_IO", "RECORD_FAILED", "PUSH_FAILED",
|
||||
EXIT_FILE = {"NO_SESSION_LOGS", "LOG_PULL_FAILED", "LOG_CLEANUP_FAILED", "SCREENSHOT_FAILED", "FILE_IO", "RECORD_FAILED", "PUSH_FAILED",
|
||||
"PACING_FAILED", "XR_CAPTURE_FAILED"}
|
||||
EXIT_DENIED = {"AGENT_COMMANDS_DISABLED", "PROTECTED_PREFS", "PROTECTED_CONTROL"}
|
||||
|
||||
|
||||
@@ -76,6 +76,9 @@ FAKE_ADB = textwrap.dedent('''\
|
||||
elif args[:1] == ["pull"]:
|
||||
entry["denied"] = True
|
||||
print(f"adb: error: failed to stat remote object '{args[1]}': Permission denied", file=sys.stderr)
|
||||
elif args[:2] == ["shell", "rm"] and config.get("fail_cleanup"):
|
||||
entry["denied"] = True
|
||||
print("adb: error: cleanup denied", file=sys.stderr)
|
||||
with open(config["log"], "a") as log:
|
||||
log.write(json.dumps(entry) + "\\n")
|
||||
sys.exit(1 if entry.get("denied") else 0)
|
||||
@@ -138,7 +141,9 @@ CPUFREQ = textwrap.dedent('''\
|
||||
def battery(tenths):
|
||||
return f"Current Battery Service state:\n level: 80\n temperature: {tenths}\n technology: Li-poly"
|
||||
PRIVATE_LOG_DIR = "/data/user/0/com.droiddeck.launcher/files/logs/sessions/s1"
|
||||
EXPORTED_ZIP = "/storage/emulated/0/Android/data/com.droiddeck.launcher/files/agent-logs/DroidDeck-s1.zip"
|
||||
AGENT_LOGS_DIR = "/storage/emulated/0/Android/data/com.droiddeck.launcher/files/agent-logs"
|
||||
EXPORTED_DIR = AGENT_LOGS_DIR + "/123e4567-e89b-12d3-a456-426614174000"
|
||||
EXPORTED_ZIP = EXPORTED_DIR + "/DroidDeck-s1.zip"
|
||||
|
||||
|
||||
def share_zip(entries):
|
||||
@@ -531,7 +536,8 @@ class DroiddeckctlTest(FakeAdbTest):
|
||||
code, out = self.run_ctl("wait", "cool", "--below", "38")
|
||||
self.assertEqual("THERMAL_FAILED", out["error"]["code"])
|
||||
def test_logs_pull_the_exported_zip_instead_of_the_private_folder(self):
|
||||
self.configure({"state": ended_state(), "logs": {"ok": True, "sessionId": "s1", "zip": EXPORTED_ZIP}},
|
||||
self.configure({"state": ended_state(), "logs": {"ok": True, "sessionId": "s1", "zip": EXPORTED_ZIP,
|
||||
"cleanupDir": EXPORTED_DIR}},
|
||||
files={EXPORTED_ZIP: share_zip({"s1/session.log": "booted", "s1/steam/console.log": "c",
|
||||
"stores/epic.log": "e", "../outside": "x"})})
|
||||
destination = self.dir / "artifacts"
|
||||
@@ -542,16 +548,163 @@ class DroiddeckctlTest(FakeAdbTest):
|
||||
self.assertEqual("e", (destination / "stores" / "epic.log").read_text())
|
||||
self.assertFalse((self.dir / "outside").exists())
|
||||
self.assertEqual([["pull", EXPORTED_ZIP]], [c["args"][:2] for c in self.calls() if c["args"][:1] == ["pull"]])
|
||||
self.assertIn(["shell", "rm", "-f", EXPORTED_ZIP], [c["args"] for c in self.calls()])
|
||||
self.assertIn(["shell", "rm", "-rf", EXPORTED_DIR], [c["args"] for c in self.calls()])
|
||||
|
||||
def test_logs_of_a_session_that_started_meanwhile_are_refused(self):
|
||||
self.configure({"state": ended_state(), "logs": {"ok": True, "sessionId": "s2", "zip": EXPORTED_ZIP}},
|
||||
self.configure({"state": ended_state(), "logs": {"ok": True, "sessionId": "s2", "zip": EXPORTED_ZIP,
|
||||
"cleanupDir": EXPORTED_DIR}},
|
||||
files={EXPORTED_ZIP: share_zip({"s2/session.log": "other"})})
|
||||
code, out = self.run_ctl("logs", "latest", str(self.dir / "artifacts"))
|
||||
self.assertEqual(6, code)
|
||||
self.assertEqual("LOG_PULL_FAILED", out["error"]["code"])
|
||||
self.assertFalse((self.dir / "artifacts").exists())
|
||||
self.assertIn(["shell", "rm", "-f", EXPORTED_ZIP], [c["args"] for c in self.calls()])
|
||||
self.assertIn(["shell", "rm", "-rf", EXPORTED_DIR], [c["args"] for c in self.calls()])
|
||||
|
||||
def test_logs_from_a_legacy_provider_remove_only_the_export_zip(self):
|
||||
legacy_zip = AGENT_LOGS_DIR + "/DroidDeck-s1.zip"
|
||||
self.configure({"state": ended_state(),
|
||||
"logs": {"ok": True, "sessionId": "s1", "zip": legacy_zip}},
|
||||
files={legacy_zip: share_zip({"s1/session.log": "booted"})})
|
||||
code, out = self.run_ctl("logs", "latest", str(self.dir / "artifacts"))
|
||||
self.assertEqual(0, code, out)
|
||||
commands = [c["args"] for c in self.calls()]
|
||||
self.assertIn(["shell", "rm", "-f", legacy_zip], commands)
|
||||
self.assertNotIn(["shell", "rm", "-rf", AGENT_LOGS_DIR], commands)
|
||||
|
||||
def test_logs_report_when_remote_export_cleanup_fails(self):
|
||||
self.configure({"state": ended_state(),
|
||||
"logs": {"ok": True, "sessionId": "s1", "zip": EXPORTED_ZIP,
|
||||
"cleanupDir": EXPORTED_DIR}},
|
||||
fail_cleanup=True, files={EXPORTED_ZIP: share_zip({"s1/session.log": "booted"})})
|
||||
code, out = self.run_ctl("logs", "latest", str(self.dir / "artifacts"))
|
||||
self.assertEqual(6, code, out)
|
||||
self.assertEqual("LOG_CLEANUP_FAILED", out["error"]["code"])
|
||||
self.assertIn("may remain in shared storage", out["error"]["message"])
|
||||
|
||||
def test_logs_keep_pull_failure_primary_when_remote_cleanup_fails(self):
|
||||
self.configure({"state": ended_state(),
|
||||
"logs": {"ok": True, "sessionId": "s1", "zip": EXPORTED_ZIP,
|
||||
"cleanupDir": EXPORTED_DIR}}, fail_cleanup=True)
|
||||
code, out = self.run_ctl("logs", "latest", str(self.dir / "artifacts"))
|
||||
self.assertEqual(6, code)
|
||||
self.assertEqual("LOG_PULL_FAILED", out["error"]["code"])
|
||||
self.assertIn("Permission denied", out["error"]["message"])
|
||||
self.assertIn(["shell", "rm", "-rf", EXPORTED_DIR], [call["args"] for call in self.calls()])
|
||||
|
||||
def test_logs_replace_the_previous_snapshot_and_preserve_unrelated_files(self):
|
||||
destination = self.dir / "artifacts"
|
||||
destination.mkdir()
|
||||
(destination / "notes.txt").write_text("keep me")
|
||||
first = self.dir / "first.zip"
|
||||
with zipfile.ZipFile(first, "w") as archive:
|
||||
archive.writestr("s1/session.log", "first")
|
||||
archive.writestr("s1/steam/console.log", "old")
|
||||
ctl.unpack_logs(first, destination, "s1")
|
||||
self.assertEqual("old", (destination / "steam/console.log").read_text())
|
||||
|
||||
second = self.dir / "second.zip"
|
||||
with zipfile.ZipFile(second, "w") as archive:
|
||||
archive.writestr("s1/session.log", "second")
|
||||
ctl.unpack_logs(second, destination, "s1")
|
||||
self.assertEqual("second", (destination / "session.log").read_text())
|
||||
self.assertFalse((destination / "steam/console.log").exists())
|
||||
self.assertEqual("keep me", (destination / "notes.txt").read_text())
|
||||
|
||||
def test_logs_map_destination_creation_failure_to_pull_error(self):
|
||||
destination = (self.dir / "artifacts").resolve()
|
||||
archive = self.dir / "logs.zip"
|
||||
real_mkdir = Path.mkdir
|
||||
|
||||
def fail_destination(path, *args, **kwargs):
|
||||
if path == destination:
|
||||
raise PermissionError("injected destination failure")
|
||||
return real_mkdir(path, *args, **kwargs)
|
||||
|
||||
with unittest.mock.patch.object(Path, "mkdir", new=fail_destination):
|
||||
with self.assertRaises(ctl.ControlError) as raised:
|
||||
ctl.unpack_logs(archive, destination, "s1")
|
||||
|
||||
self.assertEqual("LOG_PULL_FAILED", raised.exception.code)
|
||||
self.assertIn("injected destination failure", str(raised.exception))
|
||||
|
||||
def test_logs_do_not_follow_a_stale_managed_symlink(self):
|
||||
if os.name == "nt":
|
||||
self.skipTest("symlink creation is unavailable on this Windows host")
|
||||
destination = self.dir / "artifacts"
|
||||
destination.mkdir()
|
||||
victim = destination / "notes.txt"
|
||||
victim.write_text("keep me")
|
||||
(destination / ".droiddeckctl-managed-logs.json").write_text(json.dumps(["old.log"]))
|
||||
(destination / "old.log").symlink_to(victim)
|
||||
archive = self.dir / "logs.zip"
|
||||
with zipfile.ZipFile(archive, "w") as z:
|
||||
z.writestr("s1/session.log", "current")
|
||||
|
||||
ctl.unpack_logs(archive, destination, "s1")
|
||||
|
||||
self.assertEqual("keep me", victim.read_text())
|
||||
self.assertTrue((destination / "old.log").is_symlink())
|
||||
self.assertEqual("current", (destination / "session.log").read_text())
|
||||
|
||||
def test_logs_roll_back_a_failed_manifest_commit_and_can_retry(self):
|
||||
destination = (self.dir / "artifacts").resolve()
|
||||
destination.mkdir()
|
||||
(destination / "notes.txt").write_text("keep me")
|
||||
first = self.dir / "first.zip"
|
||||
with zipfile.ZipFile(first, "w") as z:
|
||||
z.writestr("s1/session.log", "first")
|
||||
z.writestr("s1/steam/console.log", "old")
|
||||
ctl.unpack_logs(first, destination, "s1")
|
||||
old_manifest = (destination / ".droiddeckctl-managed-logs.json").read_bytes()
|
||||
second = self.dir / "second.zip"
|
||||
with zipfile.ZipFile(second, "w") as z:
|
||||
z.writestr("s1/session.log", "second")
|
||||
z.writestr("s1/new.log", "new")
|
||||
|
||||
real_replace = ctl.os.replace
|
||||
failed = False
|
||||
def fail_manifest_once(source, target):
|
||||
nonlocal failed
|
||||
if Path(target) == destination / ".droiddeckctl-managed-logs.json" and not failed:
|
||||
failed = True
|
||||
raise OSError("injected manifest failure")
|
||||
return real_replace(source, target)
|
||||
|
||||
with unittest.mock.patch.object(ctl.os, "replace", side_effect=fail_manifest_once):
|
||||
with self.assertRaises(ctl.ControlError):
|
||||
ctl.unpack_logs(second, destination, "s1")
|
||||
|
||||
self.assertEqual("first", (destination / "session.log").read_text())
|
||||
self.assertEqual("old", (destination / "steam/console.log").read_text())
|
||||
self.assertFalse((destination / "new.log").exists())
|
||||
self.assertEqual(old_manifest, (destination / ".droiddeckctl-managed-logs.json").read_bytes())
|
||||
ctl.unpack_logs(second, destination, "s1")
|
||||
self.assertEqual("second", (destination / "session.log").read_text())
|
||||
self.assertEqual("new", (destination / "new.log").read_text())
|
||||
self.assertFalse((destination / "steam/console.log").exists())
|
||||
self.assertEqual("keep me", (destination / "notes.txt").read_text())
|
||||
|
||||
def test_logs_preserve_the_old_manifest_when_its_backup_fails(self):
|
||||
destination = (self.dir / "artifacts").resolve()
|
||||
destination.mkdir()
|
||||
archive = self.dir / "logs.zip"
|
||||
with zipfile.ZipFile(archive, "w") as z:
|
||||
z.writestr("s1/session.log", "current")
|
||||
manifest = destination / ".droiddeckctl-managed-logs.json"
|
||||
manifest.write_text(json.dumps(["old.log"]))
|
||||
original = manifest.read_bytes()
|
||||
real_replace = ctl.os.replace
|
||||
|
||||
def fail_manifest_backup(source, target):
|
||||
if Path(source) == manifest:
|
||||
raise OSError("injected manifest backup failure")
|
||||
return real_replace(source, target)
|
||||
|
||||
with unittest.mock.patch.object(ctl.os, "replace", side_effect=fail_manifest_backup):
|
||||
with self.assertRaises(ctl.ControlError):
|
||||
ctl.unpack_logs(archive, destination, "s1")
|
||||
|
||||
self.assertEqual(original, manifest.read_bytes())
|
||||
|
||||
def test_logs_from_an_older_build_fall_back_to_the_private_folder(self):
|
||||
self.configure({"state": ended_state()})
|
||||
|
||||
Reference in new issue
Block a user