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:
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.androidbackupgui.backup.core
|
||||
package com.example.androidbackupgui.backup
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.example.androidbackupgui.backup.restic
|
||||
|
||||
import java.io.File
|
||||
import com.example.androidbackupgui.backup.core.AppResult
|
||||
|
||||
/**
|
||||
* 后端执行器——消除 [ResticBackup]、[ResticRestore]、[ResticSnapshotOps]、
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user