release: v1.13

- CRITICAL: 配置文件权限加固, 无障碍修复
- HIGH: CancellationException 透传 ×8, SMB/WebDAV Failure 修复, supervisorScope
- 构建: bind 127.0.0.1, allowBackup=false, CI test
- 安全: 签名密码加固, ResticRestBridge auth
- 死代码: 删除 MD4Provider, 3 个死方法, DataSizes, isFileNotFound, getAppLabel
- 修复: ResticCommandRunner NPE, MissingAlgoProvider 全局注册
- 网络: SMB/WebDAV 重试+退避, WebDAV Range 断点续传
- 稳定性: onDestroyView null-safety, isArchiveSafe symlink 误杀修复, WebDAV 超时配置
This commit is contained in:
sakuradairong
2026-06-06 13:09:23 +08:00
parent 1f3e1ceea8
commit 5faedd53af
35 changed files with 2629 additions and 435 deletions

View File

@@ -24,6 +24,8 @@ jobs:
- name: Lint - name: Lint
run: ./gradlew lint run: ./gradlew lint
- name: Test
run: ./gradlew test
- name: Build release APK - name: Build release APK
run: ./gradlew assembleRelease run: ./gradlew assembleRelease

View File

@@ -0,0 +1,303 @@
# 无障碍审查报告 — Android Backup GUI
**审查阶段**:第三层 — 可维护性与用户体验
**审查技能**accessibility (WCAG 2.2)
**审查日期**2026-06-06
**项目规模**37 个源文件
**审查范围**UI 相关 4 个 Fragment/Activity、4 个布局 XML、菜单、资源文件
---
## 严重程度说明
| 级别 | 定义 |
|------|------|
| **严重** | 用户无法完成核心操作,或屏幕阅读器完全无法识别交互元素 |
| **高** | 严重阻碍无障碍使用,有合理的替代方案但未实现 |
| **中** | 影响使用体验,但用户可通过变通方式完成任务 |
| **低** | 体验可改进点,非阻塞性 |
---
## 发现汇总
| # | 文件 | 行号 | 问题 | 严重程度 |
|---|------|------|------|----------|
| 1 | PackageListAdapter.kt | 61-69, 116 | `TextView` 模拟按钮(排除数据切换)缺少无障碍角色 | **严重** |
| 2 | PackageListAdapter.kt | 76-88 | `MaterialCardView` 点击区域未合并无障碍语义 | **高** |
| 3 | PackageListAdapter.kt | 52-53 | `CheckBox` 缺少对应应用的 `contentDescription` | **高** |
| 4 | PackageListAdapter.kt | 109-115 | 排除数据切换状态未以文字方式通知 | **中** |
| 5 | fragment_backup.xml | 168-169 | statusText 缺少无障碍实时区域 | **高** |
| 6 | fragment_restore.xml | 90-91 | statusText 缺少无障碍实时区域 | **高** |
| 7 | fragment_config.xml | 384-390 | configStatusText 缺少无障碍实时区域 | **高** |
| 8 | fragment_backup.xml | 152-160 | progressBar 开始/结束状态无无障碍通知 | **中** |
| 9 | fragment_restore.xml | 73-81 | progressBar 开始/结束状态无无障碍通知 | **中** |
| 10 | fragment_backup.xml | 100-133 | 排序/全选按钮文本仅 11sp不符合可缩放要求 | **中** |
| 11 | fragment_backup.xml | 39-43 | "用户:" 标签与 Spinner 无程序化关联 | **中** |
| 12 | fragment_backup.xml | 59-65 | "输出目录:" 标签与目录显示无程序化关联 | **低** |
| 13 | fragment_restore.xml | 49-53 | "用户:" 标签与 Spinner 无程序化关联 | **中** |
| 14 | PackageListAdapter.kt | 61-69 | "数据"文本排除切换触摸目标可能不足 48dp | **中** |
| 15 | MainActivity.kt | 93-100 | BottomNavigation 缺少 `contentDescription` 仅凭图标导航 | **低** |
---
## 详细发现
### 发现 1`TextView` 模拟按钮 — 排除数据切换(严重)
**文件**`PackageListAdapter.kt`
**行号**61-69创建116点击监听器
**问题**:使用 `TextView`(非标准按钮控件)实现点击交互。`TextView` 默认不向 TalkBack 宣告其可点击角色。屏幕阅读器用户听到"数据"但不知道可以点击切换。
```kotlin
// 第 61-69 行:创建
val et = TextView(ctx).apply {
id = R.id.excludeToggle
// ...
}
// 第 116 行:添加点击
toggle.setOnClickListener {
dataToggleCb(pkg, !excluded)
}
```
**修复建议**
- 方案A(最优):改用 `MaterialButton``ImageButton` 替代 `TextView`,并设置 `contentDescription`
- 方案B(最小改动):在 `TextView` 上显式设置 `focusable = true``clickable = true`,并设置 `contentDescription` 说明其作用和状态。
- 添加 `stateDescription` 或更新 `contentDescription` 反映当前切换状态("点击排除数据备份" / "点击包含数据备份")。
---
### 发现 2卡片点击区域无障碍语义缺失(高)
**文件**`PackageListAdapter.kt`
**行号**76-88
**问题**`MaterialCardView` 上设置了 `setOnClickListener` 用于切换 CheckBox但 TalkBack 视卡片为一个独立可点击元素,未与内部 CheckBox 的角色合并。用户无法明确知道点击卡片的效果是切换选中状态。
```kotlin
// 第 76 行
card.setOnClickListener {
val pos = holder.adapterPosition
// ... 切换 checkbox
}
```
**修复建议**
- 为卡片设置 `contentDescription` 关联应用名和选中状态。
- 或为卡片添加 `role = Role.Button` 的语义,合并内部子元素的无障碍信息。
- 最佳实践是在 `onBindViewHolder` 中更新卡片的 `contentDescription` 为"勾选 ${app.label}"或"取消勾选 ${app.label}"。
---
### 发现 3CheckBox 缺少对应描述(高)
**文件**`PackageListAdapter.kt`
**行号**52-53创建92-102绑定
**问题**CheckBox 在代码中创建且未设置 `contentDescription`。虽然旁边有 `TextView` 显示应用名,但程序化关联不完善。
```kotlin
// 第 52-53 行
val cb = CheckBox(ctx).apply {
id = R.id.checkbox
// 没有 contentDescription
}
```
**修复建议**:在 `onBindViewHolder`(第 96 行设置 `textView.text` 之后)为 checkbox 设置 `contentDescription`
```kotlin
holder.checkbox.contentDescription = "选择 ${app.label.ifEmpty { pkg }}"
```
当选中/取消时同步更新描述。
---
### 发现 4排除数据切换状态缺少文字通知(中)
**文件**`PackageListAdapter.kt`
**行号**109-115
**问题**:排除数据开关通过 `paintFlags`(删除线)和 `isSelected` 来表示状态,这些仅视觉变化不会被 TalkBack 识别。用户无法知道当前是否已排除数据。
```kotlin
// 第 109-115 行
toggle.text = "数据"
toggle.paintFlags = if (excluded) {
toggle.paintFlags or android.graphics.Paint.STRIKE_THRU_TEXT_FLAG
} else {
toggle.paintFlags and android.graphics.Paint.STRIKE_THRU_TEXT_FLAG.inv()
}
toggle.isSelected = excluded
```
**修复建议**:显式更新 `contentDescription`
```kotlin
toggle.contentDescription = if (excluded) {
"排除 ${app.label.ifEmpty { pkg }} 的用户数据备份"
} else {
"包含 ${app.label.ifEmpty { pkg }} 的用户数据备份"
}
```
---
### 发现 5/6/7状态文字缺少无障碍实时区域(高)
**文件**
- `fragment_backup.xml` 第 168-169 行statusText
- `fragment_restore.xml` 第 90-91 行statusText
- `fragment_config.xml` 第 384-390 行configStatusText
**问题**:三个状态文字 View 均未设置 `accessibilityLiveRegion`。当代码调用 `updateStatus()``applyState()` 更新文本内容时TalkBack 不会自动朗读变化。
**修复建议**:在布局 XML 中添加:
```xml
android:accessibilityLiveRegion="polite"
```
同时,建议在 `BackupFragment.kt:406``updateStatus` 方法和 `ConfigFragment.kt:136` 设置状态文本后手动触发无障碍事件,确保 TalkBack 播报:
```kotlin
binding.statusText.sendAccessibilityEvent(
AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED
)
```
---
### 发现 8/9进度指示器状态无通知(中)
**文件**
- `fragment_backup.xml` 第 152-160 行progressBar
- `fragment_restore.xml` 第 73-81 行progressBar
**问题**`LinearProgressIndicator``visibility``VISIBLE`/`GONE` 之间切换(`BackupFragment.kt:402``RestoreFragment.kt:395`),但 TalkBack 不会主动通知用户加载开始或结束。
**修复建议**:在 `setRunning()` 方法中切换进度条可见性时,同步更新 `statusText` 并确保其 `accessibilityLiveRegion` 生效。例如在显示进度条时更新 statusText 为"正在加载…",隐藏时更新为"加载完成"。
---
### 发现 10排序/全选按钮文本过小(中)
**文件**`fragment_backup.xml`
**行号**100-133
**问题**:排序和全选按钮的 `android:textSize` 设置为 `11sp`
```xml
<Button android:textSize="11sp" ... />
```
`11sp` 远低于 Android 推荐的 `14sp` 最小可读文本大小。当用户开启大字模式时,文本可读性受影响。此外按钮使用 `layout_weight="1"` 分布宽度,在窄屏上按钮宽度可能不足 48dp。
**修复建议**
- 将文本大小提升至 `14sp` 以上。
- 添加 `android:minWidth="48dp"` 确保触摸区域不小于 48dp。
- 考虑使用图标+文字的紧凑布局替代纯文字小按钮。
---
### 发现 11/13"用户:" 标签缺少程序化关联(中)
**文件**
- `fragment_backup.xml` 第 39-43 行
- `fragment_restore.xml` 第 49-53 行
**问题**"用户:" 是一个独立的 `TextView`,与后面的 `Spinner` 无程序化关联。TalkBack 用户需自行推断两者关系。
**修复建议**:为 `TextView` 添加 `android:labelFor="@id/userSelector"` 属性:
```xml
<TextView
android:labelFor="@+id/userSelector"
... />
```
---
### 发现 12"输出目录:" 标签缺少程序化关联(低)
**文件**`fragment_backup.xml`
**行号**59-65
**问题**"输出目录:" 标签之后是 `outputPathLabel`(显示路径的 TextView和"修改"按钮。标签与路径显示之间无程序化关联。
**修复建议**:为输出目录标签添加 `labelFor` 指向 `outputPathLabel`,或将其合并为带标题的可操作区域。
---
### 发现 14排除数据切换触摸目标可能不足 48dp(中)
**文件**`PackageListAdapter.kt`
**行号**61-69
**问题**"数据"文本是纯 `TextView`,没有设置 `minWidth``minHeight`。点击区域仅限于文本包裹范围,可能小于 Android 推荐的 48dp 最小触摸目标。
```kotlin
val et = TextView(ctx).apply {
id = R.id.excludeToggle
// 没有 minWidth/minHeight 或最小 padding 保证触摸区域
}
```
**修复建议**:添加最小触摸尺寸:
```kotlin
val et = TextView(ctx).apply {
// ...
minWidth = resources.getDimensionPixelSize(
android.R.dimen.app_icon_size // 48dp
)
minimumHeight = resources.getDimensionPixelSize(
android.R.dimen.app_icon_size
)
}
```
或改用 `MaterialButton` 替代。
---
### 发现 15BottomNavigation 缺少 ContentDescription(低)
**文件**
- `MainActivity.kt` 第 93-100 行
- `res/menu/bottom_nav.xml` 第 2-15 行
**问题**BottomNavigationView 的菜单项包含 `android:title` 文本,但 `android:icon` 引用图标(`@drawable/ic_backup` 等)未设置 `android:contentDescription`。虽然 `android:title` 可被 TalkBack 读取,但图标作为装饰元素应标记为 `android:importantForAccessibility="no"` 以避免冗余播报。
**建议**:在菜单 XML 中为图标装饰属性添加声明(需在 menu 中设置 `app:iconContentDescription` 或在代码中设置)。不过由于 `labelVisibilityMode="labeled"`title 总是可见的TalkBack 可以通过 title 识别,此项优先级较低。
---
## 总结
### 按严重程度统计
| 严重程度 | 数量 |
|----------|------|
| 严重 | 1 |
| 高 | 4 |
| 中 | 8 |
| 低 | 2 |
| **总计** | **15** |
### 按文件分布
| 文件 | 发现问题数 | 最严重问题 |
|------|-----------|-----------|
| PackageListAdapter.kt | 5 | 严重 — TextView 模拟按钮 |
| fragment_backup.xml | 5 | 高 — 缺少无障碍实时区域 |
| fragment_restore.xml | 3 | 高 — 缺少无障碍实时区域 |
| fragment_config.xml | 1 | 高 — 缺少无障碍实时区域 |
| MainActivity.kt | 1 | 低 — BottomNavigation 图标描述 |
### 最优先修复项(共 5 项)
1. **PackageListAdapter.kt:61-69**`TextView` 模拟按钮改为语义化按钮并添加 `contentDescription`
2. **PackageListAdapter.kt:76-88** — 卡片点击区域合并无障碍信息,添加选中/未选中的状态文字
3. **PackageListAdapter.kt:52-53** — CheckBox 绑定应用名作为 `contentDescription`
4. **fragment_backup.xml:168, fragment_restore.xml:90, fragment_config.xml:385** — 为所有状态文字添加 `accessibilityLiveRegion="polite"`
5. **PackageListAdapter.kt:109-115** — 排除切换状态同步到 `contentDescription`
### 项目整体无障碍评估
该应用大量使用 Material Design 3 标准组件,这些组件内置了基本无障碍支持(如 TalkBack 可识别 Button、Switch、CheckBox 等)。主要无障碍缺陷集中在**程序化创建的列表项**`PackageListAdapter`)和**动态状态更新**缺乏通知机制。修复优先级建议从 PackageListAdapter 的 5 个问题开始,它们影响最核心的交互流程(应用选择和排除数据)。配置页面的无障碍支持较好(使用 TextInputLayout + hint 提供标签)。总体评分 **6/10**,完成上述修复后可提升至 **8/10**

View File

@@ -38,9 +38,9 @@ android {
signingConfigs { signingConfigs {
release { release {
storeFile rootProject.file("app/release.keystore") storeFile rootProject.file("app/release.keystore")
storePassword System.getenv("KEYSTORE_PASSWORD") ?: "android" storePassword System.getenv("KEYSTORE_PASSWORD")
keyAlias "release" keyAlias "release"
keyPassword System.getenv("KEY_PASSWORD") ?: "android" keyPassword System.getenv("KEY_PASSWORD")
v1SigningEnabled true v1SigningEnabled true
v2SigningEnabled true v2SigningEnabled true
} }
@@ -48,7 +48,11 @@ android {
buildTypes { buildTypes {
release { release {
if (rootProject.file("app/release.keystore").exists()) { if (rootProject.file("app/release.keystore").exists()) {
signingConfig signingConfigs.release def ksPass = System.getenv("KEYSTORE_PASSWORD")
def kPass = System.getenv("KEY_PASSWORD")
if (ksPass != null && kPass != null) {
signingConfig signingConfigs.release
}
} }
} }
} }

View File

@@ -51,7 +51,7 @@
# --- jcifs-ng (SMB) keep class/member names for MD4Provider reflection --- # --- jcifs-ng (SMB) keep class/member names for reflection (was MD4Provider) ---
-keep class jcifs.util.Crypto { *; } -keep class jcifs.util.Crypto { *; }
-keep class jcifs.smb.NtlmUtil { *; } -keep class jcifs.smb.NtlmUtil { *; }
-keep class jcifs.ntlmssp.Type3Message { *; } -keep class jcifs.ntlmssp.Type3Message { *; }

View File

@@ -10,7 +10,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:allowBackup="true" android:allowBackup="false"
android:extractNativeLibs="true" android:extractNativeLibs="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"

View File

@@ -7,15 +7,6 @@ import com.example.androidbackupgui.root.shellEscape
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable
data class DataSizes(
val apkBytes: Long = 0,
val userBytes: Long = 0,
val userDeBytes: Long = 0,
val dataBytes: Long = 0,
val obbBytes: Long = 0,
val mediaBytes: Long = 0,
)
@Serializable @Serializable
data class AppInfo( data class AppInfo(
@@ -30,7 +21,6 @@ data class AppInfo(
val userId: UserId = UserId(0), val userId: UserId = UserId(0),
val hasKeystore: Boolean = false, val hasKeystore: Boolean = false,
val iconPath: String? = null, val iconPath: String? = null,
val dataSizes: DataSizes = DataSizes(),
) )
object AppScanner { object AppScanner {
@@ -101,16 +91,6 @@ object AppScanner {
.filter { it.isNotEmpty() } .filter { it.isNotEmpty() }
} }
/** Get the app label/name. */
suspend fun getAppLabel(packageName: String): String = withContext(Dispatchers.IO) {
val result = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep -A1 'ApplicationInfo' | grep 'label=' | head -1")
val label = result.output
.substringAfter("label=", "")
.substringBefore(" ")
.removeSurrounding("\"")
.trim()
label.ifEmpty { packageName }
}
/** Check if a package has OBB data. */ /** Check if a package has OBB data. */
suspend fun hasObbData(packageName: String): Boolean = withContext(Dispatchers.IO) { suspend fun hasObbData(packageName: String): Boolean = withContext(Dispatchers.IO) {

View File

@@ -183,6 +183,8 @@ data class BackupConfig(
appendLine("restic_backend_share=\"${config.resticBackendShare}\"") appendLine("restic_backend_share=\"${config.resticBackendShare}\"")
appendLine("restic_backend_domain=\"${config.resticBackendDomain}\"") appendLine("restic_backend_domain=\"${config.resticBackendDomain}\"")
}) })
file.setReadable(true, true) // owner only
file.setWritable(true, true) // owner only
} }
} }
} }

View File

