Просмотр исходного кода

fix(guard): 堵住配置护栏的三个绕过口

1. gradle 的 ReadBookConfig 写检查只认 `ReadBookConfig.` 前缀,成员 import
   (`import ...ReadBookConfig.durConfig`,含 as 别名) 之后的裸写、以及
   `with(ReadBookConfig) { durConfig = ... }` 这类作用域函数里的裸写全看不见。
   加两条:禁 durConfig 成员 import;直接盯裸赋值 `durConfig =`。
   盯赋值而不是枚举作用域函数,是为了不误伤 ChapterProvider 里只读的
   `with(ReadBookConfig)`。

2. ReadStyleRepository 是 Koin 单例却没有引用限制,任何文件注入它就能
   直接 save() 覆盖 readConfig.json,磁盘与 ReadStyleConfigStore 内存分叉。
   照 configStoreOwners 的手法加 owner 名单。

3. ChapterProviderMetricsInvariantTest 的消费侧检查显式跳过 import 行,
   `import ...ChapterProvider.viewWidth` 之后裸用即可撕裂读取且不报。
   删掉那层过滤,让成员 import 一起进扫描;const val 仍由既有白名单放行。

通配 import 不必防:Kotlin 不允许从 object 按需导入。
每条均以临时注入样例反证过报红。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5151561 3 месяцев назад
Родитель
Сommit
a8c1ffa8ee

+ 7 - 4
app/src/test/java/io/legado/app/ui/book/read/page/provider/ChapterProviderMetricsInvariantTest.kt

@@ -40,9 +40,12 @@ class ChapterProviderMetricsInvariantTest {
             .map { it.groupValues[1] }
             .toSet() + "layoutMetrics"
 
-        val offenders = textChapterLayoutSource().lineSequence()
-            .filterNot { it.trimStart().startsWith("import ") }
-            .flatMap { line -> MEMBER_ACCESS.findAll(line).map { it.groupValues[1] } }
+        // import 行一起扫:`import ...ChapterProvider.viewWidth`(含 as 别名)之后成员可以裸写,
+        // 原先跳过 import 行的写法对这种撕裂读取完全睁眼瞎。放行的只有编译期常量——
+        // 它们不是度量,读多少次都不会和别的字段对不上。
+        // 通配 import 不用管:Kotlin 不允许从 object 按需导入。
+        val offenders = MEMBER_ACCESS.findAll(textChapterLayoutSource())
+            .map { it.groupValues[1] }
             .filterNot { it in constants }
             .toList()
             .distinct()
@@ -50,7 +53,7 @@ class ChapterProviderMetricsInvariantTest {
 
         assertEquals(
             "TextChapterLayout 在 IO 线程构造,必须用 ChapterProvider.layoutMetrics() " +
-                "一次取到自洽的一组度量。以下是逐字段读取:\n" +
+                "一次取到自洽的一组度量。以下是逐字段读取(成员 import 后的裸用同罪):\n" +
                 offenders.joinToString("\n") { "  - ChapterProvider.$it" },
             emptyList<String>(),
             offenders,

+ 27 - 1
build.gradle.kts

@@ -52,6 +52,26 @@ abstract class VerifyConfigArchitectureTask : DefaultTask() {
         val readBookConfigMutationCall = Regex(
             """\bReadBookConfig\.durConfig\.set[A-Za-z0-9_]*\s*\("""
         )
+        // 上面两条都按 `ReadBookConfig.` 前缀找,成员 import 之后的裸写一个都看不见:
+        // `import io.legado.app.help.config.ReadBookConfig.durConfig`(含 as 别名)之后
+        // `durConfig = ...` 就是绕过 gateway 的写——不落盘也不 publishState。
+        // 不必管通配 import:Kotlin 不允许从 object 按需导入。
+        val readBookConfigMemberImport = Regex(
+            """^import io\.legado\.app\.help\.config\.ReadBookConfig\.durConfig\b""",
+            RegexOption.MULTILINE,
+        )
+        // 同样的裸写还能从 `with(ReadBookConfig) { durConfig = ... }`/`ReadBookConfig.apply { }`
+        // 这类作用域函数里冒出来。与其枚举作用域函数(还会误伤 ChapterProvider 里只读的
+        // `with(ReadBookConfig)`),不如直接盯裸赋值本身:带 `.` 前缀的限定写法归上面那条管。
+        val readBookConfigBareWrite = Regex("""(?<![.\w])durConfig\s*=(?!=)""")
+        // 文件读写层 ReadStyleRepository 同理是 Koin 单例:谁 inject 谁就能直接 save()
+        // 覆盖 readConfig.json,磁盘与 ReadStyleConfigStore 的内存状态就此分叉。
+        val styleRepositoryOwners = setOf(
+            "io/legado/app/data/repository/ReadStyleRepository.kt",
+            "io/legado/app/data/repository/ReadStyleConfigStore.kt",
+            "io/legado/app/data/repository/ReadBookStyleConfigRepository.kt",
+            "io/legado/app/di/appModule.kt",
+        )
         // R4.7:Config 的值字段已是 val,字段写入由编译器拦;剩下的唯一写入口是
         // ReadStyleConfigStore 的列表操作。它是 Koin 单例,谁 inject 谁就能绕过 gateway
         // 改配置且不触发 save/publishState——所以限定只有下面这几个文件能提到这个类型。
@@ -102,7 +122,9 @@ abstract class VerifyConfigArchitectureTask : DefaultTask() {
             if (relativePath !=
                 "io/legado/app/data/repository/ReadBookStyleConfigRepository.kt" &&
                 (readBookConfigWrite.containsMatchIn(text) ||
-                    readBookConfigMutationCall.containsMatchIn(text))
+                    readBookConfigMutationCall.containsMatchIn(text) ||
+                    readBookConfigMemberImport.containsMatchIn(text) ||
+                    readBookConfigBareWrite.containsMatchIn(text))
             ) {
                 violations += "$displayPath: ReadBookConfig 写入必须经过 ReadStyleGateway"
             }
@@ -110,6 +132,10 @@ abstract class VerifyConfigArchitectureTask : DefaultTask() {
                 violations += "$displayPath: 排版配置的写入口只对 ReadStyleGateway 的实现开放," +
                     "不要注入 ReadStyleConfigStore"
             }
+            if (relativePath !in styleRepositoryOwners && "ReadStyleRepository" in text) {
+                violations += "$displayPath: readConfig.json 的读写只对 ReadStyleConfigStore 与 " +
+                    "ReadStyleGateway 的实现开放,不要注入 ReadStyleRepository"
+            }
             if (relativePath in injectedConfigFiles && "GlobalContext" in text) {
                 violations += "$displayPath: 配置所有者必须显式注入依赖,禁止 GlobalContext"
             }