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
import android.util.Log
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape
import java.io.File
/**

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ package com.example.androidbackupgui.backup.restic
import android.util.Log
import com.example.androidbackupgui.backup.core.AppError
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.Dispatchers
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.AppResult
import com.example.androidbackupgui.backup.err
import com.example.androidbackupgui.backup.core.err
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

View File

@@ -3,7 +3,7 @@ package com.example.androidbackupgui.backup.restic
import android.util.Log
import com.example.androidbackupgui.backup.core.AppError
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.withContext
import java.io.File

View File

@@ -5,6 +5,8 @@ import android.util.Log
import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoHTTPD.IHTTPSession
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.encodeToString
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.AppResult
import com.example.androidbackupgui.backup.err
import com.example.androidbackupgui.backup.core.err
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
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.AppResult
import com.example.androidbackupgui.backup.err
import com.example.androidbackupgui.backup.core.err
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

View File

@@ -1,6 +1,13 @@
package com.example.androidbackupgui.backup.restic
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.shellEscape
import kotlinx.coroutines.CancellationException

View File

@@ -1,9 +1,10 @@
package com.example.androidbackupgui.backup.restic
import android.util.Log
import com.example.androidbackupgui.backup.AppInfo
import com.example.androidbackupgui.backup.core.AppError
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.isActive
import kotlinx.coroutines.withContext

View File

@@ -1,6 +1,12 @@
package com.example.androidbackupgui.backup.restic
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.config.PropertyConfiguration
import jcifs.context.BaseContext

View File

@@ -1,6 +1,10 @@
package com.example.androidbackupgui.backup.restic
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.impl.OkHttpSardine
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.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.shellEscape
import kotlinx.coroutines.Dispatchers
@@ -9,19 +13,8 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
@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,
)
// AppInfo data class moved to backup/AppInfo.kt so it's accessible from
// the root package (used by BackupScreen, BackupViewModel, ResticStreamBackup, etc.)
object AppScanner {

View File

@@ -17,9 +17,15 @@ class SsaidCache(userId: String) {
private val ssaidMap: Map<String, String>
init {
val result = RootShell.exec(
"cat '/data/system/users/${userId.shellEscape()}/settings_ssaid.xml' 2>/dev/null"
)
// RootShell.exec is suspend; init { } blocks cannot call suspend functions.
// 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()) {
parseSsaidXml(result.output)

View File

@@ -1,5 +1,7 @@
package com.example.androidbackupgui.backup.security
import com.example.androidbackupgui.backup.BackupConfig
/**
* 统一密码提供者 - 消除重复的密码获取逻辑。
*
@@ -24,15 +26,25 @@ object CredentialProvider {
*/
fun resolve(config: BackupConfig): Credentials {
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()
?: config.resticBackendPass.takeIf { it.isNotEmpty() }
?: config.resticBackendPass.takeIf {
it.isNotEmpty() && it != "stored-in-keystore"
}
?: ""
val backendPass = PasswordManager.getBackendPass()
?: config.resticBackendPass.takeIf { it.isNotEmpty() }
?: config.resticBackendPass.takeIf {
it.isNotEmpty() && it != "stored-in-keystore"
}
?: ""
// 尝试迁移旧版密码到 PasswordManager

View File

@@ -7,7 +7,13 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
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.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_STOP_BACKUP
import com.example.androidbackupgui.backup.BackupService.Companion.EXTRA_STATUS_TEXT
@@ -257,7 +263,7 @@ class BackupViewModel(
else ->
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 {
append(errorInfo.message)
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.unit.dp
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.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.launch
import kotlinx.coroutines.withContext

View File

@@ -1,6 +1,9 @@
package com.example.androidbackupgui.backup
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.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe

View File

@@ -1,5 +1,9 @@
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.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldBeNull

View File

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

View File

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

View File

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