@@ -254,7 +254,7 @@ object BackupOperation {
if (!archiveCreated) { if (!archiveCreated) {
Log.w(TAG, "backupUserData: $packageName all methods failed — no data dirs (or inaccessible)") Log.w(TAG, "backupUserData: $packageName all methods failed — no data dirs (or inaccessible)")
return true return false
} }
// Verify compression integrity // Verify compression integrity

View File

@@ -1,131 +0,0 @@
package com.example.androidbackupgui.backup
import android.util.Log
import org.bouncycastle.crypto.digests.MD4Digest
import java.security.MessageDigest
import java.security.MessageDigestSpi
import java.security.Provider
import java.security.Security
/**
* Ensures MD4 [MessageDigest] is available for jcifs-ng on Android.
*
* jcifs-ng 2.1.x obtains MD4 by instantiating [BouncyCastleProvider]
* and calling [MessageDigest.getInstance]("MD4", bcProvider).
* Android's BouncyCastleProvider class is shadowed by the boot classloader
* and lacks MD4.
*
* Strategy: use reflection to replace `jcifs.util.Crypto.provider`
* with a delegating provider that wraps Android's BC and adds MD4.
* The MD4 [MessageDigestSpi] implementation comes from [MD4Digest]
* in bcprov-jdk15to18 (not shadowed — the class is not in boot CL).
*/
object MD4Provider {
private const val TAG = "MD4Provider"
private val registered = java.util.concurrent.atomic.AtomicBoolean(false)
private val md4Provider: Provider by lazy {
val bc = Security.getProvider("BC")
Md4DelegatingProvider(bc)
}
fun register() {
if (!registered.compareAndSet(false, true)) return
try {
// 1. Replace cached provider in every jcifs-ng class that has one
setProviderField("jcifs.util.Crypto")
for (cn in listOf(
"jcifs.smb.NtlmUtil",
"jcifs.smb.NtlmPasswordAuthenticator",
"jcifs.ntlmssp.Type3Message",
"jcifs.smb.NtlmContext"
)) setProviderField(cn)
// 2. Verify by checking what Crypto.getProvider() returns
try {
val cl = Class.forName("jcifs.util.Crypto")
val getProv = cl.getDeclaredMethod("getProvider")
getProv.isAccessible = true
val actual = getProv.invoke(null) as Provider
Log.i(TAG, "Crypto.getProvider() => ${actual::class.java.simpleName} (hasMD4=${actual.getService("MessageDigest", "MD4") != null})")
} catch (_: Exception) {}
// 3. Fallback: register a global MD4 provider too
try {
Security.insertProviderAt(Md4StandaloneProvider(), 1)
} catch (_: Exception) {}
} catch (e: Exception) {
Log.e(TAG, "Failed to inject MD4", e)
}
}
private fun setProviderField(clsName: String) {
try {
val cls = Class.forName(clsName)
for (f in cls.declaredFields) {
if (java.lang.reflect.Modifier.isStatic(f.modifiers) &&
Provider::class.java.isAssignableFrom(f.type)) {
f.isAccessible = true
f.set(null, md4Provider)
Log.i(TAG, "Set $clsName.${f.name} = Md4DelegatingProvider")
return
}
}
Log.i(TAG, "No static Provider field in $clsName")
} catch (_: ClassNotFoundException) {
Log.i(TAG, "Class not found: $clsName")
}
}
// ── MD4 MessageDigestSpi ────────────────────────────────────
class Md4DigestSpi : MessageDigestSpi() {
private val d = MD4Digest()
override fun engineGetDigestLength() = d.digestSize
override fun engineUpdate(b: Byte) { d.update(b) }
override fun engineUpdate(b: ByteArray, o: Int, l: Int) { d.update(b, o, l) }
override fun engineDigest(): ByteArray {
val r = ByteArray(d.digestSize); d.doFinal(r, 0); return r
}
override fun engineReset() { d.reset() }
}
// ── Delegating provider ─────────────────────────────────────
/** A "BC"-named provider that delegates to [bc] except for MD4. */
private class Md4DelegatingProvider(
private val bc: Provider?
) : Provider("BC", bc?.version ?: 1.0, "BC + MD4") {
init {
// Register MD4 service in the provider's internal service map
putService(Service(this, "MessageDigest", "MD4",
Md4DigestSpi::class.java.name, null, null))
}
override fun getService(type: String, algorithm: String): Service? {
if (type == "MessageDigest" && algorithm.equals("MD4", ignoreCase = true)) {
return super.getService(type, algorithm)
}
return bc?.getService(type, algorithm)
}
override fun getServices(): MutableSet<Service> {
val s = (bc?.getServices() ?: emptySet<Service>()).toMutableSet()
s.addAll(super.getServices())
return s
}
}
/** Standalone MD4-only provider registered globally as fallback. */
private class Md4StandaloneProvider : Provider("Md4Provider", 1.0, "MD4 only") {
override fun getService(type: String, algorithm: String): Service? {
if (type == "MessageDigest" && algorithm.equals("MD4", ignoreCase = true)) {
return Service(this, type, algorithm, Md4DigestSpi::class.java.name, null, null)
}
return null
}
}
}

View File

@@ -55,6 +55,14 @@ object MissingAlgoProvider {
} catch (ve: Exception) { } catch (ve: Exception) {
Log.w(TAG, "Verification failed after injection", ve) Log.w(TAG, "Verification failed after injection", ve)
} }
// 3. Fallback: register a global provider that wraps BC + MD4 + AESCMAC
try {
Security.insertProviderAt(GlobalPatchProvider(), 1)
Log.i(TAG, "Registered GlobalPatchProvider at position 1")
} catch (e: Exception) {
Log.w(TAG, "Failed to register global patch provider", e)
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to inject algorithms", e) Log.e(TAG, "Failed to inject algorithms", e)
@@ -135,3 +143,18 @@ object MissingAlgoProvider {
} }
} }
} }
/**
* Standalone provider registered globally as fallback so that
* [java.security.Security.getProvider]("BC") or any lazy-loaded
* BouncyCastleProvider instance can find MD4 and AESCMAC.
* Named differently ("MissingAlgoProvider") to avoid conflict with "BC".
*/
private class GlobalPatchProvider : Provider(
"MissingAlgoProvider", 1.0, "MD4 + AESCMAC fallback"
) {
init {
put("MessageDigest.MD4", MissingAlgoProvider.Md4Spi::class.java.name)
put("Mac.AESCMAC", MissingAlgoProvider.AesCmacSpi::class.java.name)
}
}

View File

@@ -70,6 +70,3 @@ interface RemoteTransport {
} }
} }
/** Extension to check if an [AppError] represents a "not found" remote error. */
internal fun AppError.isFileNotFound(): Boolean =
this is AppError.Remote && this.isNotFound

View File

