fix(build): 修复包重组后所有 import 错误 + 安全占位符漏洞

## 构建与测试结果

- \`./gradlew assembleDebug\` BUILD SUCCESSFUL
- \`./gradlew test\` 99/99 测试通过
- \`app-debug.apk\` 33 MB 生成

## 修复内容

### 1. 领域类型位置修正

\`AppInfo\`、\`PackageName\`、\`UserId\` 是核心领域类型,被 UI 层
(BackupScreen/ViewModel)、restic 子包、BackupOperation、AppScanner 等
多处引用。原始位置在 \`scan/AppScanner.kt\` 内(与扫描器紧耦合),
但子包化后跨包引用不便。已将它们提取到 \`backup/AppInfo.kt\` 与
\`backup/DomainTypes.kt\`(根包)作为公开领域模型。

\`AppScanner.kt\` 现在只负责扫描实现,不再定义数据模型。

### 2. 缺失 import 系统修复(~20 个文件)

包重组后所有子包文件需要显式 import 根包与其他子包的类:

- \`restic/ResticBackup.kt\`, \`ResticRestore.kt\`, \`ResticMaintenance.kt\` 等
  全部添加 \`com.example.androidbackupgui.backup.core.{AppError, AppResult, err}\`
- \`restic/SmbTransport.kt\` 添加 \`backup.core.{AppError, AppResult, LogUtil, err, retryWithBackoff}\`
  和 \`backup.security.MissingAlgoProvider\`
- \`restic/WebdavTransport.kt\` 类似补全
- \`restic/ResticStreamBackup.kt\`、\`ResticWrapper.kt\` 添加 \`backup.AppInfo\`
- \`ui/BackupViewModel.kt\`、\`RestoreScreen.kt\` 添加子包 import
- \`backup/BackupIntegrityChecker.kt\` 添加 \`root.{RootShell, shellEscape}\`
- \`scan/AppScanner.kt\` 添加 \`backup.{AppInfo, BackupConfig, PackageName, UserId}\`
- \`security/CredentialProvider.kt\` 添加 \`backup.BackupConfig\`

### 3. SsaidCache 协程适配

\`SsaidCache.init { }\` 是非 suspend 上下文,不能直接调用
\`RootShell.exec()\`(suspend)。修复:用 \`kotlinx.coroutines.runBlocking { }\`
桥接。该类仅在备份预热阶段构造,在后台调度器上运行,
阻塞单次 shell exec 是可接受的。

### 4. CredentialProvider 占位符漏洞(安全关键)

\`resolve()\` 在 PasswordManager 未初始化时回退到 \`config.resticPassword\`,
但 \`takeIf { it.isNotEmpty() }\` 没过滤 \`"stored-in-keystore"\` 占位符。

后果:如果用户的 \`backup_settings.conf\` 包含占位符(新版 toFile 写入
\`"stored-in-keystore"\`),配置回退路径会把字面字符串作为 restic 仓库
密码传给 CLI。

修复:在 \`takeIf\` 中增加 \`it != "stored-in-keystore"\` 检查。
\`migrateLegacyPasswords\` 已有此检查,\`resolve()\` 之前漏了。

**这个漏洞是被 CredentialProviderTest 发现的** — TDD 价值体现。

### 5. 测试用例修正

- \`BackupProgressTrackerTest\`: \`Thread.sleep(50)\` → \`Thread.sleep(1500)\`
  使 ETA > 0 的断言稳定通过(之前 50ms 不足以让 EMA 计算出 > 1s)

## 测试覆盖

- 11 个测试类,99 个测试用例全部通过
- 新增覆盖:\`RestoreArchiveSafety\`(11 用例,路径白名单防护核心)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
RainySY
2026-06-14 20:32:55 +08:00
parent 4eb2cc3632
commit d293c7c0de
26 changed files with 115 additions and 28 deletions

View File

@@ -0,0 +1,24 @@
package com.example.androidbackupgui.backup
import kotlinx.serialization.Serializable
/**
* 应用元数据。
*
* 由 [com.example.androidbackupgui.backup.scan.AppScanner] 扫描产生,
* 作为备份/恢复模块之间的统一应用信息载体。
*/
@Serializable
data class AppInfo(
val packageName: PackageName,
val label: String = "",
val isSystem: Boolean = false,
val apkPaths: List<String> = emptyList(),
val hasObb: Boolean = false,
val isRunning: Boolean = false,
val backupSize: Long = 0, // estimated from last backup
// Enhanced fields (multi-user, keystore, icon)
val userId: UserId = UserId(0),
val hasKeystore: Boolean = false,
val iconPath: String? = null,
)

View File

@@ -1,6 +1,8 @@
package com.example.androidbackupgui.backup package com.example.androidbackupgui.backup
import android.util.Log import android.util.Log
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape
import java.io.File import java.io.File
/** /**

View File

@@ -1,6 +1,7 @@
package com.example.androidbackupgui.backup package com.example.androidbackupgui.backup
import android.util.Log import android.util.Log
import com.example.androidbackupgui.backup.AppInfo
import com.example.androidbackupgui.backup.restic.ResticWrapper.SnapshotAppInfo import com.example.androidbackupgui.backup.restic.ResticWrapper.SnapshotAppInfo
import com.example.androidbackupgui.backup.core.LogUtil import com.example.androidbackupgui.backup.core.LogUtil
import com.example.androidbackupgui.backup.restic.ResticWrapper import com.example.androidbackupgui.backup.restic.ResticWrapper
@@ -257,6 +258,7 @@ object BackupOperation {
progressTracker: BackupProgressTracker, progressTracker: BackupProgressTracker,
emit: suspend (BackupProgress) -> Unit, emit: suspend (BackupProgress) -> Unit,
) { ) {
val pkgName = app.packageName.value
val appDir = File(backupRoot, pkgName) val appDir = File(backupRoot, pkgName)
appDir.mkdirs() appDir.mkdirs()

View File

@@ -1,4 +1,4 @@
package com.example.androidbackupgui.backup.core package com.example.androidbackupgui.backup
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable

View File

@@ -1,6 +1,7 @@
package com.example.androidbackupgui.backup.restic package com.example.androidbackupgui.backup.restic
import java.io.File import java.io.File
import com.example.androidbackupgui.backup.core.AppResult
/** /**
* 后端执行器——消除 [ResticBackup]、[ResticRestore]、[ResticSnapshotOps]、 * 后端执行器——消除 [ResticBackup]、[ResticRestore]、[ResticSnapshotOps]、

View File

@@ -1,5 +1,7 @@
package com.example.androidbackupgui.backup.restic package com.example.androidbackupgui.backup.restic
import com.example.androidbackupgui.backup.core.AppResult
import com.example.androidbackupgui.backup.core.err
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable

View File

@@ -3,7 +3,7 @@ package com.example.androidbackupgui.backup.restic
import android.util.Log import android.util.Log
import com.example.androidbackupgui.backup.core.AppError import com.example.androidbackupgui.backup.core.AppError
import com.example.androidbackupgui.backup.core.AppResult import com.example.androidbackupgui.backup.core.AppResult
import com.example.androidbackupgui.backup.err import com.example.androidbackupgui.backup.core.err
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive

View File

@@ -2,7 +2,7 @@ package com.example.androidbackupgui.backup.restic
import com.example.androidbackupgui.backup.core.AppError import com.example.androidbackupgui.backup.core.AppError
import com.example.androidbackupgui.backup.core.AppResult import com.example.androidbackupgui.backup.core.AppResult
import com.example.androidbackupgui.backup.err import com.example.androidbackupgui.backup.core.err
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext

View File

@@ -3,7 +3,7 @@ package com.example.androidbackupgui.backup.restic
import android.util.Log import android.util.Log
import com.example.androidbackupgui.backup.core.AppError import com.example.androidbackupgui.backup.core.AppError
import com.example.androidbackupgui.backup.core.AppResult import com.example.androidbackupgui.backup.core.AppResult
import com.example.androidbackupgui.backup.err import com.example.androidbackupgui.backup.core.err
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File

View File

@@ -5,6 +5,8 @@ import android.util.Log
import fi.iki.elonen.NanoHTTPD import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoHTTPD.IHTTPSession import fi.iki.elonen.NanoHTTPD.IHTTPSession
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import com.example.androidbackupgui.backup.core.AppResult
import com.example.androidbackupgui.backup.core.err
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json

View File

@@ -2,7 +2,7 @@ package com.example.androidbackupgui.backup.restic
import com.example.androidbackupgui.backup.core.AppError import com.example.androidbackupgui.backup.core.AppError
import com.example.androidbackupgui.backup.core.AppResult import com.example.androidbackupgui.backup.core.AppResult
import com.example.androidbackupgui.backup.err import com.example.androidbackupgui.backup.core.err
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive

View File

@@ -2,7 +2,7 @@ package com.example.androidbackupgui.backup.restic
import com.example.androidbackupgui.backup.core.AppError import com.example.androidbackupgui.backup.core.AppError
import com.example.androidbackupgui.backup.core.AppResult import com.example.androidbackupgui.backup.core.AppResult
import com.example.androidbackupgui.backup.err import com.example.androidbackupgui.backup.core.err
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext

View File

@@ -1,6 +1,13 @@
package com.example.androidbackupgui.backup.restic package com.example.androidbackupgui.backup.restic
import android.util.Log import android.util.Log
import com.example.androidbackupgui.backup.AppInfo
import com.example.androidbackupgui.backup.BackupOperation
import com.example.androidbackupgui.backup.core.AppError
import com.example.androidbackupgui.backup.core.AppResult
import com.example.androidbackupgui.backup.core.LogUtil
import com.example.androidbackupgui.backup.core.err
import com.example.androidbackupgui.backup.scan.AppScanner
import com.example.androidbackupgui.root.RootShell import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException

View File

@@ -1,9 +1,10 @@
package com.example.androidbackupgui.backup.restic package com.example.androidbackupgui.backup.restic
import android.util.Log import android.util.Log
import com.example.androidbackupgui.backup.AppInfo
import com.example.androidbackupgui.backup.core.AppError import com.example.androidbackupgui.backup.core.AppError
import com.example.androidbackupgui.backup.core.AppResult import com.example.androidbackupgui.backup.core.AppResult
import com.example.androidbackupgui.backup.err import com.example.androidbackupgui.backup.core.err
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext

View File

@@ -1,6 +1,12 @@
package com.example.androidbackupgui.backup.restic package com.example.androidbackupgui.backup.restic
import android.util.Log import android.util.Log
import com.example.androidbackupgui.backup.core.AppError
import com.example.androidbackupgui.backup.core.AppResult
import com.example.androidbackupgui.backup.core.LogUtil
import com.example.androidbackupgui.backup.core.err
import com.example.androidbackupgui.backup.core.retryWithBackoff
import com.example.androidbackupgui.backup.security.MissingAlgoProvider
import jcifs.CIFSContext import jcifs.CIFSContext
import jcifs.config.PropertyConfiguration import jcifs.config.PropertyConfiguration
import jcifs.context.BaseContext import jcifs.context.BaseContext

View File

@@ -1,6 +1,10 @@
package com.example.androidbackupgui.backup.restic package com.example.androidbackupgui.backup.restic
import android.util.Log import android.util.Log
import com.example.androidbackupgui.backup.core.AppError
import com.example.androidbackupgui.backup.core.AppResult
import com.example.androidbackupgui.backup.core.err
import com.example.androidbackupgui.backup.core.retryWithBackoff
import com.thegrizzlylabs.sardineandroid.Sardine import com.thegrizzlylabs.sardineandroid.Sardine
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
import com.thegrizzlylabs.sardineandroid.impl.SardineException import com.thegrizzlylabs.sardineandroid.impl.SardineException

View File

@@ -2,6 +2,10 @@ package com.example.androidbackupgui.backup.scan
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import com.example.androidbackupgui.backup.AppInfo
import com.example.androidbackupgui.backup.BackupConfig
import com.example.androidbackupgui.backup.PackageName
import com.example.androidbackupgui.backup.UserId
import com.example.androidbackupgui.root.RootShell import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -9,19 +13,8 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class AppInfo( // AppInfo data class moved to backup/AppInfo.kt so it's accessible from
val packageName: PackageName, // the root package (used by BackupScreen, BackupViewModel, ResticStreamBackup, etc.)
val label: String = "",
val isSystem: Boolean = false,
val apkPaths: List<String> = emptyList(),
val hasObb: Boolean = false,
val isRunning: Boolean = false,
val backupSize: Long = 0, // estimated from last backup
// Enhanced fields (multi-user, keystore, icon)
val userId: UserId = UserId(0),
val hasKeystore: Boolean = false,
val iconPath: String? = null,
)
object AppScanner { object AppScanner {

View File

@@ -17,9 +17,15 @@ class SsaidCache(userId: String) {
private val ssaidMap: Map<String, String> private val ssaidMap: Map<String, String>
init { init {
val result = RootShell.exec( // RootShell.exec is suspend; init { } blocks cannot call suspend functions.
"cat '/data/system/users/${userId.shellEscape()}/settings_ssaid.xml' 2>/dev/null" // Use runBlocking to bridge — this class is only constructed during the
) // backup's preheat phase, on a background dispatcher, so blocking here
// for the duration of one shell exec is acceptable.
val result = kotlinx.coroutines.runBlocking {
RootShell.exec(
"cat '/data/system/users/${userId.shellEscape()}/settings_ssaid.xml' 2>/dev/null"
)
}
ssaidMap = if (result.isSuccess && result.output.isNotBlank()) { ssaidMap = if (result.isSuccess && result.output.isNotBlank()) {
parseSsaidXml(result.output) parseSsaidXml(result.output)

View File

@@ -1,5 +1,7 @@
package com.example.androidbackupgui.backup.security package com.example.androidbackupgui.backup.security
import com.example.androidbackupgui.backup.BackupConfig
/** /**
* 统一密码提供者 - 消除重复的密码获取逻辑。 * 统一密码提供者 - 消除重复的密码获取逻辑。
* *
@@ -24,15 +26,25 @@ object CredentialProvider {
*/ */
fun resolve(config: BackupConfig): Credentials { fun resolve(config: BackupConfig): Credentials {
val resticPassword = PasswordManager.getResticPassword() val resticPassword = PasswordManager.getResticPassword()
?: config.resticPassword.takeIf { it.isNotEmpty() } ?: config.resticPassword.takeIf {
// Reject the "stored-in-keystore" placeholder so it never reaches
// the restic CLI as the literal repository password. The real
// password is held by PasswordManager; this config field is
// only a migration artifact.
it.isNotEmpty() && it != "stored-in-keystore"
}
?: "" ?: ""
val backendPassword = PasswordManager.getBackendPassword() val backendPassword = PasswordManager.getBackendPassword()
?: config.resticBackendPass.takeIf { it.isNotEmpty() } ?: config.resticBackendPass.takeIf {
it.isNotEmpty() && it != "stored-in-keystore"
}
?: "" ?: ""
val backendPass = PasswordManager.getBackendPass() val backendPass = PasswordManager.getBackendPass()
?: config.resticBackendPass.takeIf { it.isNotEmpty() } ?: config.resticBackendPass.takeIf {
it.isNotEmpty() && it != "stored-in-keystore"
}
?: "" ?: ""
// 尝试迁移旧版密码到 PasswordManager // 尝试迁移旧版密码到 PasswordManager

View File

@@ -7,7 +7,13 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.androidbackupgui.backup.* import com.example.androidbackupgui.backup.*
import com.example.androidbackupgui.backup.core.AppError
import com.example.androidbackupgui.backup.core.AppResult import com.example.androidbackupgui.backup.core.AppResult
import com.example.androidbackupgui.backup.core.ErrorSuggestionFactory
import com.example.androidbackupgui.backup.restic.defaultResticWrapper
import com.example.androidbackupgui.backup.scan.AppScanner
import com.example.androidbackupgui.backup.security.CredentialProvider
import com.example.androidbackupgui.backup.security.ResticBinary
import com.example.androidbackupgui.backup.BackupService.Companion.ACTION_START_BACKUP import com.example.androidbackupgui.backup.BackupService.Companion.ACTION_START_BACKUP
import com.example.androidbackupgui.backup.BackupService.Companion.ACTION_STOP_BACKUP import com.example.androidbackupgui.backup.BackupService.Companion.ACTION_STOP_BACKUP
import com.example.androidbackupgui.backup.BackupService.Companion.EXTRA_STATUS_TEXT import com.example.androidbackupgui.backup.BackupService.Companion.EXTRA_STATUS_TEXT
@@ -257,7 +263,7 @@ class BackupViewModel(
else -> else ->
AppError.LocalIO("备份异常: ${e.message}", s.config.outputPath, cause = e) AppError.LocalIO("备份异常: ${e.message}", s.config.outputPath, cause = e)
} }
val errorInfo = com.example.androidbackupgui.backup.ErrorSuggestionFactory.createSuggestion(error, "备份操作") val errorInfo = com.example.androidbackupgui.backup.core.ErrorSuggestionFactory.createSuggestion(error, "备份操作")
val errorMessage = buildString { val errorMessage = buildString {
append(errorInfo.message) append(errorInfo.message)
if (errorInfo.suggestion.isNotEmpty()) { if (errorInfo.suggestion.isNotEmpty()) {

View File

@@ -12,7 +12,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.androidbackupgui.backup.* import com.example.androidbackupgui.backup.*
import com.example.androidbackupgui.backup.core.AppError
import com.example.androidbackupgui.backup.core.AppResult
import com.example.androidbackupgui.backup.restic.ResticWrapper
import com.example.androidbackupgui.backup.restic.defaultResticWrapper import com.example.androidbackupgui.backup.restic.defaultResticWrapper
import com.example.androidbackupgui.backup.AppInfo
import com.example.androidbackupgui.backup.PackageName
import com.example.androidbackupgui.backup.scan.AppScanner
import com.example.androidbackupgui.backup.security.PasswordManager
import com.example.androidbackupgui.backup.security.ResticBinary
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext

View File

@@ -1,6 +1,9 @@
package com.example.androidbackupgui.backup package com.example.androidbackupgui.backup
import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.throwables.shouldThrow
import com.example.androidbackupgui.backup.core.AppError
import com.example.androidbackupgui.backup.core.AppResult
import com.example.androidbackupgui.backup.core.err
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe

View File

@@ -1,5 +1,9 @@
package com.example.androidbackupgui.backup package com.example.androidbackupgui.backup
import com.example.androidbackupgui.backup.core.AppError
import com.example.androidbackupgui.backup.core.AppResult
import com.example.androidbackupgui.backup.core.err
import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldBeNull

View File

@@ -26,7 +26,7 @@ class BackupProgressTrackerTest : FunSpec({
test("第一个应用完成后 ETA 大于 0") { test("第一个应用完成后 ETA 大于 0") {
val tracker = BackupProgressTracker(totalApps = 10) val tracker = BackupProgressTracker(totalApps = 10)
tracker.startApp("com.app1") tracker.startApp("com.app1")
Thread.sleep(50) // 模拟备份耗时 Thread.sleep(1500) // 模拟备份耗时,确保 ETA 计算可观测
tracker.completeApp() tracker.completeApp()
val progress = tracker.getProgress() val progress = tracker.getProgress()

View File

@@ -1,5 +1,7 @@
package com.example.androidbackupgui.backup package com.example.androidbackupgui.backup
import com.example.androidbackupgui.backup.security.ResticBinary
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe

View File

@@ -1,5 +1,7 @@
package com.example.androidbackupgui.backup package com.example.androidbackupgui.backup
import com.example.androidbackupgui.backup.restic.ResticCommandRunner
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe