diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 0d099cbf0..311dc0082 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt index 5570bd981..c1955caa0 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt @@ -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() { diff --git a/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt b/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt index 899faf4ca..f838e71ac 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt @@ -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, val truncated: Boolean, @@ -89,6 +116,70 @@ internal object EnhancedSessionLogShare { private val sourceOpenerOverride = object : ThreadLocal() { 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 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() @@ -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() 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\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\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 withAndroidSecureChannel(root: Path, relative: Path, block: (SeekableByteChannel) -> T): T? { val opened = ArrayList() + 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 readSource(block: () -> T): T = try { + block() + } catch (error: Exception) { + throw SourceReadException(error) + } + + private inline fun 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, diff --git a/app/src/test/java/com/droiddeck/launcher/agent/AgentLogExportTest.kt b/app/src/test/java/com/droiddeck/launcher/agent/AgentLogExportTest.kt new file mode 100644 index 000000000..babdec83b --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/agent/AgentLogExportTest.kt @@ -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() + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/EnhancedSessionLogShareTest.kt b/app/src/test/java/com/droiddeck/launcher/session/EnhancedSessionLogShareTest.kt index c1e2e8a21..f9fea7c9b 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/EnhancedSessionLogShareTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/EnhancedSessionLogShareTest.kt @@ -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 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) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt index cd4a711d1..6af039ade 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt @@ -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() } } diff --git a/docs/agent-control.md b/docs/agent-control.md index dbbdbb9b0..7a1fed1d0 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -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 diff --git a/tools/droiddeckctl b/tools/droiddeckctl index 559a437a8..65fb274ce 100755 --- a/tools/droiddeckctl +++ b/tools/droiddeckctl @@ -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"} diff --git a/tools/tests/test_droiddeckctl.py b/tools/tests/test_droiddeckctl.py index 565e918c8..731e3aa38 100644 --- a/tools/tests/test_droiddeckctl.py +++ b/tools/tests/test_droiddeckctl.py @@ -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()})