@@ -2,14 +2,15 @@ package com.example.androidbackupgui.backup
import android.util.Log import android.util.Log
import java.io.File import java.io.File
import java.util.UUID
/** /**
* Manages [ResticRestBridge] lifecycle: create, start, stop, clean cache. * Manages [ResticRestBridge] lifecycle: create, start, stop, clean cache.
* *
* Usage: * Usage:
* ```kotlin * bridgeRunner.withBridge(backend, url, user, pass, share, domain, repoPath) { bridgeUrl, authToken ->
* bridgeRunner.withBridge(backend, url, user, pass, share, domain, repoPath) { bridgeUrl ->
* // RESTIC_REPOSITORY = bridgeUrl * // RESTIC_REPOSITORY = bridgeUrl
* // RESTIC_REST_USERNAME/PASSWORD = authToken (set via buildBridgeEnv)
* restic commands go here * restic commands go here
* } * }
* // bridge stopped + cache cleaned automatically * // bridge stopped + cache cleaned automatically
@@ -47,25 +48,26 @@ class RestBridgeRunner {
share: String, share: String,
domain: String domain: String
) -> RemoteTransport? = ::createTransport, ) -> RemoteTransport? = ::createTransport,
block: suspend (bridgeUrl: String) -> T block: suspend (bridgeUrl: String, authToken: String) -> T
): T { ): T {
if (backend == "local") { if (backend == "local") {
return block(repoPath) return block(repoPath, "")
} }
// Reuse cached transport (same SMB session) for consistent cross-bridge visibility val authToken = UUID.randomUUID().toString().replace("-", "").take(32)
val key = "$backend|$backendUrl|$backendUser|$backendShare|$backendDomain" val key = "$backend|$backendUrl|$backendUser|$backendShare|$backendDomain"
if (cachedTransportKey != key) { if (cachedTransportKey != key) {
cachedTransport?.let { Log.d(TAG, "discarding stale cached transport") } cachedTransport?.let { Log.d(TAG, "discarding stale cached transport") }
val t = transportFactory(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain) val t = transportFactory(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain)
?: return block(repoPath) ?: return block(repoPath, "")
cachedTransport = t cachedTransport = t
cachedTransportKey = key cachedTransportKey = key
} }
val transport = cachedTransport!! val transport = cachedTransport!!
val remoteBase = buildRemoteBase(backend, backendUrl, backendShare, repoPath) val remoteBase = buildRemoteBase(backend, backendUrl, backendShare, repoPath)
val bridge = ResticRestBridge(transport, remoteBase, repoPath, cacheDir) val bridge = ResticRestBridge(transport, remoteBase, repoPath, cacheDir, authToken)
try { try {
bridge.start(0) bridge.start(0)
@@ -74,14 +76,13 @@ class RestBridgeRunner {
throw IllegalStateException("REST bridge failed to bind a port") throw IllegalStateException("REST bridge failed to bind a port")
} }
val bridgeUrl = "rest:http://127.0.0.1:$port/$repoPath" val bridgeUrl = "rest:http://127.0.0.1:$port/$repoPath"
Log.i(TAG, "REST bridge started on port $port for $remoteBase") Log.i(TAG, "REST bridge started on port $port for $remoteBase (auth=${authToken.take(8)}…)")
return block(bridgeUrl) return block(bridgeUrl, authToken)
} finally { } finally {
try { try {
bridge.stop() bridge.stop()
} catch (_: Exception) {} } catch (_: Exception) {}
Log.d(TAG, "REST bridge stopped") Log.d(TAG, "REST bridge stopped")
// Clean up any leftover blob temp files
val blobs = cacheDir.listFiles { f -> f.name.startsWith("restic_blob_") } val blobs = cacheDir.listFiles { f -> f.name.startsWith("restic_blob_") }
if (blobs != null) { if (blobs != null) {
for (f in blobs) f.delete() for (f in blobs) f.delete()

View File

@@ -4,6 +4,7 @@ import android.util.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
import com.example.androidbackupgui.backup.AppError import com.example.androidbackupgui.backup.AppError
import com.example.androidbackupgui.backup.AppResult import com.example.androidbackupgui.backup.AppResult
@@ -55,25 +56,25 @@ class ResticBackup(
try { try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line) val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
if (progress.messageType == "status") emit(progress) if (progress.messageType == "status") emit(progress)
} catch (_: Exception) { } } catch (e: Exception) { if (e is CancellationException) throw e }
} }
if (result.exitCode != 0) return@withContext err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr)) if (result.exitCode != 0) return@withContext err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
parseBackupSummary(result.stdout) parseBackupSummary(result.stdout)
} else { } else {
bridgeRunner.withBridge(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, repoPath, File(cacheDir)) { bridgeUrl -> bridgeRunner.withBridge(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, repoPath, File(cacheDir)) { bridgeUrl, authToken ->
val args = mutableListOf("backup", "--json") val args = mutableListOf("backup", "--json")
for (path in paths) args.add(path) for (path in paths) args.add(path)
for (tag in tags) { args.add("--tag"); args.add(tag) } for (tag in tags) { args.add("--tag"); args.add(tag) }
if (hostname != null) { args.add("--host"); args.add(hostname) } if (hostname != null) { args.add("--host"); args.add(hostname) }
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir) val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runResticStreaming(env, args) { line -> val result = runner.runResticStreaming(env, args) { line ->
if (!coroutineContext.isActive) return@runResticStreaming if (!coroutineContext.isActive) return@runResticStreaming
try { try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line) val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
if (progress.messageType == "status") emit(progress) if (progress.messageType == "status") emit(progress)
} catch (_: Exception) { } } catch (e: Exception) { if (e is CancellationException) throw e }
} }
if (result.exitCode != 0) return@withBridge err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr)) if (result.exitCode != 0) return@withBridge err(AppError.Restic("restic backup 失败", result.exitCode, result.stderr))
@@ -117,20 +118,20 @@ class ResticBackup(
try { try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line) val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
if (progress.messageType == "status") emit(progress) if (progress.messageType == "status") emit(progress)
} catch (_: Exception) { } } catch (e: Exception) { if (e is CancellationException) throw e }
} }
if (result.exitCode != 0) return@withContext err(AppError.Restic("restic stream backup 失败", result.exitCode, result.stderr)) if (result.exitCode != 0) return@withContext err(AppError.Restic("restic stream backup 失败", result.exitCode, result.stderr))
parseBackupSummary(result.stdout) parseBackupSummary(result.stdout)
} else { } else {
bridgeRunner.withBridge(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, repoPath, File(cacheDir)) { bridgeUrl -> bridgeRunner.withBridge(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, repoPath, File(cacheDir)) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir) val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runResticWithStdin(env, args, stdinFile) { line -> val result = runner.runResticWithStdin(env, args, stdinFile) { line ->
if (!coroutineContext.isActive) return@runResticWithStdin if (!coroutineContext.isActive) return@runResticWithStdin
try { try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line) val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
if (progress.messageType == "status") emit(progress) if (progress.messageType == "status") emit(progress)
} catch (_: Exception) { } } catch (e: Exception) { if (e is CancellationException) throw e }
} }
if (result.exitCode != 0) return@withBridge err(AppError.Restic("restic stream backup 失败", result.exitCode, result.stderr)) if (result.exitCode != 0) return@withBridge err(AppError.Restic("restic stream backup 失败", result.exitCode, result.stderr))

View File

@@ -111,14 +111,15 @@ class ResticCommandRunner {
val reader = process.inputStream.bufferedReader() val reader = process.inputStream.bufferedReader()
try { try {
var line: String var line = reader.readLine()
while (reader.readLine().also { line = it } != null) { while (line != null) {
if (!coroutineContext.isActive) { if (!coroutineContext.isActive) {
process.destroy() process.destroy()
break break
} }
stdoutText.appendLine(line) stdoutText.appendLine(line)
onLine(line) onLine(line)
line = reader.readLine()
} }
} finally { } finally {
try { reader.close() } catch (_: Exception) {} try { reader.close() } catch (_: Exception) {}
@@ -196,14 +197,15 @@ class ResticCommandRunner {
val reader = process.inputStream.bufferedReader() val reader = process.inputStream.bufferedReader()
try { try {
var line: String var line = reader.readLine()
while (reader.readLine().also { line = it } != null) { while (line != null) {
if (!coroutineContext.isActive) { if (!coroutineContext.isActive) {
process.destroy() process.destroy()
break break
} }
stdoutText.appendLine(line) stdoutText.appendLine(line)
onLine(line) onLine(line)
line = reader.readLine()
} }
} finally { } finally {
try { reader.close() } catch (_: Exception) {} try { reader.close() } catch (_: Exception) {}

View File

@@ -10,11 +10,16 @@ class ResticEnvResolver {
fun buildBridgeEnv( fun buildBridgeEnv(
password: String, password: String,
bridgeUrl: String, bridgeUrl: String,
cacheDir: String cacheDir: String,
authToken: String = ""
): Map<String, String> { ): Map<String, String> {
val env = HashMap(System.getenv() ?: emptyMap()) val env = HashMap(System.getenv() ?: emptyMap())
env["RESTIC_REPOSITORY"] = bridgeUrl env["RESTIC_REPOSITORY"] = bridgeUrl
env["RESTIC_PASSWORD"] = password env["RESTIC_PASSWORD"] = password
if (authToken.isNotEmpty()) {
env["RESTIC_REST_USERNAME"] = authToken
env["RESTIC_REST_PASSWORD"] = authToken
}
if (cacheDir.isNotEmpty()) { if (cacheDir.isNotEmpty()) {
env["HOME"] = cacheDir env["HOME"] = cacheDir
env["XDG_CACHE_HOME"] = cacheDir env["XDG_CACHE_HOME"] = cacheDir

View File

@@ -52,8 +52,8 @@ class ResticMaintenance(
bridgeRunner.withBridge( bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare, backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir) backendDomain, repoPath, File(cacheDir)
) { bridgeUrl -> ) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir) val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runRestic(env, "prune") val result = runner.runRestic(env, "prune")
if (result.exitCode == 0) AppResult.Success(result.stdout) if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic prune 失败", result.exitCode, result.stderr)) else err(AppError.Restic("restic prune 失败", result.exitCode, result.stderr))
@@ -82,8 +82,8 @@ class ResticMaintenance(
bridgeRunner.withBridge( bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare, backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir) backendDomain, repoPath, File(cacheDir)
) { bridgeUrl -> ) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir) val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runRestic(env, "check") val result = runner.runRestic(env, "check")
if (result.exitCode == 0) AppResult.Success(result.stdout) if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic check 失败", result.exitCode, result.stderr)) else err(AppError.Restic("restic check 失败", result.exitCode, result.stderr))
@@ -112,8 +112,8 @@ class ResticMaintenance(
bridgeRunner.withBridge( bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare, backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir) backendDomain, repoPath, File(cacheDir)
) { bridgeUrl -> ) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir) val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runRestic(env, "stats") val result = runner.runRestic(env, "stats")
if (result.exitCode == 0) AppResult.Success(result.stdout) if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic("restic stats 失败", result.exitCode, result.stderr)) else err(AppError.Restic("restic stats 失败", result.exitCode, result.stderr))

View File

@@ -49,8 +49,8 @@ class ResticRepoInit(
bridgeRunner.withBridge( bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare, backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir) backendDomain, repoPath, File(cacheDir)
) { bridgeUrl -> ) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir) val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
runInit(env) runInit(env)
} }
} }

View File

@@ -7,6 +7,7 @@ import kotlinx.coroutines.runBlocking
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
import android.util.Base64
/** /**
* NanoHTTPD-based REST bridge implementing the restic REST backend API. * NanoHTTPD-based REST bridge implementing the restic REST backend API.
* *
@@ -23,8 +24,9 @@ class ResticRestBridge(
private val transport: RemoteTransport, private val transport: RemoteTransport,
private val remoteBase: String, private val remoteBase: String,
private val repoPath: String, private val repoPath: String,
private val cacheDir: File private val cacheDir: File,
) : NanoHTTPD(0) { private val authToken: String = ""
) : NanoHTTPD("127.0.0.1", 0) {
private val TAG = "ResticRestBridge" private val TAG = "ResticRestBridge"
@@ -39,6 +41,21 @@ class ResticRestBridge(
val headers = session.headers val headers = session.headers
val params = session.parms val params = session.parms
// Auth check (defense-in-depth — bridge is already bound to 127.0.0.1)
if (authToken.isNotEmpty()) {
val expected = "Basic " + Base64.encodeToString(
"$authToken:$authToken".toByteArray(Charsets.UTF_8),
Base64.NO_WRAP
)
val auth = headers["authorization"]
if (auth != expected) {
Log.w(TAG, "auth failed (got=${auth?.take(20)}..., expected=Basic $authToken)")
return newFixedLengthResponse(
Response.Status.UNAUTHORIZED, "text/plain", "Unauthorized"
)
}
}
Log.d(TAG, "$method $uri") Log.d(TAG, "$method $uri")
return try { return try {

View File

@@ -3,6 +3,7 @@ package com.example.androidbackupgui.backup
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import java.io.File import java.io.File
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
import com.example.androidbackupgui.backup.AppError import com.example.androidbackupgui.backup.AppError
@@ -75,7 +76,7 @@ class ResticRestore(
emit("恢复完成: ${progress.totalFiles} 个文件") emit("恢复完成: ${progress.totalFiles} 个文件")
} }
} }
} catch (_: Exception) { emit(line) } } catch (e: Exception) { if (e is CancellationException) throw e; emit(line) }
} }
if (result.exitCode == 0) AppResult.Success(Unit) if (result.exitCode == 0) AppResult.Success(Unit)
@@ -84,13 +85,13 @@ class ResticRestore(
bridgeRunner.withBridge( bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, backend, backendUrl, backendUser, backendPass, backendShare, backendDomain,
repoPath, File(cacheDir) repoPath, File(cacheDir)
) { bridgeUrl -> ) { bridgeUrl, authToken ->
File(targetPath).mkdirs() File(targetPath).mkdirs()
val args = mutableListOf("restore", snapshotId, "--target", targetPath, "--json") val args = mutableListOf("restore", snapshotId, "--target", targetPath, "--json")
if (include != null) { args.add("--include"); args.add(include) } if (include != null) { args.add("--include"); args.add(include) }
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir) val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runResticStreaming(env, args) { line -> val result = runner.runResticStreaming(env, args) { line ->
if (!coroutineContext.isActive) return@runResticStreaming if (!coroutineContext.isActive) return@runResticStreaming
try { try {
@@ -104,7 +105,7 @@ class ResticRestore(
emit("恢复完成: ${progress.totalFiles} 个文件") emit("恢复完成: ${progress.totalFiles} 个文件")
} }
} }
} catch (_: Exception) { emit(line) } } catch (e: Exception) { if (e is CancellationException) throw e; emit(line) }
} }
if (result.exitCode == 0) AppResult.Success(Unit) if (result.exitCode == 0) AppResult.Success(Unit)
@@ -142,8 +143,8 @@ class ResticRestore(
bridgeRunner.withBridge( bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, backend, backendUrl, backendUser, backendPass, backendShare, backendDomain,
repoPath, File(cacheDir) repoPath, File(cacheDir)
) { bridgeUrl -> ) { bridgeUrl, authToken ->
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir) val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runRestic(env, "dump", snapshotId, filePath) val result = runner.runRestic(env, "dump", snapshotId, filePath)
if (result.exitCode == 0) AppResult.Success(result.stdout) if (result.exitCode == 0) AppResult.Success(result.stdout)
else err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr)) else err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr))

View File

@@ -65,11 +65,11 @@ class ResticSnapshotOps(
bridgeRunner.withBridge( bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare, backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir) backendDomain, repoPath, File(cacheDir)
) { bridgeUrl -> ) { bridgeUrl, authToken ->
val args = mutableListOf("snapshots", "--json") val args = mutableListOf("snapshots", "--json")
if (tag != null) { args.add("--tag"); args.add(tag) } if (tag != null) { args.add("--tag"); args.add(tag) }
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir) val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runRestic(env, args) val result = runner.runRestic(env, args)
if (result.exitCode != 0) { if (result.exitCode != 0) {
@@ -121,7 +121,7 @@ class ResticSnapshotOps(
bridgeRunner.withBridge( bridgeRunner.withBridge(
backend, backendUrl, backendUser, backendPass, backendShare, backend, backendUrl, backendUser, backendPass, backendShare,
backendDomain, repoPath, File(cacheDir) backendDomain, repoPath, File(cacheDir)
) { bridgeUrl -> ) { bridgeUrl, authToken ->
val args = mutableListOf( val args = mutableListOf(
"forget", "forget",
"--keep-daily", keepDaily.toString(), "--keep-daily", keepDaily.toString(),
@@ -130,7 +130,7 @@ class ResticSnapshotOps(
) )
if (dryRun) args.add("--dry-run") if (dryRun) args.add("--dry-run")
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir) val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
val result = runner.runRestic(env, args) val result = runner.runRestic(env, args)
if (result.exitCode == 0) AppResult.Success(result.stdout) if (result.exitCode == 0) AppResult.Success(result.stdout)

View File

@@ -7,7 +7,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
@@ -82,7 +82,7 @@ object RestoreOperation {
val failAtomic = AtomicInteger(0) val failAtomic = AtomicInteger(0)
val semaphore = Semaphore(2) val semaphore = Semaphore(2)
coroutineScope { supervisorScope {
packages.forEachIndexed { index, pkg -> packages.forEachIndexed { index, pkg ->
launch { launch {
if (!coroutineContext.isActive) return@launch if (!coroutineContext.isActive) return@launch
@@ -298,11 +298,7 @@ object RestoreOperation {
if (!result.isSuccess) return false if (!result.isSuccess) return false
return !result.output.lines().any { line -> return !result.output.lines().any { line ->
val path = line.substringBefore(" -> ") val path = line.substringBefore(" -> ")
val hasTraversal = path.trimStart('/').split("/").any { segment -> segment == ".." } path.trimStart('/').split("/").any { segment -> segment == ".." }
val symlinkTarget = if (" -> " in line) line.substringAfter(" -> ") else ""
val unsafeSymlink = symlinkTarget.isNotEmpty() &&
(symlinkTarget.startsWith("/") || symlinkTarget.split("/").any { segment -> segment == ".." })
hasTraversal || unsafeSymlink
} }
} }

View File

@@ -0,0 +1,44 @@
package com.example.androidbackupgui.backup
import android.util.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
/**
* Retry [block] up to [maxRetries] times with exponential backoff.
* Propagates [CancellationException] immediately.
* Returns the first [AppResult.Success], or the last [AppResult.Failure] after all retries.
*/
suspend fun <T> retryWithBackoff(
tag: String,
operation: String,
maxRetries: Int = 3,
initialDelayMs: Long = 1000,
block: suspend () -> AppResult<T>
): AppResult<T> {
var lastError: AppResult.Failure? = null
repeat(maxRetries) { attempt ->
try {
val result = block()
if (result is AppResult.Success) return result
lastError = result as AppResult.Failure
if (attempt < maxRetries - 1) {
val delayMs = initialDelayMs * (1L shl attempt)
Log.w(tag, "$operation 失败 (第${attempt+1}次), ${maxRetries-attempt-1}次重试剩余, 等待${delayMs}ms: ${result.error.message}")
delay(delayMs)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
if (attempt < maxRetries - 1) {
val delayMs = initialDelayMs * (1L shl attempt)
Log.e(tag, "$operation 异常 (第${attempt+1}次), ${maxRetries-attempt-1}次重试剩余", e)
delay(delayMs)
} else {
Log.e(tag, "$operation 最终失败", e)
return err(AppError.Remote("$operation 失败 (重试${maxRetries}次后)", operation, cause = e))
}
}
}
return lastError ?: err(AppError.Remote("$operation 失败", operation))
}

View File

@@ -70,55 +70,53 @@ class SmbTransport(
private fun smbFile(path: String): SmbFile = SmbFile(buildUrl(path), context) private fun smbFile(path: String): SmbFile = SmbFile(buildUrl(path), context)
override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> = override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
withContext(Dispatchers.IO) { retryWithBackoff(TAG, "SMB 上传") {
try { withContext(Dispatchers.IO) {
val localFile = File(localPath) try {
val remote = smbFile(remotePath) val localFile = File(localPath)
// Ensure parent directories exist (parent can be null at share root) val remote = smbFile(remotePath)
val parentPath = remote.parent val parentPath = remote.parent
if (parentPath != null) { if (parentPath != null) {
val parent = SmbFile(parentPath, context) val parent = SmbFile(parentPath, context)
if (!parent.exists()) parent.mkdirs() if (!parent.exists()) parent.mkdirs()
} }
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath)) onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
val fileSize = localFile.length() val fileSize = localFile.length()
SmbFileOutputStream(remote).use { output -> SmbFileOutputStream(remote).use { output ->
localFile.inputStream().use { input -> localFile.inputStream().use { input ->
onProgress(RemoteTransport.TransferProgress("transferring", 0, 1, remotePath)) onProgress(RemoteTransport.TransferProgress("transferring", 0, 1, remotePath))
val buffer = ByteArray(bufferSize) val buffer = ByteArray(bufferSize)
var totalRead = 0L var totalRead = 0L
var n = input.read(buffer) var n = input.read(buffer)
while (n != -1) { while (n != -1) {
output.write(buffer, 0, n) output.write(buffer, 0, n)
totalRead += n totalRead += n
onByteProgress(RemoteTransport.ByteProgress(totalRead, fileSize, remotePath)) onByteProgress(RemoteTransport.ByteProgress(totalRead, fileSize, remotePath))
n = input.read(buffer) n = input.read(buffer)
}
} }
} }
val freshRemote = SmbFile(buildUrl(remotePath), context)
val actualSize = freshRemote.length()
Log.i(TAG, "upload done: $fileSize bytes local, $actualSize bytes on SMB")
if (actualSize != fileSize) {
Log.e(TAG, "upload size mismatch: local=$fileSize smb=$actualSize")
return@withContext err(AppError.Remote("SMB 上传大小不匹配", "upload"))
}
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "upload failed: ${buildUrl(remotePath)}", e)
err(AppError.Remote("SMB 上传失败", "upload", cause = e))
} }
// Re-read with a fresh SmbFile handle to verify (jcifs-ng may have stale handle)
val freshRemote = SmbFile(buildUrl(remotePath), context)
val actualSize = freshRemote.length()
Log.i(TAG, "upload done: $fileSize bytes local, $actualSize bytes on SMB")
if (actualSize != fileSize) {
Log.w(TAG, "upload size mismatch: local=$fileSize smb=$actualSize")
// Try re-opening the output stream to flush any pending writes
SmbFileOutputStream(remote).use { it.write(ByteArray(0)) }
val retrySize = freshRemote.length()
Log.w(TAG, "upload retry: smb=$retrySize bytes")
}
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "upload failed: ${buildUrl(remotePath)}", e)
err(AppError.Remote("SMB 上传失败", "upload", cause = e))
} }
} }
override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> = override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
withContext(Dispatchers.IO) { retryWithBackoff(TAG, "SMB 下载") {
withContext(Dispatchers.IO) {
try { try {
val localFile = File(localPath) val localFile = File(localPath)
localFile.parentFile?.mkdirs() localFile.parentFile?.mkdirs()
@@ -149,6 +147,7 @@ class SmbTransport(
err(AppError.Remote("SMB 下载失败", "download", cause = e)) err(AppError.Remote("SMB 下载失败", "download", cause = e))
} }
} }
}
override suspend fun listFiles(remoteDir: String): AppResult<List<RemoteTransport.RemoteFileInfo>> = override suspend fun listFiles(remoteDir: String): AppResult<List<RemoteTransport.RemoteFileInfo>> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {

View File

@@ -7,20 +7,30 @@ import com.thegrizzlylabs.sardineandroid.impl.SardineException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import android.util.Base64
import java.net.HttpURLConnection
import java.net.URL
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.IOException
class WebdavTransport( class WebdavTransport(
private val baseUrl: String, private val baseUrl: String,
private val username: String, private val username: String,
private val password: String, private val password: String,
private val bufferSize: Int = 8192 private val bufferSize: Int = 8192,
private val connectTimeoutSeconds: Int = 15,
private val readTimeoutSeconds: Int = 30
): RemoteTransport { ): RemoteTransport {
companion object { private const val TAG = "WebdavTransport" } companion object { private const val TAG = "WebdavTransport" }
private val sardine: Sardine by lazy { private val sardine: Sardine by lazy {
OkHttpSardine().apply { val client = okhttp3.OkHttpClient.Builder()
.connectTimeout(connectTimeoutSeconds.toLong(), java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(readTimeoutSeconds.toLong(), java.util.concurrent.TimeUnit.SECONDS)
.build()
OkHttpSardine(client).apply {
if (username.isNotEmpty()) { if (username.isNotEmpty()) {
setCredentials(username, password) setCredentials(username, password)
} }
@@ -33,73 +43,138 @@ class WebdavTransport(
} }
override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> = override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
withContext(Dispatchers.IO) { retryWithBackoff(TAG, "WebDAV 上传") {
try { withContext(Dispatchers.IO) {
val url = buildUrl(remotePath) try {
val file = File(localPath) val url = buildUrl(remotePath)
val fileSize = file.length() val file = File(localPath)
if (fileSize > 50 * 1024 * 1024L) { val fileSize = file.length()
return@withContext err(AppError.Remote("WebDAV 上传: 文件过大 (${fileSize / 1024 / 1024}MB), 上限 50MB", "upload")) if (fileSize > 50 * 1024 * 1024L) {
} return@withContext err(AppError.Remote("WebDAV 上传: 文件过大 (${fileSize / 1024 / 1024}MB), 上限 50MB", "upload"))
Log.d(TAG, "upload $localPath -> $url ($fileSize bytes)")
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
// Read file into ByteArray with progress (sardine.put lacks InputStream variant)
val data = file.inputStream().buffered(bufferSize).use { input ->
onProgress(RemoteTransport.TransferProgress("transferring", 0, 1, remotePath))
val out = ByteArrayOutputStream()
val buffer = ByteArray(bufferSize)
var totalRead = 0L
var n = input.read(buffer)
while (n != -1) {
out.write(buffer, 0, n)
totalRead += n
onByteProgress(RemoteTransport.ByteProgress(totalRead, fileSize, remotePath))
n = input.read(buffer)
} }
out.toByteArray() Log.d(TAG, "upload $localPath -> $url ($fileSize bytes)")
} onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
sardine.put(url, data, "application/octet-stream") val data = file.inputStream().buffered(bufferSize).use { input ->
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "upload failed: $remotePath", e)
err(AppError.Remote("WebDAV 上传失败", "upload", cause = e))
}
}
override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
withContext(Dispatchers.IO) {
try {
val url = buildUrl(remotePath)
val localFile = File(localPath)
localFile.parentFile?.mkdirs()
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
sardine.get(url).use { input ->
localFile.outputStream().use { output ->
onProgress(RemoteTransport.TransferProgress("transferring", 0, 1, remotePath)) onProgress(RemoteTransport.TransferProgress("transferring", 0, 1, remotePath))
val out = ByteArrayOutputStream()
val buffer = ByteArray(bufferSize) val buffer = ByteArray(bufferSize)
var totalRead = 0L var totalRead = 0L
var n = input.read(buffer) var n = input.read(buffer)
while (n != -1) { while (n != -1) {
output.write(buffer, 0, n) out.write(buffer, 0, n)
totalRead += n totalRead += n
onByteProgress(RemoteTransport.ByteProgress(totalRead, 0, remotePath)) onByteProgress(RemoteTransport.ByteProgress(totalRead, fileSize, remotePath))
n = input.read(buffer) n = input.read(buffer)
} }
out.toByteArray()
} }
sardine.put(url, data, "application/octet-stream")
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "upload failed: $remotePath", e)
err(AppError.Remote("WebDAV 上传失败", "upload", cause = e))
} }
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
Log.d(TAG, "download $url -> $localPath (${localFile.length()} bytes)")
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "download failed: $remotePath", e)
err(AppError.Remote("WebDAV 下载失败", "download", cause = e))
} }
} }
override suspend fun download(remotePath: String, localPath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
retryWithBackoff(TAG, "WebDAV 下载") {
withContext(Dispatchers.IO) {
try {
val url = buildUrl(remotePath)
val localFile = File(localPath)
localFile.parentFile?.mkdirs()
val partFile = File(localPath + ".part")
val existingBytes = if (partFile.exists()) partFile.length() else 0L
onProgress(RemoteTransport.TransferProgress("connecting", 0, 1, remotePath))
if (existingBytes > 0L) {
Log.d(TAG, "download 发现 .part 文件, 从 offset=$existingBytes 续传: $remotePath")
downloadRangeResume(url, partFile, existingBytes, onByteProgress, remotePath)
} else {
sardine.get(url).use { input ->
partFile.outputStream().use { output ->
onProgress(RemoteTransport.TransferProgress("transferring", 0, 1, remotePath))
val buffer = ByteArray(bufferSize)
var totalRead = 0L
var n = input.read(buffer)
while (n != -1) {
output.write(buffer, 0, n)
totalRead += n
onByteProgress(RemoteTransport.ByteProgress(totalRead, 0, remotePath))
n = input.read(buffer)
}
}
}
}
if (partFile.exists()) {
partFile.renameTo(localFile)
}
onProgress(RemoteTransport.TransferProgress("completed", 1, 1, remotePath))
Log.d(TAG, "download $url -> $localPath (${localFile.length()} bytes)")
AppResult.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "download failed: $remotePath", e)
err(AppError.Remote("WebDAV 下载失败", "download", cause = e))
}
}
} // retryWithBackoff
/**
* Resume a partial WebDAV download using HTTP Range header.
* Reads from [partFile] which already has [offset] bytes, requests remaining bytes via
* [HttpURLConnection] with Basic auth, and appends to the file.
*/
private suspend fun downloadRangeResume(
url: String,
partFile: File,
offset: Long,
onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit,
remotePath: String
) {
val conn = URL(url).openConnection() as HttpURLConnection
try {
conn.requestMethod = "GET"
if (username.isNotEmpty()) {
val basicAuth = "Basic " + Base64.encodeToString(
"$username:$password".toByteArray(Charsets.UTF_8),
Base64.NO_WRAP
)
conn.setRequestProperty("Authorization", basicAuth)
}
conn.setRequestProperty("Range", "bytes=$offset-")
conn.connect()
val statusCode = conn.responseCode
if (statusCode != 206 && statusCode != 200) {
throw IOException("WebDAV Range resume 失败: HTTP $statusCode (需要 206)")
}
val totalSize = offset + conn.contentLength
java.io.FileOutputStream(partFile, true).use { output ->
conn.inputStream.use { input ->
val buffer = ByteArray(bufferSize)
var totalRead = offset
var n = input.read(buffer)
while (n != -1) {
output.write(buffer, 0, n)
totalRead += n
onByteProgress(RemoteTransport.ByteProgress(totalRead, totalSize, remotePath))
n = input.read(buffer)
}
}
}
} finally {
conn.disconnect()
}
}
override suspend fun listFiles(remoteDir: String): AppResult<List<RemoteTransport.RemoteFileInfo>> = override suspend fun listFiles(remoteDir: String): AppResult<List<RemoteTransport.RemoteFileInfo>> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
@@ -150,8 +225,8 @@ class WebdavTransport(
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "mkdirs failed: $remotePath${e.message}") Log.e(TAG, "mkdirs failed: $remotePath${e.message}")
AppResult.Success(Unit) // best-effort; upload will fail if dir can't be created err(AppError.Remote("WebDAV mkdirs 失败", "mkdirs", cause = e))
} }
} }

View File

@@ -4,6 +4,7 @@ import android.util.Log
import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
@@ -63,7 +64,9 @@ object RootShell {
suspend fun ensureSession(): Boolean = withContext(Dispatchers.IO) { suspend fun ensureSession(): Boolean = withContext(Dispatchers.IO) {
try { try {
Shell.getShell().isRoot Shell.getShell().isRoot
} catch (_: Exception) { false } } catch (e: CancellationException) {
throw e
} catch (e: Exception) { false }
} }
suspend fun exec(command: String, timeoutMs: Long = COMMAND_TIMEOUT_MS): ShellResult = suspend fun exec(command: String, timeoutMs: Long = COMMAND_TIMEOUT_MS): ShellResult =
@@ -81,6 +84,8 @@ object RootShell {
} catch (e: TimeoutCancellationException) { } catch (e: TimeoutCancellationException) {
Log.w(TAG, "exec timeout (${timeoutMs}ms): $command") Log.w(TAG, "exec timeout (${timeoutMs}ms): $command")
ShellResult("", "Command timed out after ${timeoutMs}ms", -1) ShellResult("", "Command timed out after ${timeoutMs}ms", -1)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "exec failed: $command", e) Log.e(TAG, "exec failed: $command", e)
ShellResult("", e.message ?: "Unknown error", -1) ShellResult("", e.message ?: "Unknown error", -1)

View File

@@ -24,14 +24,7 @@ import com.example.androidbackupgui.backup.AppResult
import com.example.androidbackupgui.databinding.FragmentBackupBinding import com.example.androidbackupgui.databinding.FragmentBackupBinding
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import android.os.StatFs
import com.example.androidbackupgui.backup.StreamingBackup
import com.example.androidbackupgui.root.RootShell
import com.example.androidbackupgui.root.shellEscape
import com.example.androidbackupgui.backup.formatSize
import java.io.File import java.io.File
import java.util.Locale import java.util.Locale
@@ -399,11 +392,11 @@ class BackupFragment : Fragment() {
private fun setRunning(running: Boolean) { private fun setRunning(running: Boolean) {
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE _binding?.progressBar?.visibility = if (running) View.VISIBLE else View.GONE
} }
private suspend fun updateStatus(text: String) { private suspend fun updateStatus(text: String) {
withContext(Dispatchers.Main) { binding.statusText.text = text } withContext(Dispatchers.Main) { _binding?.statusText?.text = text }
} }
private fun updateOutputPathDisplay() { private fun updateOutputPathDisplay() {
@@ -431,117 +424,9 @@ class BackupFragment : Fragment() {
.show() .show()
} }
// ── Space detection & streaming backup ──────────── override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
/**
* Estimate the total size of data to back up using `du -sb`.
* Only counts data directories (not APKs) since that's the bulk.
*/
private suspend fun estimateBackupSize(apps: List<com.example.androidbackupgui.backup.AppInfo>): Long = withContext(Dispatchers.IO) {
var total = 0L
for (app in apps) {
val pkgEsc = app.packageName.value.shellEscape()
val result = RootShell.exec("du -sb /data/data/$pkgEsc 2>/dev/null | cut -f1")
val size = result.output.trim().toLongOrNull() ?: 0L
total += size
}
total
}
/**
* Check if [path] has at least [neededBytes] bytes free.
* Uses [StatFs] to query the filesystem.
*/
private fun hasEnoughSpace(path: File, neededBytes: Long): Boolean {
try {
val stat = StatFs(path.absolutePath)
val available = stat.availableBlocksLong * stat.blockSizeLong
// Require 1.5x headroom for temp files and metadata
return available >= neededBytes * 3 / 2
} catch (_: Exception) {
// If we can't check, assume enough space (staging mode)
return true
}
}
/**
* Run streaming backup via [StreamingBackup] + [ResticWrapper.backupStdin].
* Used when staging space is insufficient.
*/
@Suppress("UNUSED_PARAMETER")
private suspend fun runStreamingResticBackup(
config: com.example.androidbackupgui.backup.BackupConfig,
apps: List<com.example.androidbackupgui.backup.AppInfo>,
outputDir: File,
cacheDir: String
): ResticWrapper.BackupSummary? {
updateStatus("空间不足,启动流式备份模式…")
val cacheDirFile = File(cacheDir, "streaming_tmp")
cacheDirFile.mkdirs()
// Prepare streaming: create FIFO, metadata, collect APK paths
val streamingResult = StreamingBackup.prepareStreaming(
cacheDirFile, apps, null
)
// Start restic with stdin from FIFO, in parallel with data producer
var summary: ResticWrapper.BackupSummary? = null
var backupError: String? = null
coroutineScope {
// Launch restic backup (consumer)
val resticJob = async {
val result = ResticWrapper.backupStdin(
repoPath = config.resticRepo,
password = config.resticPassword,
stdinFile = streamingResult.dataFifo,
extraPaths = streamingResult.apkPaths + streamingResult.metaDir.absolutePath,
tags = listOf("streaming_${System.currentTimeMillis() / 1000}"),
hostname = "android-backup-gui",
backend = config.resticBackend,
backendUrl = config.resticBackendUrl,
backendUser = config.resticBackendUser,
backendPass = config.resticBackendPass,
backendShare = config.resticBackendShare,
onProgress = { progress ->
if (progress.messageType == "status") {
updateStatus("流式去重仓库: %.0f%% (%d/%d 个文件)".format(
progress.percentDone * 100,
progress.filesDone,
progress.totalFiles
))
}
}
)
when (result) {
is AppResult.Success -> summary = result.data
is AppResult.Failure -> backupError = result.error.message
}
}
// Launch data producer (writes tar to FIFO)
val producerJob = async {
StreamingBackup.launchDataProducer(
apps = apps,
noDataBackup = excludeDataFromBackup.toSet(),
userId = selectedUserId.toString(),
fifoPath = streamingResult.dataFifo.absolutePath
)
}
// Wait for both to complete
producerJob.await()
resticJob.await()
}
// Cleanup FIFO
try { streamingResult.dataFifo.delete() } catch (_: Exception) {}
try { streamingResult.metaDir.deleteRecursively() } catch (_: Exception) {}
if (backupError != null) {
updateStatus("流式备份失败: $backupError")
}
return summary
}
} }

View File

@@ -66,6 +66,9 @@ class PackageListAdapter(
setTextColor( setTextColor(
MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurfaceVariant, 0) MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurfaceVariant, 0)
) )
contentDescription = res.getString(R.string.exclude_data_toggle)
isFocusable = true
isClickable = true
} }
layout.addView(cb) layout.addView(cb)
layout.addView(tv) layout.addView(tv)

