SecureFlagCoverageTest.kt

4.0 KB · raw

1package com.peachpasswords.security23import javax.xml.parsers.DocumentBuilderFactory4import org.junit.Assert.assertTrue5import org.junit.Test6import org.w3c.dom.Element78class SecureFlagCoverageTest {910    // Compose screens that surface secret material. They live in the MainActivity11    // window, where FLAG_SECURE is already set release-wide, but they must also12    // enforce it during the lifetime of the screen in debug builds — which is13    // what SecureFlagEffect() does. (H-05.)14    private val secretScreens = listOf(15        "app/src/main/java/com/peachpasswords/ui/vault/EntryDetailScreen.kt",16        "app/src/main/java/com/peachpasswords/ui/vault/EntryEditScreen.kt",17        "app/src/main/java/com/peachpasswords/ui/vault/NotesModeScreen.kt",18        "app/src/main/java/com/peachpasswords/ui/vault/ExportScreen.kt",19        "app/src/main/java/com/peachpasswords/ui/vault/RecoveryWordsScreen.kt",20        "app/src/main/java/com/peachpasswords/ui/recovery/RecoveryWordEntryScreen.kt",21        "app/src/main/java/com/peachpasswords/ui/codex/CodexGenerateScreen.kt",22    )2324    // Activities whose own window is separate from MainActivity (overlay / system25    // API surfaces). They do NOT inherit FLAG_SECURE from MainActivity, so each26    // one must set it directly in its own onCreate. (H-05.)27    private val secretActivities = listOf(28        "com.peachpasswords.autofill.AutofillEntryPickerActivity",29        "com.peachpasswords.passkeys.PasskeyCredentialSelectionActivity",30    )3132    @Test fun secret_screens_keep_flag_secure_coverage() {33        secretScreens.forEach { relativePath ->34            val file = java.io.File(relativePath).takeIf { it.exists() }35                ?: java.io.File("../$relativePath")36            val source = file.readText()37            assertTrue(38                "$relativePath must protect screenshots while visible",39                source.contains("SecureFlagEffect()") ||40                    source.contains("WindowManager.LayoutParams.FLAG_SECURE"),41            )42        }43    }4445    @Test fun secret_bearing_activities_set_flag_secure() {46        // Every secret-bearing activity listed in the manifest must set47        // FLAG_SECURE itself — MainActivity's release-wide set does not reach48        // these windows. (H-05.)49        val manifestActivities = parseActivityNames(50            "app/src/main/AndroidManifest.xml",51        )52        secretActivities.forEach { fqcn ->53            val shortName = fqcn.substringAfterLast('.')54            assertTrue(55                "$fqcn must be declared in the manifest",56                manifestActivities.any { it == fqcn || it.endsWith(".$shortName") },57            )58            val relativePath = fqcnToSourcePath(fqcn)59            val file = java.io.File(relativePath).takeIf { it.exists() }60                ?: java.io.File("../$relativePath")61            val source = file.readText()62            assertTrue(63                "$fqcn must set WindowManager.LayoutParams.FLAG_SECURE " +64                    "(separate window from MainActivity)",65                source.contains("WindowManager.LayoutParams.FLAG_SECURE"),66            )67        }68    }6970    private fun parseActivityNames(relativePath: String): Set<String> {71        val file = java.io.File(relativePath).takeIf { it.exists() }72            ?: java.io.File("../$relativePath")73        val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder()74            .parse(file)75        val activities = doc.getElementsByTagName("activity")76        return (0 until activities.length)77            .mapNotNull { (activities.item(it) as? Element)?.getAttribute("android:name") }78            .toSet()79    }8081    // Activities in the manifest are declared as ".Foo" (relative to the82    // application package). The activity source lives at the matching package83    // path under app/src/main/java.84    private fun fqcnToSourcePath(fqcn: String): String {85        val pkg = fqcn.substringBeforeLast('.')86        val cls = fqcn.substringAfterLast('.')87        return "app/src/main/java/" + pkg.replace('.', '/') + "/$cls.kt"88    }89}