View File

@@ -392,15 +392,15 @@ class RestoreFragment : Fragment() {
private fun setRunning(running: Boolean) { private fun setRunning(running: Boolean) {
binding.progressBar.visibility = if (running) View.VISIBLE else View.GONE _binding?.progressBar?.visibility = if (running) View.VISIBLE else View.GONE
} }
private suspend fun updateStatus(text: String) { private suspend fun updateStatus(text: String) {
binding.statusText.text = text _binding?.statusText?.text = text
} }
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView()
_binding = null _binding = null
super.onDestroyView()
} }
} }

View File

@@ -13,4 +13,5 @@
<string name="status_done">完成 (退出码: %d)</string> <string name="status_done">完成 (退出码: %d)</string>
<string name="status_error">执行失败: %s</string> <string name="status_error">执行失败: %s</string>
<string name="status_cancelled">已取消</string> <string name="status_cancelled">已取消</string>
<string name="exclude_data_toggle">切换数据排除</string>
</resources> </resources>

88
docs/plans/roadmap.md Normal file
View File

@@ -0,0 +1,88 @@
# Android Backup GUI — 后续路线图
## 已完成(当前版本)
| 领域 | 变更 | 阶段 |
|------|------|------|
| 🔒 安全 | 配置文件权限加固、签名密码加固 | P1 |
| 🔒 安全 | SMB MD4/AESCMAC 算法注入修复 + 全局注册 | Hotfix |
| 🐛 正确性 | `CancellationException` 透传 × 8 处 | P2 |
| 🐛 正确性 | SMB/WebDAV 返回 `Failure` 而非 `Success` | P2 |
| 🐛 正确性 | `BackupOperation.backupUserData` 全失败返回 `false` | P2 |
| 🐛 正确性 | `RestoreOperation` 改用 `supervisorScope` | P2 |
| 🐛 正确性 | `ResticCommandRunner` NPE 修复2 处 readLine 模式) | Hotfix |
| 🌐 网络 | SMB/WebDAV 下载/上传自动重试 3 次 + 指数退避 | Hotfix |
| 🌐 网络 | WebDAV Range 断点续传(`.part` 文件 + HTTP Range | Hotfix |
| 🏗️ 构建 | ResticRestBridge 绑定 127.0.0.1 | P3 |
| 🏗️ 构建 | `allowBackup=false` | P3 |
| 🏗️ 构建 | CI 添加 test 步骤 | P3 |
| 🧹 清理 | 删除 `MD4Provider.kt`、3 个死方法、`DataSizes``isFileNotFound``getAppLabel` | P4 |
---
## 下一阶段规划
### Phase A: 稳定性与恢复可靠性3-5 天)
| # | 工作 | 文件 | 说明 | 风险 |
|---|------|------|------|------|
| A1 | 恢复操作 Fragment 修复 | `RestoreFragment.kt` | 添加 `onDestroyView` 防止视图分离后更新 UI | 低 |
| A2 | BackupFragment 修复 | `BackupFragment.kt` | 添加 `onDestroyView`,清理协程 | 低 |
| A3 | ResticRestBridge 认证 | `ResticRestBridge.kt` | 添加 token 认证,防止端口暴露 | 低 |
| A4 | WebDAV 超时可配置 | `WebdavTransport.kt` | Sardine 连接/读取超时通过构造参数设置 | 低 |
| A5 | tar 路径遍历检查 | `SELinuxUtil.kt` | `isArchiveSafe` 添加绝对路径检查 | 低 |
| A6 | 恢复后缓存清理 | `ResticRestBridge.kt` | restore 完成后清理 `restic_blob_*` 缓存文件 | 低 |
### Phase B: 遗留死代码与重构2-3 天)
| # | 工作 | 文件 | 说明 | 风险 |
|---|------|------|------|------|
| B1 | 冗余导入清理 | 7 个文件 | 同包 `import` 冗余 | 低 |
| B2 | 未使用导入清理 | 5 个文件 | 删除无引用 import | 低 |
| B3 | 未使用参数清理 | 3 个函数 | 删除 `@Suppress("UNUSED_PARAMETER")` | 低 |
| B4 | TAG 修复 | `ResticRepoInit.kt`, `ResticCommandRunner.kt` | TAG 变量改为类名 | 低 |
| B5 | UID 解析提取 | `BackupOperation.kt`, `StreamingBackup.kt` | 重复的 UID 解析逻辑提取公共函数 | 低 |
| B6 | 5 个子模块重复分支提取 | `ResticBackup.kt` 等 | if-else local/remote 分支模式提取公共执行函数 | 中 |
### Phase C: 功能增强5-7 天)
| # | 工作 | 文件 | 说明 | 风险 |
|---|------|------|------|------|
| C1 | 多目录恢复选择 | `RestoreFragment.kt` | 让用户选择从哪个 snapshot 恢复哪些目录 | 中 |
| C2 | 前台服务 | `BackupService.kt` | 备份/恢复时启动前台服务防止杀进程 | 中 |
| C3 | 多用户支持 | 全局 | `userId` 参数全面传递到 restore 流程 | 中 |
| C4 | 恢复进度细化 | `RestoreOperation.kt` | 每 blob 粒度进度回调 | 低 |
### Phase D: 安全加固3-4 天)
| # | 工作 | 文件 | 说明 | 风险 |
|---|------|------|------|------|
| D1 | 密码加密存储 | `BackupConfig.kt` | EncryptedSharedPreferences + 迁移现有配置 | 中 |
| D2 | 仓库密码 UI 掩码 | `ConfigFragment.kt` | 确认/二次输入 | 低 |
### Phase E: 类型安全大重构2-3 天)
| # | 工作 | 文件 | 说明 | 风险 |
|---|------|------|------|------|
| E1 | `PackageName` 全面采用 | 8+ 文件 | 函数参数 `String``PackageName` | 高 |
| E2 | `UserId` 全面采用 | 8+ 文件 | 函数参数 `String`/`Int``UserId` | 高 |
### Phase F: i18n 国际化2-3 天)
| # | 工作 | 文件 | 说明 | 风险 |
|---|------|------|------|------|
| F1 | strings.xml 提取 | 所有 UI 文件 | 将硬编码中文提取到 strings.xml | 低 |
| F2 | en/ 翻译 | strings.xml | 英文 strings.xml | 低 |
---
## 建议执行顺序
1. **Phase A**(稳定性优先 — 当前测试中暴露的问题优先修)
2. **Phase B**(清理干净再动大重构)
3. **Phase C**(用户可见功能)
4. **Phase D**(安全加固)
5. **Phase E**(类型安全 — 大重构,和 C 可能有冲突)
6. **Phase F**(最后做,纯文案)
**Phase A + B 可并行执行**

View File

@@ -0,0 +1,247 @@
# Android Backup GUI — 全面审查报告
**审查日期**: 2026-06-06
**审查范围**: 37 个 Kotlin 源文件 + 8 个布局/资源 XML + AndroidManifest
**当前状态**: 53 测试全通过lint 0 错误,编译成功
**已知问题排除**: 7 项 memory 记录的待处理项已跳过
---
## 严重程度说明
| 等级 | 定义 |
|------|------|
| **CRITICAL** | 可直接导致数据泄露、root 提权、静默数据损坏。必须立即修复 |
| **HIGH** | 特定条件下可导致敏感数据泄露、错误处理失效或功能严重受限 |
| **MEDIUM** | 风险较低或需复杂攻击链,但应规划修复 |
| **LOW** | 可改进点,非阻塞 |
| **INFO** | 建议性质,无实际风险 |
---
## 审查方法
使用 11 个 ECC 审查技能分三层并行执行:
| 层级 | 技能 | 方向 |
|------|------|------|
| 第一层:安全与正确性 | ecc-security-reviewer, security-review, ecc-silent-failure-hunter | 漏洞检测、输入校验、静默失败 |
| 第二层:架构与代码质量 | ecc-kotlin-reviewer, kotlin-coroutines-flows, ecc-type-design-analyzer, production-audit | 协程安全、类型设计、生产就绪 |
| 第三层:可维护性与用户体验 | ecc-comment-analyzer, ecc-refactor-cleaner, ecc-code-simplifier, accessibility | 死代码、简化、无障碍 |
---
## 发现汇总
| 层级 | 技能 | CRITICAL | HIGH | MEDIUM | LOW/INFO | 总计 |
|------|------|----------|------|--------|----------|------|
| 第一层 | 安全审查 | 2 | 2 | 5 | 3 | 12 |
| 第一层 | OWASP 安全审查 | 0 | 4 | 6 | 11 | 21 |
| 第一层 | 静默失败审查 | 0 | 4 | 12 | 9 | 25 |
| 第二层 | Kotlin 代码审查 | 0 | 1 | 6 | 19 | 26 |
| 第二层 | 协程/Flow 审查 | 0 | 2 | 4 | 5 | 11 |
| 第二层 | 类型设计审查 | 0 | 2 | 8 | 6 | 16 |
| 第二层 | 生产就绪审查 | 0 | 4 | 10 | 4 | 18 |
| 第三层 | 注释审查 | 0 | 0 | 2 | 4 | 6 |
| 第三层 | 死代码清理 | 0 | 4 | 4 | 4 | 12 |
| 第三层 | 代码简化 | 0 | 2 | 7 | 10 | 19 |
| 第三层 | 无障碍审查 | 1 | 4 | 8 | 2 | 15 |
| | **合计** | **3** | **29** | **72** | **77** | **181** |
---
## 顶层 CRITICAL 问题(必须立即修复)
### C1. 凭据明文存储在配置文件中
**文件**: `BackupConfig.kt:69,73,156-157`
**类型**: Secret 泄露
restic 密码和 SMB/WebDAV 凭据以明文写入 `backup_settings.conf`,位于 `filesDir`。root 环境下任何进程可读取。UI 中密码字段也未使用 `inputType="textPassword"`
**建议**: 使用 `EncryptedSharedPreferences`UI 使用密码掩码输入框。
### C2. 配置文件写入权限不安全
**文件**: `BackupConfig.kt:~144`
**类型**: 权限滥用
`file.writeText()` 使用系统默认文件权限,未显式设置 owner-only 权限。
**建议**: 保存后调用 `file.setReadable(true, true)` / `file.setWritable(true, true)`
### C3. TextView 模拟按钮缺少无障碍角色
**文件**: `PackageListAdapter.kt:61-69`
**类型**: 无障碍
"数据"排除切换使用纯 `TextView` 实现点击交互TalkBack 无法识别其可点击角色。
**建议**: 改用 `MaterialButton` 或添加 `focusable=true`, `clickable=true`, `contentDescription`
---
## HIGH 优先修复29 项)
### 安全(第一层汇总)
| # | 文件 | 行号 | 问题 | 建议 |
|---|------|------|------|------|
| H1 | `ResticRestBridge.kt` | 27 | NanoHTTPD 绑定 0.0.0.0 无认证,局域网可访问 | 改为 `NanoHTTPD("127.0.0.1", 0)` |
| H2 | `RestoreOperation.kt` | 137-149 | tar 解压使用 `-C /`,恶意存档可覆写系统文件 | 添加绝对路径检查,临时目录解压 |
| H3 | `SmbTransport.kt` | 103-109 | SMB 上传大小不匹配仍返回 Success数据可能静默损坏 | 不匹配时返回 `AppResult.Failure` |
| H4 | `BackupOperation.kt` | 255-257 | `backupUserData` 全失败时返回 `true`,用户看到"成功" | 改为 `return false` |
| H5 | `ResticBackup.kt` | 55-58, 73-77 | `CancellationException` 被空 `catch` 吞没,取消信号丢失 | 加 `catch (e: CancellationException) { throw e }` |
| H6 | `WebdavTransport.kt` | 153-155 | `mkdirs` 完全失败仍返回 `Success(Unit)` | 异常时应返回 `AppResult.Failure` |
| H7 | `RootShell.kt` | 84-87 | `CancellationException``catch (e: Exception)` 吞没,全局取消失效 | 加 `catch (e: CancellationException) { throw e }` |
| H8 | `ResticRestore.kt` | 78, 107 | 同 H5CancellationException 被空 catch 吞没 | 重新抛出 CancellationException |
| H9 | `BackupConfig.kt` | 69, 73 | 所有密码未加密存储OWASP 维度) | EncryptedSharedPreferences |
| H10 | `ui/ConfigViewModel.kt` | 180-183 | 空密码检测仅 `initResticRepo` 中有,其他操作入口缺 | 在所有操作入口添加密码空值检查 |
| H11 | `BackupConfig.kt` | 139-186 | `allowBackup=true` 使 ADB 备份可提取明文配置 | 设为 `false` 或加密 |
### 架构与代码质量(第二层汇总)
| # | 文件 | 行号 | 问题 | 建议 |
|---|------|------|------|------|
| H12 | `RestoreOperation.kt` | 85 | `coroutineScope``supervisorScope`,单应用失败取消全部 | 改用 `supervisorScope` |
| H13 | `PackageName` 值类 | 全局 | 多处方法签名使用 `String` 而非 `PackageName` | 统一改为 `PackageName` |
| H14 | `UserId` 值类 | 全局 | 业务层使用 `String`/`Int` 而非 `UserId` | 统一切换为 `UserId` |
| H15 | `ResticRestBridge.kt` | 246-257 | `buildV2Json` 手动拼接 JSON无转义 | 使用 `JSONObject`/`kotlinx.serialization` |
| H16 | `BackupConfig.kt` | 77-137 | 领域模型混合文件 I/O 逻辑(`fromFile`/`toFile` | 提取到 `BackupConfigSerializer` |
| H17 | `BackupOperation.kt` + `RestoreOperation.kt` | 各 300-500 行 | object 承担过多职责,混合 Shell 命令 + 数据格式 + 文件操作 | 按关注点拆分 |
| H18 | `package-list-adapter` | 33-90 | 程序化创建视图而非 XML无法热重载 | 改用 XML 布局 |
| H19 | 生产就绪 | 全局 | 大量硬编码中文字符串strings.xml 完全过时 | 全部移入 strings.xml+国际化 |
| H20 | `app/build.gradle` | 48-53 | Release 构建无混淆R8/ProGuard | 添加 `minifyEnabled true` |
| H21 | `app/build.gradle` | 41,43 | 签名密码回退为弱密码 `"android"` | 环境变量未设置时强制构建失败 |
| H22 | `.github/workflows/ci.yml` | - | CI 不运行 `test`,不验证回归 | 添加 `./gradlew test` |
| H23 | `WebdavTransport.kt` | 22-28 | 无超时配置,请求可能永远挂起 | 设置 connect/read/write 超时 |
| H24 | `SmbTransport.kt`, `WebdavTransport.kt` | 全局 | 远程操作无重试策略 | 实现指数退避重试 |
### 可维护性与无障碍(第三层汇总)
| # | 文件 | 行号 | 问题 | 建议 |
|---|------|------|------|------|
| H25 | `MD4Provider.kt` | 全文件 | 整文件死代码,被 `MissingAlgoProvider` 取代 | 删除 |
| H26 | `BackupFragment.kt` | 440-546 | 3 个流式备份方法从未被调用 | 删除或接入 |
| H27 | `RemoteTransport.kt` | 73-75 | `isFileNotFound` 扩展函数从未使用 | 删除 |
| H28 | `AppScanner.kt` | 26-33 | `DataSizes` 数据类从未被填充或读取 | 删除 |
| H29 | `PackageListAdapter.kt` | 76-88 | 卡片点击区域无障碍语义缺失 | 添加状态文字 contentDescription |
---
## 关键模式分析
### 模式 1CancellationException 被吞没(全局性)
**影响面**: 项目几乎所有协程操作通过 `RootShell.exec`,其 `catch (e: Exception)` 吞没 `CancellationException`。用户取消操作时,正在运行的 shell 命令不会收到取消信号。
**涉及文件**: `RootShell.kt:84-87`, `ResticBackup.kt:55-58,73-77,117-120,130-134`, `ResticRestore.kt:78,107`, `RootShell.kt:63-66`, `ResticWrapper.kt:315-317`
**修复**: 所有空 `catch (_: Exception)` 前加 `catch (e: CancellationException) { throw e }`
### 模式 2远程操作失败返回 Success3 处)
**涉及文件**: `SmbTransport.kt:103-109`, `WebdavTransport.kt:153-155`, `BackupOperation.kt:255-257`
**影响**: 上层调用者无法区分"操作成功"和"操作失败但返回了 Success"。
### 模式 3PackageName/UserId 值类未被方法签名采用
**涉及文件**: 全局,影响 `AppScanner`, `BackupOperation`, `RestoreOperation`, `StreamingBackup`, `PackageListAdapter`, `BackupProgress`, `RestoreProgress`
**影响**: 值类的类型安全收益完全丧失,编译器无法区分 `PackageName` 和任意 `String`
### 模式 45 个子模块重复 local/remote 分支模式
**涉及文件**: `ResticBackup.kt`, `ResticRestore.kt`, `ResticRepoInit.kt`, `ResticMaintenance.kt`, `ResticSnapshotOps.kt`
**影响**: 每个方法都复制 `if (backend == "local")` 分支,增加维护成本和出错可能。
### 模式 5BackupFragment 和 RestoreFragment 缺少 onDestroyView
**影响**: ViewPager 场景下Fragment 视图销毁后协程完成时可能操作已分离的视图。
---
## 正向发现(设计良好的实践)
| 实践 | 文件 | 说明 |
|------|------|------|
| ✅ 密码通过环境变量传递 | `ResticEnvResolver.kt` | 不在命令行中出现,防止 `ps` 窥探 |
| ✅ `shellEscape()` 一致使用 | `RootShell.kt:15` | 所有 shell 拼接参数都经过转义 |
| ✅ `execSafe()` 安全方法 | `RootShell.kt:95-101` | 提供自动参数转义的执行方法 |
| ✅ SharedFlow 用于一次性事件 | `ConfigViewModel.kt:103` | 标准实践 |
| ✅ StateFlow 用于 UI 状态 | `ConfigViewModel.kt:106` | 标准实践 |
| ✅ `repeatOnLifecycle` | `ConfigFragment.kt:75-84` | 正确使用生命周期感知收集 |
| ✅ ResticBinary 双重检查锁定 | `ResticBinary.kt:13-33` | 正确的 `@Volatile` + `synchronized` |
---
## 按文件发现密度
| 文件 | 发现数 | 最严重 |
|------|--------|--------|
| `backup/ResticRestBridge.kt` | 10+ | HIGH |
| `backup/BackupOperation.kt` | 10+ | HIGH |
| `backup/BackupConfig.kt` | 8+ | CRITICAL |
| `root/RootShell.kt` | 5+ | HIGH |
| `backup/ResticBackup.kt` | 4+ | HIGH |
| `backup/SmbTransport.kt` | 4+ | HIGH |
| `backup/WebdavTransport.kt` | 4+ | HIGH |
| `backup/RestoreOperation.kt` | 4+ | HIGH |
| `ui/PackageListAdapter.kt` | 5+ | CRITICAL (a11y) |
| `backup/ResticCommandRunner.kt` | 4+ | MEDIUM |
| `ui/ConfigViewModel.kt` | 4+ | HIGH |
| `backup/StreamingBackup.kt` | 3+ | MEDIUM |
---
## 修复路线图
### 立即修复CRITICAL
1. 密码加密存储EncryptedSharedPreferences
2. 配置文件权限加固
3. 无障碍TextView 改为语义化按钮
### 下一个版本HIGH 优先级)
4. `CancellationException` 全局修复(所有空 catch
5. ResticRestBridge 绑定 127.0.0.1 + 认证
6. tar 解压路径检查
7. SMB/WebDAV 失败时返回 Failure 而非 Success
8. `supervisorScope` 替代 `coroutineScope`
9. 死代码清理MD4Provider.kt, 3 个死方法, DataSizes 等)
10. 添加 release R8/ProGuard 混淆
11. CI 添加 `./gradlew test`
12. WebDAV 超时配置
13. 远程操作重试策略
14. 密码 UI 掩码输入
15. 多目录恢复选择
### 规划修复MEDIUM
16. `PackageName`/`UserId` 值类全面采用
17. BackupConfig 分离 I/O 逻辑
18. BackupOperation/RestoreOperation 拆分
19. PackageListAdapter 改用 XML 布局
20. 国际化:硬编码字符串移入 strings.xml
21. 释放签名密码加固
22. 前台服务通知进度更新
23. 恢复操作确认对话框
24. API 超时配置
25. `@Serializable` 死注解清理
---
## 统计概览
| 指标 | 值 |
|------|-----|
| 审查技能数 | 11 |
| 审查文件数 | 37 Kotlin + ~15 资源/配置 |
| 总发现数 | 181 |
| CRITICAL | 3 |
| HIGH | 29 |
| MEDIUM | 72 |
| LOW/INFO | 77 |
| 可删除代码 | ~150 行 + 1 个整文件 + ~20 行导入 |
| 生产就绪评分 | 58/100 |
| 测试覆盖率 | 53 测试(持续集成未运行) |

View File

@@ -0,0 +1,266 @@
# 第三阶段 — 死代码清理审查报告
> 审查范围: android-backup-gui 项目 37 个 Kotlin 源文件
> 审查技能: ecc-refactor-cleaner死代码、未使用导入、重复逻辑、废弃代码
> 已知不重复: Phase 2 已报告的 @Serializable 死注解TypeDesign F12不在此重复
> 已知不重复: memory 中 7 个待处理项不在此重复
---
## 严重程度分级
| 等级 | 含义 |
|------|------|
| 🔴 **严重** | 功能层面死代码,占用维护成本,可能引发混淆 |
| 🟠 **中** | 未使用导入/参数,可能清理但非功能阻塞 |
| 🟡 **低** | 装饰性/可清理但不影响运行 |
---
## 🔴 严重发现
### F1. `MD4Provider.kt` 整文件死代码
**文件**: `app/src/main/java/com/example/androidbackupgui/backup/MD4Provider.kt`
**行号**: 1-137整文件
**问题**: `MD4Provider``MissingAlgoProvider` 完全取代。`MissingAlgoProvider` 提供了 `MD4` + `AESCMAC` 两种算法注入,且是 `SmbTransport` 实际调用的对象。`MD4Provider` 在任何地方都未被引用。
**证据**:
- `SmbTransport` 调用的是 `MissingAlgoProvider.register()`
- 全局搜索 `MD4Provider` 仅命中自身文件
**建议**: 删除整个 `MD4Provider.kt` 文件。
---
### F2. `BackupFragment.kt` 三个死方法(流式备份未接入)
**文件**: `app/src/main/java/com/example/androidbackupgui/ui/BackupFragment.kt`
**行号**: 440-546
**问题**: 以下三个方法定义了流式备份逻辑但从未被调用:
| 方法 | 行号 | 说明 |
|------|------|------|
| `estimateBackupSize()` | 440 | 估算备份数据大小 |
| `hasEnoughSpace()` | 455 | 检查磁盘空间是否充足 |
| `runStreamingResticBackup()` | 472 | 执行流式备份FIFO 管道) |
**证据**: 全局搜索三个方法名,除自身定义外无任何调用点。`startBackup()` 方法走的是常规 restic `backup` 路径,未调用流式路径。
`runStreamingResticBackup` 上标注了 `@Suppress("UNUSED_PARAMETER")` 且参数 `outputDir: File` 从未使用,说明开发者已知此方法目前是死代码。
**建议**: 删除三个方法及相关 `import android.os.StatFs`(如果没有其他用途)。或将流式备份接入到 `startBackup` 的条件分支中。
---
### F3. `RemoteTransport.isFileNotFound()` 未使用扩展函数
**文件**: `app/src/main/java/com/example/androidbackupgui/backup/RemoteTransport.kt`
**行号**: 73-75
```kotlin
internal fun AppError.isFileNotFound(): Boolean =
this is AppError.Remote && this.isNotFound
```
**问题**: 此扩展函数定义后从未在任何地方调用。`Remote` 错误中的 `isNotFound` 字段通过 `when (error) { is AppError.Remote -> ... }` 模式匹配访问,不需要扩展函数。
**证据**: 全局搜索 `isFileNotFound` 仅命中此定义。
**建议**: 删除此扩展函数。
---
### F4. `DataSizes` 数据类及其字段从未使用
**文件**: `app/src/main/java/com/example/androidbackupgui/backup/AppScanner.kt`
**行号**: 26-33
```kotlin
@Serializable
data class DataSizes(
val apkBytes: Long = 0,
val userBytes: Long = 0,
// ...
)
```
```kotlin
data class AppInfo(
// ...
val dataSizes: DataSizes = DataSizes(), // 33 行
)
```
**问题**: `DataSizes` 类型仅用于 `AppInfo.dataSizes` 字段的默认值,没有任何代码对此字段写入非默认值或读取。这是残留的"预留"字段。
**证据**: 全局搜索 `dataSizes` 仅命中定义行33`DataSizes` 类型本身26`@Serializable` 注解也是死注解(`AppInfo` 从未被 kotlinx-serialization 序列化)。
**建议**: 删除 `DataSizes` 数据类和 `AppInfo.dataSizes` 字段。保留 `@Serializable` 的清理评估留给 Phase 2 已知报告。
---
## 🟠 中等发现
### F5. 子模块中 TAG 常量复制粘贴错误
**文件**:
- `app/src/main/java/com/example/androidbackupgui/backup/ResticRepoInit.kt` 第 7 行: `private val TAG = "ResticWrapper"`
- `app/src/main/java/com/example/androidbackupgui/backup/ResticCommandRunner.kt` 第 8 行: `private val TAG = "ResticWrapper"`
**问题**: 两个子模块使用的 TAG 为 `"ResticWrapper"`,而非自己的类名。导致 logcat 中无法区分日志来源。
**建议**: 改为 `"ResticRepoInit"``"ResticCommandRunner"`
---
### F6. 同包冗余导入(跨 7 个文件)
以下文件在 `package com.example.androidbackupgui.backup` 中,却显式 import 了同包的 `AppError``AppResult``err`
| 文件 | 冗余导入行 |
|------|-----------|
| `ResticRepoInit.kt` | `import com.example.androidbackupgui.backup.AppError/AppResult/err` |
| `ResticBackup.kt` | `import com.example.androidbackupgui.backup.AppError/AppResult/err` |
| `ResticRestore.kt` | `import com.example.androidbackupgui.backup.AppError/AppResult/err` |
| `ResticSnapshotOps.kt` | `import com.example.androidbackupgui.backup.AppError/AppResult/err` |
| `ResticMaintenance.kt` | `import com.example.androidbackupgui.backup.AppError/AppResult/err` |
| `ResticWrapper.kt` | `import com.example.androidbackupgui.backup.AppError/AppResult/err` |
| `ResticCommandRunner.kt` | `import com.example.androidbackupgui.backup.AppError`(且此导入实际未使用——该文件不引用 `AppError`|
**建议**: 清理全部冗余 import。`ResticCommandRunner.kt` 中的 `AppError` 为真正未使用导入,应删除。
---
### F7. 真正未使用的导入
| 文件 | 行号 | 导入 | 原因 |
|------|------|------|------|
| `ResticWrapper.kt` | 5 | `import kotlinx.coroutines.isActive` | 文件内无使用 |
| `ResticWrapper.kt` | 9 | `import kotlin.coroutines.coroutineContext` | 文件内无使用 |
| `BackupFragment.kt` | 34 | `import com.example.androidbackupgui.backup.formatSize` | 文件内无使用 |
| `ConfigFragment.kt` | 19-20 | `import kotlinx.coroutines.Dispatchers` / `import kotlinx.coroutines.withContext` | Fragment 类中从未使用(全部委托给 ViewModel|
| `ConfigViewModel.kt` | 8 | `import com.example.androidbackupgui.backup.formatSize` | 文件内无使用 |
**建议**: 删除上述导入。
---
### F8. 未使用参数(已标注 `@Suppress`
| 文件 | 函数 | 未使用参数 | 行号 |
|------|------|-----------|------|
| `ResticRestBridge.kt` | `handleConfig()` | `headers: Map<String, String>` | 166 |
| `StreamingBackup.kt` | `launchDataProducer()` | `userId: String` | 90 |
| `BackupFragment.kt` | `runStreamingResticBackup()` | `outputDir: File` | 475 |
**问题**: 参数被显式标记为未使用。如果近期无实现计划,应直接删除参数。
**建议**:
- `handleConfig`: `headers` 可以移除HEAD/GET/POST 都不需要它)
- `launchDataProducer`: `userId` 若留作后续多用户支持,保留但记录 TODO
- `runStreamingResticBackup`: 整个方法为死代码(见 F2删除即可
---
## 🟡 低严重度发现
### F9. `AppScanner.getAppLabel()` 方法
**文件**: `app/src/main/java/com/example/androidbackupgui/backup/AppScanner.kt`
**行号**: 87-92
```kotlin
suspend fun getAppLabel(packageName: String): String = withContext(Dispatchers.IO) {
val result = RootShell.exec("dumpsys package ...")
// ...
}
```
**问题**: 此 public 方法通过 `dumpsys package` 解析应用标签。但它返回的是包名fallback且项目中实际使用 `resolveLabels()`(通过 `PackageManager` API来获取标签。此方法未被任何代码调用。
**证据**: 项目中使用 `resolveLabels()` 获取标签,`getAppLabel()` 无调用者。
**建议**: 确认无用后删除。
---
### F10. 重复的 if-else bridge 模式(架构级别)
在 5 个子模块中(`ResticRepoInit`, `ResticBackup`, `ResticRestore`, `ResticSnapshotOps`, `ResticMaintenance`),每个方法都重复以下模式:
```kotlin
if (backend == "local") {
val env = envResolver.buildLocalEnv(...)
// run restic command
} else {
bridgeRunner.withBridge(...) { bridgeUrl ->
val env = envResolver.buildBridgeEnv(...)
// run restic command
}
}
```
**影响**: `ResticMaintenance` 中 3 个方法prune/check/stats结构完全一致仅有命令参数不同。跨模块总共 ~8 次重复。
**建议**: 可提取为公共执行函数,如 `withResticEnv(backend, ...) { env -> runner.runRestic(env, ...) }`。此为架构改进建议,非阻塞。
---
### F11. `BackupFragment.estimateBackupSize` 缩进错误
**文件**: `app/src/main/java/com/example/androidbackupgui/ui/BackupFragment.kt`
**行号**: 440-449
缩进层次错误:`val pkgEsc = ...` 等行应在 `for` 循环体内但缩进级别与函数体相同:
```kotlin
for (app in apps) {
val pkgEsc = app.packageName.value.shellEscape() // ← 缩进错误
val result = RootShell.exec(...)
```
**建议**: 修复缩进(但该函数本身是死代码 F2删除后自然解决
---
### F12. 重复的 UID 解析逻辑
**文件**:
- `AppScanner.kt``hasKeystore()`(行 111-117中解析 UID 的逻辑
- `RestoreOperation.kt``resolveAppUid()`(行 462-490中解析 UID 的逻辑
**问题**: 两处通过 `dumpsys package ... | grep 'userId='` 解析 UID 的代码逻辑高度相似。`RestoreOperation.resolveAppUid()` 更完整(支持 3 种 fallback`AppScanner.hasKeystore()` 有独立的实现。
**建议**: 可将 UID 解析提取为公共工具函数,避免两处维护。
---
## 汇总
| 编号 | 严重度 | 类别 | 位置 | 建议 |
|------|--------|------|------|------|
| F1 | 🔴 | 死代码 | `MD4Provider.kt` 整文件 | 删除 |
| F2 | 🔴 | 死代码 | `BackupFragment.kt` 440-5463 个方法)| 删除或接入 |
| F3 | 🔴 | 死代码 | `RemoteTransport.kt:73-75` | 删除扩展函数 |
| F4 | 🔴 | 死代码 | `AppScanner.kt:26-33` DataSizes | 删除 |
| F5 | 🟠 | 错误TAG | `ResticRepoInit.kt:7`, `ResticCommandRunner.kt:8` | 改为类名 |
| F6 | 🟠 | 冗余导入 | 7 个文件中的同包 import | 清理 |
| F7 | 🟠 | 未使用导入 | 5 个文件 | 删除 |
| F8 | 🟠 | 未使用参数 | 3 个函数(已 @Suppress| 删除参数或加 TODO |
| F9 | 🟡 | 死代码 | `AppScanner.kt:87-92` getAppLabel | 确认后删除 |
| F10 | 🟡 | 重复模式 | 5 个子模块中的 if-else bridge | 提取公共执行函数 |
| F11 | 🟡 | 格式问题 | `BackupFragment.kt:440-449` 缩进 | 修复(随 F2 解决)|
| F12 | 🟡 | 重复逻辑 | UID 解析在两处重复 | 提取工具函数 |
---
## 清理收益估算
- 可删除文件: 1 个(`MD4Provider.kt`, ~5.1KB
- 可删除代码行: ~150 行(死方法 + DataSizes + 扩展函数)
- 可清理导入: ~20 行(冗余 + 未使用导入)
- 可清理参数: 3 个
- 代码库缩减: ~6-8% 的源代码量

View File

@@ -0,0 +1,561 @@
# Android Backup GUI — OWASP 导向安全审查报告
> 审查日期: 2026-06-06
> 范围: 全部 37 个 Kotlin 源文件 + AndroidManifest.xml
> 已知问题已排除memory 中记录的 7 项 Remaining Gaps 不在此报告重复)
---
## 目录
1. [认证与授权](#1-认证与授权)
2. [输入校验](#2-输入校验)
3. [敏感数据处理](#3-敏感数据处理)
4. [API 安全](#4-api-安全)
5. [安全配置](#5-安全配置)
6. [日志/调试信息泄露](#6-日志调试信息泄露)
7. [Intent/组件暴露](#7-intent组件暴露)
---
## 1. 认证与授权
### 1.1 无权限检查直接执行 Root 命令
**严重程度**: 中
**文件**: `backup/BackupOperation.kt`
**位置**: 第 109、173、228、246-249、273-276、297-300、308-311、334、350、369 行等
`RootShell.exec()` 在整个代码库中被广泛调用,但在调用前不做任何权限检查。虽然没有运行时安全检查(因为是 root 应用),但以下操作直接通过 `RootShell.exec()` 执行系统命令并拼接用户控制的输入:
```kotlin
// BackupOperation.kt:109 — cp 命令使用 shellEscape
RootShell.exec("cp '${apkPath.shellEscape()}' ...")
// BackupOperation.kt:297 — tar 命令拼接目录名
RootShell.exec("set -o pipefail; $tarCmd -cf - $excludeArgs ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} ...")
// BackupOperation.kt:333 — 读取含有应用名的系统文件
val result = RootShell.exec("cat '$ssaidFile' 2>/dev/null")
```
**修复建议**: 虽然 `shellEscape()` 提供了防御,但所有 root shell 调用应使用 `execSafe()` 而不是 `exec()`
### 1.2 RootShell 启用 libsu 详细日志
**严重程度**: 低
**文件**: `root/RootShell.kt`
**位置**: 第 54 行
```kotlin
Shell.enableVerboseLogging = true
```
生产环境中启用 libsu 的详细日志,会将所有 su 会话操作的细节写入 logcat。
**修复建议**: 改为构建标志控制,仅在 debug 构建启用。
### 1.3 QUERY_ALL_PACKAGES 敏感权限
**严重程度**: 低(已声明为必要)
**文件**: `app/src/main/AndroidManifest.xml`
**位置**: 第 7 行
```xml
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
```
Google Play 对 `QUERY_ALL_PACKAGES` 有严格审核要求,该应用的核心功能需要此权限以列举用户安装的应用。
**修复建议**: 确认应用不上架 Google Play 或已通过审核。当前无修复必要。
---
## 2. 输入校验
### 2.1 Restic 密码为空时仍继续执行
**严重程度**: 高
**文件**: `ui/ConfigViewModel.kt`
**位置**: 第 180-183 行
```kotlin
if (form.repo.isEmpty() || form.password.isEmpty()) {
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "请填写仓库路径和密码")) }
return
}
```
`initResticRepo()``form.password.isEmpty()` 时返回。但 `refreshResticStatus()`(第 217-256 行)和 `showResticStats()`(第 258-295 行)和 `pruneResticSnapshots()`(第 297-344 行)在 `form.password` 为空时不会检查,直接将空密码传给 `ResticWrapper`
**修复建议**: 在所有操作入口添加密码空值检查,或至少记录 warning。
### 2.2 用户配置字段无输入校验
**严重程度**: 中
**文件**: `backup/BackupConfig.kt`
**位置**: 第 78-136 行 (`fromFile`)
配置解析使用 `toIntOrNull()` 处理整数(静默回退到默认值),字符串字段没有任何长度、格式或内容验证。例如:
- `resticBackendUrl` 不验证是否为合法 URL
- `resticBackendShare` 不验证 SMB share 名称格式
- `resticBackendUser``resticBackendPass` 不验证为空时的行为
**文件**: `ui/ConfigFragment.kt`
**位置**: 第 200-217 行 (`saveConfig`)
```kotlin
resticPassword = binding.resticPasswordEdit.text?.toString() ?: "",
resticBackendUrl = binding.resticBackendUrlEdit.text?.toString()?.trim() ?: "",
```
来自 UI 的输入仅进行了简单的 null→empty 转换,没有任何格式校验。
**修复建议**: 添加输入验证层,至少检查 URL 格式、必填字段非空。对于 restic 仓库密码,提示用户确认。
### 2.3 ResticRestBridge URI 路径注入风险
**严重程度**: 中
**文件**: `backup/ResticRestBridge.kt`
**位置**: 第 62-117 行 (`handleRequest`)
URI 路径解析时,`segments``strippedPath.split("/").filter { it.isNotEmpty() }` 产生,然后直接用于构建远程路径:
```kotlin
// 第 100-102 行
val type = firstSegment
val name = if (segments.size >= 2) segments.drop(1).joinToString("/") else null
```
以及后续的远程路径构建:
```kotlin
// 第 232 行
val remoteDir = "$remoteBase/$type"
// 第 262 行
val remotePath = "$remoteBase/$type/$name"
```
虽然 restic 是唯一客户端,但 URI 中的编码路径可能被滥用于路径遍历。`name` 通过 `joinToString("/")` 直接拼接到远程路径。
**修复建议**: 对 `type``name` 进行路径字符过滤,拒绝 `..``./` 等特殊路径序列。添加到 `RemoteTransport` 调用前。
---
## 3. 敏感数据处理
### 3.1 Restic 密码和凭据明文存储
**严重程度**: 高
**文件**: `backup/BackupConfig.kt`
**位置**: 第 69、73 行
```kotlin
val resticPassword: String = "",
val resticBackendPass: String = "",
```
**文件**: `backup/BackupConfig.kt`
**位置**: 第 139-186 行 (`toFile`)
```kotlin
appendLine("restic_password=\"${config.resticPassword}\"")
appendLine("restic_backend_pass=\"${config.resticBackendPass}\"")
```
所有密码以明文写入配置文件 `backup_settings.conf`,存储在 `filesDir``/data/data/com.example.androidbackupgui/files/`)。在已有 root 权限的设备上,其他 root 进程可以读取该文件。Android `android:allowBackup="true"` 更使 ADB 备份可以提取此文件。
**修复建议**:
- 使用 `EncryptedSharedPreferences`AndroidX Security加密存储密码
- 或在运行时从用户输入获取密码,不持久化到磁盘
-`allowBackup` 设为 `false` 以防止 ADB 备份提取
### 3.2 SSAID 唯一标识符泄露
**严重程度**: 中
**文件**: `backup/BackupOperation.kt`
**位置**: 第 331-347 行 (`backupSsaid`)
```kotlin
val ssaidLine = result.output.lines().firstOrNull { line ->
line.contains("packageName=\"$packageName\"") || line.contains("packageName='$packageName'")
}
val value = ssaidLine
?.substringAfter("value=\"")
?.substringBefore("\"")
?.takeIf { it.isNotBlank() }
if (value != null) {
File(appDir, "ssaid.txt").writeText(value) // 明文写入备份输出
}
```
SSAIDSettings Secure Android ID是每个应用的唯一标识符属于 `Settings.Secure` 级别的敏感标识符。备份文件中的 `ssaid.txt` 以明文存储,且:
**文件**: `backup/LogUtil.kt` 间接受到影响(日志中可能包含 SSAID
实际上没有日志泄露,但 `ssaid.txt` 作为备份的一部分进入 restic 仓库restic 仓库本身加密但元数据路径可见。
**修复建议**: SSAID 备份/恢复是 restore 功能的核心需求,当前处理方式可接受。但应在文档中说明此行为。
### 3.3 WiFi 配置包含网络密码
**严重程度**: 中
**文件**: `backup/WifiManager.kt`
**位置**: 第 41-47 行 (`backup`)
```kotlin
val result = RootShell.exec("cp '$wifiSource' '${wifiDest.absolutePath.shellEscape()}'")
```
WiFi 配置文件(`WifiConfigStore.xml``wpa_supplicant.conf`)包含网络 SSID 和密码的明文或哈希值。这些文件被复制到备份输出,进而可能被 restic 快照处理。
**修复建议**: 在备份 WiFi 配置时过滤或加密敏感字段。WiFi 密码至少应标记为需要额外保护。
### 3.4 Restic 密码通过环境变量传递
**严重程度**: 中性(设计合理)
**文件**: `backup/ResticEnvResolver.kt`
**位置**: 第 17、35 行
```kotlin
env["RESTIC_PASSWORD"] = password
```
通过环境变量而非命令行参数传递密码是**正确的做法**,可以防止密码被 `ps` 等进程列表工具窥探。这是值得保持的好设计。
**注意**: 环境变量仍可被 `/proc/self/environ` 读取(在 root 权限下),但对于该应用的威胁模型(已有 root 权限),这是可接受的。
---
## 4. API 安全
### 4.1 ResticRestBridge 无认证监听本地端口
**严重程度**: 高
**文件**: `backup/ResticRestBridge.kt`
**位置**: 第 22-27 行
```kotlin
class ResticRestBridge(...) : NanoHTTPD(0) {
```
`NanoHTTPD(0)` 默认绑定到 `0.0.0.0`所有网络接口端口由系统分配0 表示任意可用端口)。桥接器不包含任何认证机制:
- 第 36-54 行 (`serve`): 没有 IP 过滤、Token 检查或任何认证
- 第 62-117 行 (`handleRequest`): 直接处理所有 HTTP 方法GET/POST/DELETE/HEAD
- 第 348-371 行 (`handlePostBlob`): 接受任意文件上传到远程存储
- 第 376-386 行 (`handleDeleteBlob`): 允许删除远程存储中的任意 blob
**文件**: `backup/RestBridgeRunner.kt`
**位置**: 第 76 行
```kotlin
val bridgeUrl = "rest:http://127.0.0.1:$port/$repoPath"
```
虽然 restic 客户端被指示连接到 `127.0.0.1`,但 NanoHTTPD 服务器绑定在 `0.0.0.0`。同一局域网/WLAN 下的其他设备可以访问此端口。
**修复建议**: 创建 NanoHTTPD 时指定只监听 127.0.0.1。NanoHTTPD 构造函数的端口参数后可以添加 IP 地址参数,或使用 `NanoHTTPD("127.0.0.1", 0)`(如果 API 支持)。否则,在启动后添加 iptables 规则限制本地访问。
### 4.2 ResticRestBridge 错误信息泄露
**严重程度**: 低
**文件**: `backup/ResticRestBridge.kt`
**位置**: 第 47-51 行
```kotlin
} catch (e: Exception) {
Log.e(TAG, "request failed: $method $uri", e)
newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "text/plain",
e.message ?: "Internal error"
)
}
```
异常消息直接返回给 HTTP 客户端。更严重的是,`streamBodyToFile` 的失败也返回给客户端:
```kotlin
// 第 207-210 行
if (tmpResult.isFailure) return@runBlocking newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "text/plain",
"body read failed: ${tmpResult.exceptionOrNull()?.message ?: "unknown"}"
)
```
**修复建议**: 将详细的错误消息仅记录到日志,返回通用的 "Internal error"。
---
## 5. 安全配置
### 5.1 allowBackup 启用
**严重程度**: 高
**文件**: `app/src/main/AndroidManifest.xml`
**位置**: 第 13 行
```xml
android:allowBackup="true"
```
`allowBackup="true"` 允许通过 `adb backup` 提取应用的全部私有数据,包括 `filesDir` 中的 `backup_settings.conf`(包含明文 restic 密码和备份凭据)。
**修复建议**: 设置为 `false`
```xml
android:allowBackup="false"
android:fullBackupContent="false"
```
### 5.2 无网络安全配置
**严重程度**: 中
**文件**: `app/src/main/AndroidManifest.xml`
**位置**: 第 12-18 行
应用声明了 `INTERNET``ACCESS_NETWORK_STATE` 权限,支持 WebDAV、SMB 和 rest-server 远程传输,但未配置 `android:networkSecurityConfig`。这意味着默认允许所有未加密的明文流量HTTP对于传输备份数据的场景存在安全风险。
**修复建议**: 添加 `res/xml/network_security_config.xml` 网络安全配置,明确允许/限制明文流量目标。如果仅使用内网 NAS可以限制明文到特定内网网段。
### 5.3 无备份数据加密说明
**严重程度**: 低
备份的数据(应用 APK、数据目录、WiFi 配置等不进行应用层加密。restic 仓库会进行传输中和静态加密(如果配置了),但本地 staging 目录中的备份文件放在外部存储的明文目录中。
**修复建议**: 建议用户在文档中了解本地备份目录中的文件未加密restic 仓库提供加密但需正确保管密码。
---
## 6. 日志/调试信息泄露
### 6.1 RootShell 命令日志泄露
**严重程度**: 中
**文件**: `root/RootShell.kt`
**位置**: 第 82、85 行
```kotlin
Log.w(TAG, "exec timeout (${timeoutMs}ms): $command")
Log.e(TAG, "exec failed: $command", e)
```
`RootShell.exec()` 在命令失败或超时时将完整的命令字符串记录到 logcat。如果 `exec()` 被传入包含密码或 token 的命令,这些敏感数据会被泄露到 logcat。
当前实现中 `BackupOperation.kt` 主要使用 `execSafe()`(通过 `shellEscape()`),但 `exec()` 是公有函数,任何调用者都可能传入未脱敏的命令。
**修复建议**:
- 在日志中截断或脱敏命令字符串
- 或更严格地——不在日志中包含命令内容,只记录标签和错误码
### 6.2 SSAID 值记录到日志
**严重程度**: 高
**文件**: `backup/BackupOperation.kt`
**位置**: 第 345 行
```kotlin
Log.d(TAG, "backupSsaid: backed up SSAID for $packageName = $value")
```
SSAIDSettings Secure Android ID是每个应用唯一的设备级标识符直接以明文记录到 logcat。logcat 在 Android 8+ 受权限保护,但仍可被系统应用和 adb 读取。
**文件**: `backup/RestoreOperation.kt`
**位置**: 第 398、401、411 行
```kotlin
Log.i(TAG, "restoreSsaid: restored SSAID for $packageName via XML (uid=$uid)")
Log.w(TAG, "restoreSsaid: XML edit completed but entry not found, falling back")
Log.e(TAG, "restoreSsaid: failed to set SSAID for $packageName: ${result.error}")
```
虽然恢复端未直接记录 SSAID 值,但记录了 UID唯一整数标识符结合包名可识别设备。
**修复建议**: 不在日志中记录 SSAID 值,只记录操作状态。
### 6.3 LogUtil 日志文件可能包含敏感信息
**严重程度**: 中
**文件**: `backup/LogUtil.kt`
**位置**: 第 45-58 行 (`writeLog`)
```kotlin
private fun writeLog(level: String, tag: String, message: String) {
val dir = baseDir ?: return
executor.execute {
...
val line = "$timestamp $level/$tag: $message\n"
logFile.appendText(line)
}
}
```
`LogUtil` 将所有 `i/w/e` 日志写入 `baseDir/logs/` 目录下的日期文件。这些日志文件包含 `LogUtil.i/w/e()` 调用的全部消息,可能包括命令参数、错误详情等敏感信息。日志文件保留 7 天。
```kotlin
// 第 77-84 行
fun getLogFiles(): List<File> {
val logDir = File(dir, "logs")
return logDir.listFiles()
?.filter { it.name.endsWith(".log") }
?.sortedBy { it.name } ?: emptyList()
}
```
日志文件可通过 `getLogFiles()` 获取,虽然当前没有代码直接暴露给其他应用,但 restic 备份会扫描此目录,导致日志被包含在备份快照中。
**修复建议**:
- 添加日志级别过滤,不在文件日志中包含 `Log.d` 级别的调试信息
- 考虑在日志过虑器中脱敏已知的敏感模式密码、SSAID、token
- 将日志目录添加到 restic 备份排除列表
### 6.4 配置 URL 日志可能包含内嵌凭据
**严重程度**: 低
**文件**: `ui/ConfigViewModel.kt`
**位置**: 第 178 行
```kotlin
Log.i(TAG, "initResticRepo: repo=${form.repo} backend=${form.backend} url=${form.backendUrl}")
```
如果用户将凭据嵌入 backend URL`https://user:password@host/path`这些凭据会被记录到日志。WebDAV URL 有时包含用户名。
**修复建议**: 在日志中脱敏 URL 中的用户信息部分。
### 6.5 Shell 命令冗余日志
**严重程度**: 低
**文件**: `backup/ResticCommandRunner.kt`
**位置**: 第 36、42、76-77 行等
```kotlin
Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args -> cmd=$cmd")
Log.i(TAG, "runRestic cmd=${cmdArgs.joinToString(" ")}")
Log.i(TAG, "runRestic exitCode=$exitCode stdout_len=${stdout.length}")
if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText.trim()}")
```
尽管密码通过环境变量而非命令行参数传递(正确做法),但命令参数被完整记录。在 restic `init``backup``restore` 等命令中,命令行包含仓库路径、标签、主机名等信息,这些信息本身通常不敏感,但 `args` 参数在日志中可见。
文件路径日志(第 173 行):
```kotlin
Log.i(TAG, "runResticWithStdin cmd=${cmdArgs.joinToString(" ")} stdin=${stdinFile.absolutePath}")
```
**修复建议**: 当前日志设计合理——密码不在命令行中,因此日志不包含密码。无需更改。
---
## 7. Intent/组件暴露
### 7.1 ResticRestBridge 绑定到 0.0.0.0
**严重程度**: 高
(已在 4.1 中详述——此问题跨类别)
**文件**: `backup/ResticRestBridge.kt`
**位置**: 第 27 行
```kotlin
) : NanoHTTPD(0) {
```
NanoHTTPD 默认绑定所有网络接口。同一设备上或同一网络中的恶意应用/用户可访问此 REST 接口,读取/写入远程存储中的 blob 数据。
### 7.2 BackupService 未导出但使用隐式 Intent
**严重程度**: 低
**文件**: `backup/BackupService.kt`
**位置**: 第 21-23 行
```kotlin
const val ACTION_START_BACKUP = "com.example.androidbackupgui.action.START_BACKUP"
const val ACTION_STOP_BACKUP = "com.example.androidbackupgui.action.STOP_BACKUP"
const val EXTRA_STATUS_TEXT = "status_text"
```
**文件**: `ui/BackupFragment.kt`
**位置**: 第 190-192、391-394 行
```kotlin
val serviceIntent = Intent(requireContext(), BackupService::class.java)
serviceIntent.action = BackupService.ACTION_START_BACKUP
```
Service 声明为 `exported="false"`所以只有同一应用内可访问——安全。Action 字符串使用完整包名前缀,避免了与其他应用的 Intent 冲突。
### 7.3 MainActivity 导出为 LAUNCHER
**严重程度**: 低(标准做法)
**文件**: `app/src/main/AndroidManifest.xml`
**位置**: 第 20-27 行
```xml
<activity android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
```
标准 LAUNCHER Activity 导出设置,但在 root 应用上下文中,其他应用可以调用此 Activity 触发初始化和权限请求流程。
**修复建议**: 对于意外启动,可添加 `android:exported="true"` 但仅保留 MAIN/LAUNCHER intent-filter。当前配置已正确。
---
## 问题严重程度汇总
|编号|严重程度|类型|文件|行号|
|---|---|---|---|---|
|3.1|**高**|敏感数据-明文密码|BackupConfig.kt|69,73,178-182|
|5.1|**高**|安全配置-allowBackup|AndroidManifest.xml|13|
|4.1 / 7.1|**高**|API 安全-无认证桥接|ResticRestBridge.kt|27,36-54|
|6.2|**高**|日志泄露-SSAID|BackupOperation.kt|345|
|2.1|中|输入校验-密码空值检查缺失|ConfigViewModel.kt|217-256|
|2.2|中|输入校验-字段无格式验证|BackupConfig.kt, ConfigFragment.kt|78-136,200-217|
|2.3|中|输入校验-路径注入风险|ResticRestBridge.kt|62-117|
|3.2|中|敏感数据-SSAID 明文备份|BackupOperation.kt|331-347|
|3.3|中|敏感数据-WiFi 配置含密码|WifiManager.kt|41-47|
|5.2|中|安全配置-无 networkSecurityConfig|AndroidManifest.xml|12-18|
|6.1|中|日志泄露-命令内容|RootShell.kt|82,85|
|6.3|中|日志泄露-文件日志含敏感信息|LogUtil.kt|45-58|
|1.1|低|授权-无权限检查模式|BackupOperation.kt|多处|
|1.2|低|配置-冗余 libsu 日志|RootShell.kt|54|
|4.2|低|API-错误信息泄露|ResticRestBridge.kt|47-51,207-210|
|6.4|低|日志泄露-URL 可能含凭据|ConfigViewModel.kt|178|
---
## 最重要的修复建议(按优先级排序)
1. **(紧急)修复 ResticRestBridge 绑定到 0.0.0.0** — 改为仅监听 127.0.0.1,防止局域网内其他设备访问 REST 桥接 API。
2. **(紧急)设置 allowBackup="false"** — 防止 ADB 备份提取明文密码配置文件。
3. **(高优先级)移除 SSAID 值日志输出** — `BackupOperation.kt:345` 中删除 `= $value` 部分。
4. **(高优先级)对备份配置使用加密存储** — 使用 `EncryptedSharedPreferences` 或运行时密码输入,避免密码明文持久化。
5. **(中优先级)添加输入验证层** — 对 `resticBackendUrl` 等字段进行格式验证,所有操作前检查密码非空。
6. **(中优先级)添加 networkSecurityConfig** — 限制明文流量目标。
7. **(中优先级)审查 LogUtil 日志内容** — 确保日志文件中不包含密码/SSAID 等敏感字段。

333
security-review-report.md Normal file
View File

@@ -0,0 +1,333 @@
# Android Backup GUI — 安全审查报告
**审查日期**: 2026-06-06
**审查范围**: 37 个 Kotlin 源文件
**审查技能**: 安全漏洞检测注入、Secret 泄露、权限滥用、路径遍历)
---
## 严重程度分级说明
| 等级 | 定义 |
|------|------|
| CRITICAL | 可直接导致 root 提权、用户数据泄露或远程命令执行的漏洞。必须立即修复。 |
| HIGH | 在特定条件下可导致敏感数据泄露或越权访问。应在下一版本修复。 |
| MEDIUM | 安全风险较低,或需要复杂攻击链才能利用。建议规划修复。 |
| LOW | 信息泄露风险极低,或设计上可接受但不够理想。可选修复。 |
---
## 发现汇总
| # | 严重程度 | 类别 | 文件 | 行号 |
|---|----------|------|------|------|
| 1 | **CRITICAL** | Secret 泄露 | `BackupConfig.kt` | 69, 73, toFile() |
| 2 | **CRITICAL** | Secret 泄露 | `BackupConfig.kt` | toFile() |
| 3 | **HIGH** | 认证缺失 | `ResticRestBridge.kt` | 27 |
| 4 | **HIGH** | 路径遍历/越权写入 | `RestoreOperation.kt` | restoreData() |
| 5 | **MEDIUM** | 命令注入(Sed) | `RestoreOperation.kt` | 250-253 |
| 6 | **MEDIUM** | 信息泄露(Logcat) | `RootShell.kt` | 55 |
| 7 | **MEDIUM** | 路径遍历 | `ResticRestBridge.kt` | 246-257 |
| 8 | **MEDIUM** | 信息泄露(Logcat) | `ResticCommandRunner.kt` | 40-41 |
| 9 | **MEDIUM** | 加密/安全存储 | `BackupConfig.kt` | 68-73 |
| 10 | **LOW** | 缺少参数验证 | `AppScanner.kt` | 多处 |
| 11 | **LOW** | SMB 签名关闭 | `SmbTransport.kt` | 26 |
| 12 | **LOW** | 证书固定缺失 | `WebdavTransport.kt` | 22-28 |
---
## 详细发现
### 🔴 CRITICAL: 凭据明文存储在配置文件中
**文件**: `BackupConfig.kt` 第 69 行
**文件**: `BackupConfig.kt` 第 73 行
**文件**: `BackupConfig.kt` toFile() 方法
```kotlin
// 第 69 行
val resticPassword: String = "",
// 第 73 行
val resticBackendPass: String = "",
```
**问题**: Restic 仓库密码、SMB/WebDAV 密码以明文形式存储在 `backup_settings.conf` 文件中。配置文件位于 `filesDir/backup_settings.conf`,在 root 权限下对任何进程可读。`toFile()` 方法(~第 156-157 行)将密码直接写入文件:
```kotlin
appendLine("restic_password=\"${config.resticPassword}\"")
appendLine("restic_backend_pass=\"${config.resticBackendPass}\"")
```
此外UI 中密码以明文显示和编辑(`ConfigFragment.kt` 第 151 行)。
**风险**: 任何具有 root 权限的进程(或通过漏洞获得 root 的恶意应用)可读取这些凭据。如果用户使用相同的 restic 密码保护多个设备,泄露范围会扩大。
**建议**:
1. 使用 Android `EncryptedSharedPreferences` 存储密码(加密后存储在配置目录)
2. 密码字段在 UI 中使用 `inputType="textPassword"` 隐藏显示
3. 考虑使用 Android Keystore 进行密钥管理
4. 配置文件设置为仅 app 自身可读(`MODE_PRIVATE`,但 root 环境下效果有限)
---
### 🔴 CRITICAL: 配置文件写入默认权限不安全
**文件**: `BackupConfig.kt``toFile()` 方法(~第 144 行)
```kotlin
fun toFile(config: BackupConfig, file: File) {
file.parentFile?.mkdirs()
file.writeText(buildString { ... })
}
```
**问题**: `file.writeText()` 使用系统默认文件权限。在 Android 上,`filesDir` 中的文件默认模式为 `MODE_PRIVATE`,但 root 权限环境绕过此保护。此外没有任何文件权限的显式设置。
**建议**: 保存配置文件后显式设置权限:
```kotlin
file.setReadable(true, true) // owner-only readable
file.setWritable(true, true) // owner-only writable
```
考虑迁移到 Android KeyStore + EncryptedSharedPreferences。
---
### 🔴 HIGH: ResticRestBridge 绑定到所有网络接口且无认证
**文件**: `ResticRestBridge.kt` 第 27 行
```kotlin
class ResticRestBridge(...) : NanoHTTPD(0) {
```
**问题**: `NanoHTTPD(0)` 绑定到 `0.0.0.0`(所有网络接口),随机端口。而桥接 URL 使用的是 `127.0.0.1``RestBridgeRunner.kt` 第 72 行),但服务器本身对所有接口开放。该桥接提供无需任何认证的完整备份仓库读写访问(`GET`/`POST`/`DELETE` blob、`HEAD` 检查、`list` 操作)。
**风险**: 设备上任何进程(不需要 root都可以扫描开放端口、连接到桥接并读取或写入备份仓库。由于桥接在随机端口上运行且生命周期短暂利用难度稍高但仍存在。
**建议**:
1. 使用 `ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"))` 或 NanoHTTPD 的 `bindAddr` 参数显式绑定到 localhost
2. 添加认证令牌restic REST API 支持 token 认证)
3. 限制响应时间窗口,使用后立即删除 blob
---
### 🔴 HIGH: Tar 解压使用 `-C /` 可能导致系统文件覆写
**文件**: `RestoreOperation.kt``restoreData()` 方法(~第 137-149 行)
```kotlin
val baseCmd = when {
archive.name.endsWith(".zst") ->
"set -o pipefail; $zstdCmd -d -c '$archivePath' | $tarCmd -xf - $excludeArgs -C / 2>/dev/null"
...
}
```
**问题**: 备份存档使用根目录 `/` 解压。`isArchiveSafe` 方法(~第 220-232 行)仅检查 `..` 路径穿越和指向外部的符号链接,但**不检查**
- 存档中的绝对路径条目(如 `/etc/passwd``/system/bin/app_process`
- 硬链接(可绕过 `..` 检查)
- 设备节点
- 解压总量(可用于磁盘空间耗尽攻击)
如果攻击者能够修改备份文件(例如通过恶意 App 访问外部存储),解压操作可覆写任意系统文件。
**建议**:
1. 添加对绝对路径的检查 —— 拒绝包含 `/` 前缀路径(绝对路径)的存档
2. 使用 `isArchiveSafe` 补充绝对路径检测:`line.startsWith("/")`
3. 考虑使用 `--strip-components` 选项或在临时目录解压后再移动到目标路径
4. 添加存档大小和解压条目数量上限
---
### 🟡 MEDIUM: SSAID 恢复中的 Sed 命令注入风险
**文件**: `RestoreOperation.kt` 第 250-253 行
```kotlin
val manipCmd = buildString {
append("sed -i \"/package.*${packageName.shellEscape()}/d\" '$targetFile' && ")
append("sed -i \"s#</settings>#<setting id=\\\"$id\\\" package=\\\"${packageName.shellEscape()}\\\" value=\\\"${ssaidValue.shellEscape()}\\\" defaultValue=\\\"default\\\" />\\n</settings>#\" '$targetFile'")
}
```
**问题**: `ssaidValue``packageName` 虽然经过了 `shellEscape()`(处理单引号),但 Sed 模式中使用 `#` 作为分隔符。如果 `ssaidValue` 包含 `#`UUID 不可能,但从文件读取的 SSAID 可能包含任意字符),会破坏 Sed 命令结构。此外,`shellEscape()` 只处理 shell 层的单引号,不处理 Sed 层的 `\``&``/` 等特殊字符。
**风险**: 若攻击者可通过修改 `ssaid.txt` 文件插入恶意 Sed 表达式,可能导致任意文件写入。
**建议**:
1. 使用纯 Kotlin XML 解析(如 `XmlPullParser`)操作 `settings_ssaid.xml`,而不是 Sed
2. 或使用 `sed -e` 的分隔符参数引用,并验证 `ssaidValue` 只包含十六进制字符
---
### 🟡 MEDIUM: RootShell 启用了 libsu 详细日志
**文件**: `RootShell.kt` 第 55 行
```kotlin
Shell.enableVerboseLogging = true
```
**问题**: libsu 的详细日志会将所有 shell 命令输出到 Logcat。Logcat 在 Android 上对任何具有 `READ_LOGS` 权限的应用可读。这可能导致命令路径、参数、错误消息等信息泄露。
**风险**: 调试期间有助于开发,但生产版本应禁用。命令本身不包含密码(通过环境变量传递),但路径结构和目录名可能暴露敏感信息。
**建议**:
1. 根据构建类型控制日志级别:
```kotlin
Shell.enableVerboseLogging = BuildConfig.DEBUG
```
2. 或完全移除该行
---
### 🟡 MEDIUM: ResticRestBridge JSON 手动拼接存在注入风险
**文件**: `ResticRestBridge.kt` 第 246-257 行
```kotlin
private fun buildV2Json(items: List<RemoteTransport.RemoteFileInfo>): String {
val sb = StringBuilder("[")
var first = true
for (item in items) {
...
sb.append("{\"name\":\"${item.name}\",\"size\":${item.size}}")
}
sb.append("]")
return sb.toString()
}
```
**问题**: 文件名 `item.name` 直接插值到 JSON 字符串中。若远程存储上的文件名包含 `"``\\``\n` 等字符,会破坏 JSON 结构,可能导致解析错误或意外的数据暴露。
**风险**: 文件名来自远程存储SMB/WebDAV攻击者可能控制这些名称。返回给 restic 的损坏 JSON 可能导致备份操作失败或状态误报。
**建议**: 使用 `kotlinx-serialization``JSONArray` 构建 JSON。
---
### 🟡 MEDIUM: ResticCommandRunner 日志暴露仓库 URL
**文件**: `ResticCommandRunner.kt` 第 40-41 行
```kotlin
Log.d(TAG, "runRestic REPOSITORY=${env["RESTIC_REPOSITORY"]}")
```
**问题**: 虽然代码注释正确指出 `RESTIC_PASSWORD` 不应记录,`RESTIC_REPOSITORY` 仍可能包含 SMB 共享名称、仓库路径等敏感信息。Logcat 可被其他应用读取。
**建议**: 至少将敏感部分截断或哈希,或仅在 DEBUG 构建下记录。
---
### 🟡 MEDIUM: 多个凭据未加密存储在内存中
**文件**: `BackupConfig.kt` 第 68-73 行
```kotlin
val resticPassword: String = "",
val resticBackendUser: String = "",
val resticBackendPass: String = "",
```
**问题**: `BackupConfig` 作为 `@Serializable data class`,所有密码字段在进程生命周期内以不可变字符串形式保存在内存中。字符串在 Java 中不可变,无法显式清除(零覆盖)。
此外,`ResticWrapper` 的所有公开 API 方法都将密码作为方法参数传递,导致 Activity/Fragment/ViewModel 中密码的副本散布各处。
**建议**:
1. 通过值对象传递密码,操作完成后立即清除
2. 考虑使用 `CharArray` 并在使用后填充空白
3. 在传递之间最小化密码在 Kotlin 对象图中的驻留时间
---
### 🟢 LOW: AppScanner 中 userId 参数缺少非负验证
**文件**: `AppScanner.kt` 多处
```kotlin
suspend fun scanThirdParty(context: Context, userId: Int = 0): List<AppInfo> = withContext(Dispatchers.IO) {
val result = RootShell.exec("pm list packages -3 --user $userId")
```
**问题**: `userId` 虽然类型为 `Int`,直接插值到 shell 命令中。如果传入负数(如 -1可能导致意外行为。但 `userId` 来自 Spinner 选择或 `UserId` 值类(已验证非负),因此实际风险很低。
**建议**: 在 UI 层、`UserId` 值类或 `AppScanner` 入口处增加正数验证。
---
### 🟢 LOW: SMB 传输默认关闭签名/加密
**文件**: `SmbTransport.kt` 第 26 行
```kotlin
private val smbSigning: Boolean = false
```
**问题**: SMB 签名和加密默认禁用。在不安全的网络中,攻击者可进行 SMB 中继攻击。代码注释说明"多数家庭服务器不支持",是合理的取舍。
**建议**: 在 UI 配置页面添加 SMB 签名开关,让用户根据网络环境决定。
---
### 🟢 LOW: WebDAV 传输缺少证书固定
**文件**: `WebdavTransport.kt` 第 22-28 行
```kotlin
private val sardine: Sardine by lazy {
OkHttpSardine().apply {
if (username.isNotEmpty()) {
setCredentials(username, password)
}
}
}
```
**问题**: `OkHttpSardine` 使用默认的 HTTPS 配置没有自定义证书验证或证书固定Certificate Pinning。中间人攻击MITM可窃取 WebDAV 的备份凭据。
**建议**: 对于重视安全的场景,可选支持证书固定,或在 UI 中显示当前 HTTPS 证书指纹。
---
## 正向发现(设计良好的安全实践)
| 实践 | 文件 | 说明 |
|------|------|------|
| ✅ 密码通过环境变量传递 | `ResticEnvResolver.kt` | RESTIC_PASSWORD 通过 env 传递,不在命令行中出现 |
| ✅ `shellEscape()` 一致使用 | `root/RootShell.kt:15` | 所有 shell 拼接参数都经过了转义 |
| ✅ `execSafe()` 安全方法 | `root/RootShell.kt:95-101` | 提供自动参数转义的执行方法 |
| ✅ ProcessBuilder 列表参数 | `ResticCommandRunner.kt` | 使用 List<String> 参数,无 shell 拼接 |
| ✅ `isArchiveSafe()` 路径穿越检查 | `RestoreOperation.kt:220-232` | 解压前检查 `..` 和危险符号链接 |
| ✅ 类型安全的值类 | `DomainTypes.kt` | `PackageName``UserId` 提供编译期类型安全 |
| ✅ 定时命令超时 | `RootShell.kt:26` | 120 秒超时防止命令挂死 |
| ✅ 取消传播 | 多处 | CancellationException 正确重新抛出 |
| ✅ RESTIC_PASSWORD 不记录日志 | `ResticCommandRunner.kt:42` | 明确注释不记录密码 |
---
## 风险优先级建议
### 立即修复 (CRITICAL)
1. **BackupConfig.kt**: 使用 `EncryptedSharedPreferences` 替换明文配置存储
2. **BackupConfig.kt**: 保存后设置文件权限为 `MODE_PRIVATE`
### 下一版本修复 (HIGH)
3. **ResticRestBridge.kt**: 绑定到 127.0.0.1 而非 0.0.0.0
4. **RestoreOperation.kt**: `isArchiveSafe` 增加绝对路径检查;解压到临时目录再移动
### 规划修复 (MEDIUM)
5. **RestoreOperation.kt**: SSAID XML 操作改为 XML 解析器而非 Sed
6. **ResticRestBridge.kt**: JSON 改用序列化库构建
7. **RootShell.kt**: 生产环境禁用详细日志
8. **ResticCommandRunner.kt**: 截断或保护仓库 URL 日志
### 可选改进 (LOW)
9. **SmbTransport.kt**: 考虑默认启用 SMB 签名
10. **WebdavTransport.kt**: 可选证书固定支持
11. **AppScanner.kt**: 添加 userId 验证
---
*注意: 本报告未包含 `memory://root/memory_summary.md` 中记录的 7 个已知待处理项。*

484
silent-failure-review.md Normal file
View File

@@ -0,0 +1,484 @@
# 静默失败审查报告 — android-backup-gui
> 审查日期: 2026-06-06
> 审查范围: 37 个 Kotlin 源文件
> 已排除: memory://root 已知的 7 个待处理项
---
## 严重程度分级
- **CRITICAL**: 数据静默损坏或丢失,用户无法感知
- **HIGH**: 错误被吞没,导致后续操作基于错误假设继续
- **MEDIUM**: 错误被吞没但影响范围有限,或仅影响辅助功能
- **LOW**: 微小错误处理缺失,实际影响小
---
## 发现清单
### F1 [HIGH] — SMB 上传大小不匹配不报告错误
**文件**: `SmbTransport.kt:103-109`
**类型**: 未检查的返回值 / 静默数据损坏
```kotlin
if (actualSize != fileSize) {
Log.w(TAG, "upload size mismatch: local=$fileSize smb=$actualSize")
SmbFileOutputStream(remote).use { it.write(ByteArray(0)) }
val retrySize = freshRemote.length()
Log.w(TAG, "upload retry: smb=$retrySize bytes")
}
// 继续返回 Success(Unit)
```
即使 SMB 端实际存储的字节数与本地不一致,`upload()` 仍返回 `AppResult.Success(Unit)`。写入零字节空数组的"修复"尝试没有验证效果。如果 SMB 服务器写入缓存有问题或磁盘空间不足restic blob 数据可能部分损坏,而上层调用者 (`RestBridgeRunner`) 不知道。
**建议**: 当 `actualSize != fileSize` 时,应返回 `err(AppError.Remote("SMB 上传大小不匹配: local=$fileSize vs smb=$actualSize", "upload"))`
---
### F2 [HIGH] — backupUserData 全失败时返回成功
**文件**: `BackupOperation.kt:255-257`
**类型**: 错误替换/空回退
```kotlin
if (!archiveCreated) {
Log.w(TAG, "backupUserData: $packageName all methods failed ...")
return true // 返回成功!
}
```
当三种数据备份方法全部失败时目录不存在、权限不足、tar 不可用),函数返回 `true`。上层调用者 (`BackupOperation.backupApps` line 131) 看到 `true` 就认为数据备份成功,累加 `successAtomic`,用户看到的报告就是"成功"。应用的用户数据被静默跳过。
**建议**: 改为 `return false` 让调用者知道数据备份实际失败。如果需要容错(某些应用确实没有数据目录),应在 `backupUserData` 外部判断,或返回区分"跳过"和"失败"的信号。
---
### F3 [HIGH] — CancellationException 被空 catch 吞没
**文件**: `ResticBackup.kt:55-58`, `ResticBackup.kt:73-77`, `ResticBackup.kt:117-120`, `ResticBackup.kt:130-134`
**类型**: 异步错误丢失
```kotlin
try {
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
if (progress.messageType == "status") emit(progress)
} catch (_: Exception) { }
```
`catch (_: Exception)` 会捕获 `kotlinx.coroutines.CancellationException`。如果协程在 JSON 解析期间被取消,取消信号被吞没,进度回调继续运行。虽然在 `runResticStreaming`/`runResticWithStdin` 内部也有协程活跃检查(`!coroutineContext.isActive`),但取消信号仍可能延迟或丢失。
**建议**: 在空 catch 前加 `catch (e: CancellationException) { throw e }`,或改用 `catch (e: Exception) { if (e is CancellationException) throw e }`
---
### F4 [HIGH] — WebDAV mkdirs 完全失败时仍返回成功
**文件**: `WebdavTransport.kt:153-155`
**类型**: 错误替换
```kotlin
} catch (e: Exception) {
Log.w(TAG, "mkdirs failed: $remotePath${e.message}")
AppResult.Success(Unit) // best-effort
}
```
即使所有目录层级都无法创建,该方法返回 `AppResult.Success(Unit)`。注释说"upload will fail if dir can't be created",但上传可能在更深层的操作上以不同的错误信息失败(如"permission denied" vs "directory not found"),使诊断更加困难。上层调用者无法区分"目录已存在"和"完全无法创建"。
**建议**: 仅在确定目录确实存在时返回 Success如 SMB 实现中检测 `STATUS_OBJECT_NAME_COLLISION`)。对所有其他异常应返回 `err(AppError.Remote(...))`
---
### F5 [MEDIUM] — WifiManager.restore 始终返回 true
**文件**: `WifiManager.kt:54-85`
**类型**: 错误替换
整个 `restore()` 方法始终返回 `true`line 84即使
- `findWifiConfigPath()` 返回 null 且 fallback 路径无法创建目录line 63-64 返回 false但被统一 return@withContext false 处理... 等等这里 line 84 是最后一行)
- 实际上 line 63 `return@withContext false` 确实会提前返回。但如果成功执行到 line 84无论如何都返回 true。中间 `cp``chown``chmod` 的失败仅被记录日志,不通知调用者。
**建议**: `cp``chmod`/`chown` 失败时应返回 false。当前 RestoreFragment 中 `WifiManager.restore(dir)` 的返回值没有被使用,但接口应该诚实。
---
### F6 [MEDIUM] — ResticWrapper.getLatestSnapshotAppDetails 静默返回 null
**文件**: `ResticWrapper.kt:270-275`, `ResticWrapper.kt:288`
**类型**: 空回退
```kotlin
is AppResult.Failure -> {
Log.w(TAG, "getLatestSnapshotAppDetails: listSnapshots failed: ...")
null
}
// 和
is AppResult.Failure -> return@withContext null
```
`listSnapshots()``dump()` 失败时返回 `null`。调用者 (`BackupFragment.kt:228`) 仅检查 `snapshotApps != null`,看到 null 就跳过累积快照逻辑。用户不知道仓库存在但无法读取——也可能是仓库密码错误、网络问题或权限问题。但此行为在 API 文档中有意说明,且后续备份仍能工作,只是失去了累积合并能力。
**建议**: 考虑返回 `AppResult<Map<String, SnapshotAppInfo>?>` 以区分"无快照"和"读取失败"。或者增加 UI 通知。
---
### F7 [MEDIUM] — parseAppDetailsJson 捕获所有异常
**文件**: `ResticWrapper.kt:315-317`
**类型**: 被吞没的异常
```kotlin
} catch (_: Exception) {
Log.w(TAG, "parseAppDetailsJson: failed to parse JSON")
}
```
捕获所有 `Exception` 类型(包括 `CancellationException``OutOfMemoryError` 等)。虽然当前函数在 `Dispatchers.IO` 上下文外的同步路径调用,但应该缩小异常范围。
**建议**: 改为 `catch (e: org.json.JSONException)`
---
### F8 [MEDIUM] — StreamingBackup mkfifo 失败不报告
**文件**: `StreamingBackup.kt:50`
**类型**: 未检查的返回值
```kotlin
RootShell.exec("mkfifo '${fifo.absolutePath.shellEscape()}'")
```
`mkfifo` 的执行结果完全被忽略。如果 `mkfifo` 失败例如文件系统只读、磁盘满FIFO 文件不存在,后续 `restic backup --stdin` 会以模糊的错误失败。`StreamingBackup.prepareStreaming` 返回的 `StreamingResult` 将包含无效的 FIFO 路径。调用者在 `BackupFragment.kt:484` 直接使用结果,没有验证 FIFO 是否创建成功。
**建议**: 检查结果并抛出异常或返回失败信号。
---
### F9 [MEDIUM] — BackupFragment 中 restore 操作结果被忽略
**文件**: `ui/BackupFragment.kt:274-284`
**类型**: 未检查的返回值
```kotlin
ResticWrapper.restore(
repoPath = config.resticRepo,
password = config.resticPassword,
snapshotId = latestSnap.shortId,
targetPath = backupRoot.absolutePath,
...
)
```
在累积备份流程中,从 restic 仓库恢复最新快照到本地暂存目录的结果完全被忽略。如果恢复失败(例如密码错误、网络中断),`backupRoot` 目录可能不完整,但备份操作继续执行。后续 `BackupOperation.backupApps` 可能会基于不完整的文件结构工作。
**建议**: 检查 `restore()``AppResult`,如果失败则终止备份流程并通知用户。
---
### F10 [MEDIUM] — StreamingBackup.launchDataProducer 的 tar 失败仅记录日志
**文件**: `StreamingBackup.kt:116-118`
**类型**: 被吞没的异常
```kotlin
if (!result.isSuccess) {
Log.w(TAG, "Data backup failed for $pkgName: ${result.error}")
}
```
单个应用的 tar 数据备份失败时仅记录日志,继续下一个应用。调用者 (`BackupFragment.kt:534`) 通过 `producerJob.await()` 等待完成,但该函数总是返回 `true`除非协程被取消。这意味着即使某些应用的数据完全没有备份调用者也认为一切正常。restic 对缺失数据无法感知——它只归档了 FIFO 中收到的内容。
**建议**: 收集失败列表并通过返回值或回调通知调用者。
---
### F11 [MEDIUM] — RestBridgeRunner 中未识别的后端静默穿透
**文件**: `RestBridgeRunner.kt:61`
**类型**: 空回退
```kotlin
val t = transportFactory(...)
?: return block(repoPath)
```
`RemoteTransport.create()` 返回 `null`(未知 backend代码直接调用 `block(repoPath)`,其中 `repoPath` 是原始路径字符串而不是桥接 URL。restic 会收到一个可能无效的仓库 URL产生令人困惑的错误"repository doesn't exist" 而不是"未知后端类型")。
**建议**: 至少记录一个错误,或抛出异常说明后端类型不支持。
---
### F12 [MEDIUM] — SMB listFiles 在无权限时静默返回空列表
**文件**: `SmbTransport.kt:165-186`
**类型**: 空回退
```kotlin
val entries = dir.listFiles()
?.map { f -> ... }
?: emptyList()
```
`SmbFile.listFiles()` 在 SMB 权限不足时可能返回 `null`。此时 `?: emptyList()` 将静默返回空列表。调用者可能认为路径是空的而不是没有读取权限。SMB 协议可以在 `SmbException` 中返回具体的 ntStatus 错误,但这里的 null 合并将错误掩盖了。
**建议**: 在 `else` 分支或 `catch` 中检查文件是否确实存在,如果存在但 listFiles 返回 null应返回错误而非空列表。
---
### F13 [MEDIUM] — backupPermissions 静默跳过
**文件**: `BackupOperation.kt:349-354`
**类型**: 被吞没的异常
```kotlin
private suspend fun backupPermissions(packageName: String, appDir: File) {
val result = RootShell.exec("dumpsys package ...")
if (result.output.isNotBlank()) {
File(appDir, "permissions.txt").writeText(result.output)
}
}
```
如果 `dumpsys package` 命令失败或输出为空,权限备份静默跳过。`backupApps` 在 line 163 调用此函数时不检查结果,也不记录错误。恢复时将没有权限文件,应用以默认权限运行。
**建议**: 至少在 `dumpsys` 命令失败时记录日志。考虑返回 `Boolean` 让调用者知晓。
---
### F14 [MEDIUM] — backupSsaid 静默跳过
**文件**: `BackupOperation.kt:331-347`
**类型**: 被吞没的异常
```kotlin
private suspend fun backupSsaid(packageName: String, appDir: File, userId: String) {
val result = RootShell.exec("cat '$ssaidFile' 2>/dev/null")
if (!result.isSuccess || result.output.isBlank()) return
// ...
}
```
如果 XML 文件无法读取或解析失败SSAID 备份完全静默跳过。SSAID 是 Google 广告标识符,丢失后用户可能收到新的 ID。
**建议**: 在 cat 命令失败时记录警告日志。
---
### F15 [MEDIUM] — initResticRepo 使用 exceptionOrNull 可能导致 null 显示
**文件**: `ui/ConfigViewModel.kt:205-207`
**类型**: 错误替换
```kotlin
Log.e(TAG, "initResticRepo failed: ${result.exceptionOrNull()?.message}")
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(
message = "初始化失败: ${result.exceptionOrNull()?.message}"
))}
```
`AppResult.exceptionOrNull()` 创建一个新的 `RuntimeException`,如果原始 `AppError.message` 为 null例如 `AppError.Restic("", -1, "")`),用户将看到 "初始化失败: null"。
**建议**: 使用 `${result.errorOrNull()?.message ?: "未知错误"}`
---
### F16 [MEDIUM] — RestBridgeRunner 中临时文件删除结果未检查
**文件**: `RestBridgeRunner.kt:85-88`
**类型**: 资源泄露
```kotlin
val blobs = cacheDir.listFiles { f -> f.name.startsWith("restic_blob_") }
if (blobs != null) {
for (f in blobs) f.delete()
}
```
临时 blob 文件删除的结果未检查,且 `listFiles` 筛选器可能遗漏子目录中的临时文件(如 `ResticRestBridge``cacheDir` 中创建 `restic_blob_*` 文件)。随着操作频繁进行,可能累积未清理的临时文件。
**建议**: 使用 `f.delete()` 的返回值进行日志记录,并考虑递归清理。
---
### F17 [LOW] — RootShell.ensureSession 静默返回 false
**文件**: `root/RootShell.kt:63-67`
**类型**: 被吞没的异常
```kotlin
suspend fun ensureSession(): Boolean = withContext(Dispatchers.IO) {
try {
Shell.getShell().isRoot
} catch (_: Exception) { false }
}
```
如果 `Shell.getShell()` 抛出任何异常(包括 `NullPointerException``RuntimeException`),静默返回 `false`。调用者无法区分"没有 root 权限"和"libsu 未初始化或其他错误"。
**建议**: 记录异常。可以考虑区分不同类型的失败。
---
### F18 [LOW] — AppScanner 多项查询静默失败
**文件**: `AppScanner.kt:41,53,96`
**类型**: 空回退
```kotlin
if (!result.isSuccess) return@withContext emptyList()
```
`scanThirdParty``scanSystem``getApkPaths` 在 shell 命令失败时返回空列表。如果 `pm list packages` 因为 root 权限临时问题失败,用户看到的应用列表为空,但没有任何错误提示。
**建议**: 在 UI 层调用前检查返回的空列表并显示适当消息(已在 `BackupFragment.scanApps()` 中捕获异常,但 shell 层面的失败可能被漏过)。
---
### F19 [LOW] — backupUserData tar 命令可能静默失败
**文件**: `BackupOperation.kt:228`
**类型**: 未检查的返回值
```kotlin
val dirs = dataPaths.filter { RootShell.exec("test -d '${it.shellEscape()}'").isSuccess }.toMutableList()
```
`test -d` 在 nsenter namespace 切换后可能对某些路径返回假阴性。如果所有 `test -d` 都失败,`dirs` 为空列表,代码会转到 `else` 分支line 234-238尝试直接运行 tar而 tar 也会因为没有源路径而静默失败或产生空归档。此时 `archiveCreated` 保持 false进入 line 255 的 fallback 处理——但这个 fallback 返回 true见 F2
**建议**: 如果 `dirs` 为空且 tar 直接执行也未产生输出,应返回明确的失败信号。
---
### F20 [LOW] — WifiManager.backup 结果未在 BackupOperation 中检查
**文件**: `ui/BackupFragment.kt:310`
**类型**: 未检查的返回值
```kotlin
WifiManager.backup(File(result.outputDir))
```
WiFi 配置备份的结果完全被忽略。如果 WiFi 备份失败,用户不会收到任何通知。`WifiManager.backup()` 可以返回 `null`(失败时),但调用者没有使用返回值。
**建议**: 至少记录结果,考虑在最终摘要中显示 WiFi 备份状态。
---
### F21 [LOW] — estimateBackupSize 忽略 du 错误
**文件**: `ui/BackupFragment.kt:440-449`
**类型**: 未检查的返回值
```kotlin
val result = RootShell.exec("du -sb /data/data/$pkgEsc 2>/dev/null | cut -f1")
val size = result.output.trim().toLongOrNull() ?: 0L
```
如果 `du` 命令失败、输出为空或解析失败,该应用的估计大小为 0。最终的空间估算可能严重偏低仅用于判断是否需要流式备份可能导致本应触发流式备份的大数据集使用暂存模式。
**建议**: 考虑使用保守的默认值或根据应用大小粗略估算。
---
### F22 [LOW] — BackupOperation 中 chmod 结果未检查
**文件**: `BackupOperation.kt:173`
**类型**: 未检查的返回值
```kotlin
RootShell.exec("chmod -R 0755 '${backupRoot.absolutePath}'")
```
备份完成后设置目录权限的结果未检查。虽然不影响备份数据的完整性,但如果 `chmod` 失败,后续读取备份的用户可能会遇到权限问题。
**建议**: 至少记录 `chmod` 失败日志。
---
### F23 [LOW] — BackupFragment 中前台服务启动异常被吞没
**文件**: `ui/BackupFragment.kt:193-195`
**类型**: 被吞没的异常
```kotlin
try {
ContextCompat.startForegroundService(requireContext(), serviceIntent)
} catch (_: Exception) {}
```
如果前台服务启动失败(例如缺少权限、应用在后台),异常被完全吞没。备份操作仍然继续,但进程可能被 Android 杀死。
**建议**: 记录异常,考虑通知用户服务启动失败。
---
### F24 [LOW] — ConfigFragment 中 OperationEvent 的 InitFailed/PruneFailed 不显示错误详情
**文件**: `ui/ConfigFragment.kt:157-159`
**类型**: 错误替换
```kotlin
is OperationEvent.InitFailed -> {
Log.d(TAG, "init failed")
Snackbar.make(binding.root, "仓库初始化失败", Snackbar.LENGTH_SHORT).show()
}
```
InitFailed/PruneFailed 事件不携带错误详情,用户只看到"初始化失败"/"清理失败",不知道具体原因。实际错误消息在 ViewModel 的 `resticStatus.message` 中已经设置,但 UI 没有在 snackbar 中使用它。
**建议**: 从 ViewModel 状态读取错误详情并在 snackbar 中显示,或让 OperationEvent 携带错误消息。
---
### F25 [LOW] — RestBridgeRunner 中缓存传输不被清理
**文件**: `RestBridgeRunner.kt:58-63`
**类型**: 资源泄露
```kotlin
if (cachedTransportKey != key) {
cachedTransport?.let { Log.d(TAG, "discarding stale cached transport") }
val t = transportFactory(...)
...
cachedTransport = t
cachedTransportKey = key
}
```
当缓存键变化时,旧的 `cachedTransport`SMB 会话或 WebDAV client被直接丢弃而不关闭。对于 `SmbTransport`,内部的 `CIFSContext` 和 jcifs-ng 连接可能保持打开,直到 GC 触发 finalizer。对于 `WebdavTransport`OkHttp 客户端可能保持连接池和线程。
**建议**: 如果 `RemoteTransport` 接口添加 `close()` 方法,在替换缓存时调用。
---
## 分类统计
| 严重程度 | 数量 | 关键文件 |
|---|---|---|
| HIGH | 4 | SmbTransport, BackupOperation, ResticBackup, WebdavTransport |
| MEDIUM | 12 | ResticWrapper(2), StreamingBackup(2), BackupFragment, WifiManager, RestBridgeRunner, SmbTransport, BackupOperation(2), ConfigViewModel, AppScanner |
| LOW | 9 | RootShell, AppScanner(3), BackupFragment(3), BackupOperation, ConfigFragment, RestBridgeRunner |
**发现总数**: 25
---
## 总结与优先修复建议
### 必须修复 (HIGH)
1. **F1** (`SmbTransport.kt:103-109`) — SMB 上传后大小校验失败应返回错误,而非静默继续
2. **F2** (`BackupOperation.kt:255-257`) — `backupUserData` 全方式失败时返回 `true` 是在告知上层"数据已备份"
3. **F3** (`ResticBackup.kt:55-58` 等) — 进度回调中的空 catch 吞没 `CancellationException`,需添加重新抛出
4. **F4** (`WebdavTransport.kt:153-155`) — `mkdirs` 完全失败返回 Success 是错误替换
### 高优先级 (MEDIUM)
- **F10** — `StreamingBackup.launchDataProducer` 不传播 tar 错误
- **F12** — `SmbTransport` listFiles 返回 null 时可能是权限问题
- **F13/F14** — `backupPermissions`/`backupSsaid` 静默跳过
- **F8** — `StreamingBackup.mkfifo` 结果未检查
### 建议
整个代码库中使用 `catch (_: Exception)` 的模式需要系统性审查:应在所有协程 lambda 中的空 catch 前加 `catch (e: CancellationException) { throw e }`。关键入口点(如 `ResticBackup.kt:58`)已有 `CancellationException` 被吞没的问题。