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:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -24,6 +24,8 @@ jobs:
|
||||
|
||||
- name: Lint
|
||||
run: ./gradlew lint
|
||||
- name: Test
|
||||
run: ./gradlew test
|
||||
|
||||
- name: Build release APK
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
303
accessibility-review-report.md
Normal file
303
accessibility-review-report.md
Normal 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}"。
|
||||
|
||||
---
|
||||
|
||||
### 发现 3:CheckBox 缺少对应描述(高)
|
||||
|
||||
**文件**:`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` 替代。
|
||||
|
||||
---
|
||||
|
||||
### 发现 15:BottomNavigation 缺少 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**。
|
||||
@@ -38,9 +38,9 @@ android {
|
||||
signingConfigs {
|
||||
release {
|
||||
storeFile rootProject.file("app/release.keystore")
|
||||
storePassword System.getenv("KEYSTORE_PASSWORD") ?: "android"
|
||||
storePassword System.getenv("KEYSTORE_PASSWORD")
|
||||
keyAlias "release"
|
||||
keyPassword System.getenv("KEY_PASSWORD") ?: "android"
|
||||
keyPassword System.getenv("KEY_PASSWORD")
|
||||
v1SigningEnabled true
|
||||
v2SigningEnabled true
|
||||
}
|
||||
@@ -48,10 +48,14 @@ android {
|
||||
buildTypes {
|
||||
release {
|
||||
if (rootProject.file("app/release.keystore").exists()) {
|
||||
def ksPass = System.getenv("KEYSTORE_PASSWORD")
|
||||
def kPass = System.getenv("KEY_PASSWORD")
|
||||
if (ksPass != null && kPass != null) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
testOptions {
|
||||
unitTests.all {
|
||||
|
||||
|
||||
2
app/proguard-rules.pro
vendored
2
app/proguard-rules.pro
vendored
@@ -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.smb.NtlmUtil { *; }
|
||||
-keep class jcifs.ntlmssp.Type3Message { *; }
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:extractNativeLibs="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
|
||||
@@ -7,15 +7,6 @@ import com.example.androidbackupgui.root.shellEscape
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
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
|
||||
data class AppInfo(
|
||||
@@ -30,7 +21,6 @@ data class AppInfo(
|
||||
val userId: UserId = UserId(0),
|
||||
val hasKeystore: Boolean = false,
|
||||
val iconPath: String? = null,
|
||||
val dataSizes: DataSizes = DataSizes(),
|
||||
)
|
||||
|
||||
object AppScanner {
|
||||
@@ -101,16 +91,6 @@ object AppScanner {
|
||||
.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. */
|
||||
suspend fun hasObbData(packageName: String): Boolean = withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -183,6 +183,8 @@ data class BackupConfig(
|
||||
appendLine("restic_backend_share=\"${config.resticBackendShare}\"")
|
||||
appendLine("restic_backend_domain=\"${config.resticBackendDomain}\"")
|
||||
})
|
||||
file.setReadable(true, true) // owner only
|
||||
file.setWritable(true, true) // owner only
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +254,7 @@ object BackupOperation {
|
||||
|
||||
if (!archiveCreated) {
|
||||
Log.w(TAG, "backupUserData: $packageName all methods failed — no data dirs (or inaccessible)")
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify compression integrity
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,14 @@ object MissingAlgoProvider {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,14 +2,15 @@ package com.example.androidbackupgui.backup
|
||||
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Manages [ResticRestBridge] lifecycle: create, start, stop, clean cache.
|
||||
*
|
||||
* Usage:
|
||||
* ```kotlin
|
||||
* bridgeRunner.withBridge(backend, url, user, pass, share, domain, repoPath) { bridgeUrl ->
|
||||
* bridgeRunner.withBridge(backend, url, user, pass, share, domain, repoPath) { bridgeUrl, authToken ->
|
||||
* // RESTIC_REPOSITORY = bridgeUrl
|
||||
* // RESTIC_REST_USERNAME/PASSWORD = authToken (set via buildBridgeEnv)
|
||||
* restic commands go here
|
||||
* }
|
||||
* // bridge stopped + cache cleaned automatically
|
||||
@@ -47,25 +48,26 @@ class RestBridgeRunner {
|
||||
share: String,
|
||||
domain: String
|
||||
) -> RemoteTransport? = ::createTransport,
|
||||
block: suspend (bridgeUrl: String) -> T
|
||||
block: suspend (bridgeUrl: String, authToken: String) -> T
|
||||
): T {
|
||||
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"
|
||||
if (cachedTransportKey != key) {
|
||||
cachedTransport?.let { Log.d(TAG, "discarding stale cached transport") }
|
||||
val t = transportFactory(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain)
|
||||
?: return block(repoPath)
|
||||
?: return block(repoPath, "")
|
||||
cachedTransport = t
|
||||
cachedTransportKey = key
|
||||
}
|
||||
val transport = cachedTransport!!
|
||||
|
||||
val remoteBase = buildRemoteBase(backend, backendUrl, backendShare, repoPath)
|
||||
val bridge = ResticRestBridge(transport, remoteBase, repoPath, cacheDir)
|
||||
val bridge = ResticRestBridge(transport, remoteBase, repoPath, cacheDir, authToken)
|
||||
|
||||
try {
|
||||
bridge.start(0)
|
||||
@@ -74,14 +76,13 @@ class RestBridgeRunner {
|
||||
throw IllegalStateException("REST bridge failed to bind a port")
|
||||
}
|
||||
val bridgeUrl = "rest:http://127.0.0.1:$port/$repoPath"
|
||||
Log.i(TAG, "REST bridge started on port $port for $remoteBase")
|
||||
return block(bridgeUrl)
|
||||
Log.i(TAG, "REST bridge started on port $port for $remoteBase (auth=${authToken.take(8)}…)")
|
||||
return block(bridgeUrl, authToken)
|
||||
} finally {
|
||||
try {
|
||||
bridge.stop()
|
||||
} catch (_: Exception) {}
|
||||
Log.d(TAG, "REST bridge stopped")
|
||||
// Clean up any leftover blob temp files
|
||||
val blobs = cacheDir.listFiles { f -> f.name.startsWith("restic_blob_") }
|
||||
if (blobs != null) {
|
||||
for (f in blobs) f.delete()
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
import com.example.androidbackupgui.backup.AppResult
|
||||
@@ -55,25 +56,25 @@ class ResticBackup(
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
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))
|
||||
parseBackupSummary(result.stdout)
|
||||
} 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")
|
||||
for (path in paths) args.add(path)
|
||||
for (tag in tags) { args.add("--tag"); args.add(tag) }
|
||||
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 ->
|
||||
if (!coroutineContext.isActive) return@runResticStreaming
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
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))
|
||||
@@ -117,20 +118,20 @@ class ResticBackup(
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
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))
|
||||
parseBackupSummary(result.stdout)
|
||||
} else {
|
||||
bridgeRunner.withBridge(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, repoPath, File(cacheDir)) { bridgeUrl ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
bridgeRunner.withBridge(backend, backendUrl, backendUser, backendPass, backendShare, backendDomain, repoPath, File(cacheDir)) { bridgeUrl, authToken ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
|
||||
val result = runner.runResticWithStdin(env, args, stdinFile) { line ->
|
||||
if (!coroutineContext.isActive) return@runResticWithStdin
|
||||
try {
|
||||
val progress = resticJson.decodeFromString<ResticWrapper.ResticProgress>(line)
|
||||
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))
|
||||
|
||||
@@ -111,14 +111,15 @@ class ResticCommandRunner {
|
||||
val reader = process.inputStream.bufferedReader()
|
||||
|
||||
try {
|
||||
var line: String
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
var line = reader.readLine()
|
||||
while (line != null) {
|
||||
if (!coroutineContext.isActive) {
|
||||
process.destroy()
|
||||
break
|
||||
}
|
||||
stdoutText.appendLine(line)
|
||||
onLine(line)
|
||||
line = reader.readLine()
|
||||
}
|
||||
} finally {
|
||||
try { reader.close() } catch (_: Exception) {}
|
||||
@@ -196,14 +197,15 @@ class ResticCommandRunner {
|
||||
val reader = process.inputStream.bufferedReader()
|
||||
|
||||
try {
|
||||
var line: String
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
var line = reader.readLine()
|
||||
while (line != null) {
|
||||
if (!coroutineContext.isActive) {
|
||||
process.destroy()
|
||||
break
|
||||
}
|
||||
stdoutText.appendLine(line)
|
||||
onLine(line)
|
||||
line = reader.readLine()
|
||||
}
|
||||
} finally {
|
||||
try { reader.close() } catch (_: Exception) {}
|
||||
|
||||
@@ -10,11 +10,16 @@ class ResticEnvResolver {
|
||||
fun buildBridgeEnv(
|
||||
password: String,
|
||||
bridgeUrl: String,
|
||||
cacheDir: String
|
||||
cacheDir: String,
|
||||
authToken: String = ""
|
||||
): Map<String, String> {
|
||||
val env = HashMap(System.getenv() ?: emptyMap())
|
||||
env["RESTIC_REPOSITORY"] = bridgeUrl
|
||||
env["RESTIC_PASSWORD"] = password
|
||||
if (authToken.isNotEmpty()) {
|
||||
env["RESTIC_REST_USERNAME"] = authToken
|
||||
env["RESTIC_REST_PASSWORD"] = authToken
|
||||
}
|
||||
if (cacheDir.isNotEmpty()) {
|
||||
env["HOME"] = cacheDir
|
||||
env["XDG_CACHE_HOME"] = cacheDir
|
||||
|
||||
@@ -52,8 +52,8 @@ class ResticMaintenance(
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
) { bridgeUrl, authToken ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
|
||||
val result = runner.runRestic(env, "prune")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic prune 失败", result.exitCode, result.stderr))
|
||||
@@ -82,8 +82,8 @@ class ResticMaintenance(
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
) { bridgeUrl, authToken ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
|
||||
val result = runner.runRestic(env, "check")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic check 失败", result.exitCode, result.stderr))
|
||||
@@ -112,8 +112,8 @@ class ResticMaintenance(
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
) { bridgeUrl, authToken ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
|
||||
val result = runner.runRestic(env, "stats")
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic("restic stats 失败", result.exitCode, result.stderr))
|
||||
|
||||
@@ -49,8 +49,8 @@ class ResticRepoInit(
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
) { bridgeUrl, authToken ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
|
||||
runInit(env)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import kotlinx.coroutines.runBlocking
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import android.util.Base64
|
||||
/**
|
||||
* NanoHTTPD-based REST bridge implementing the restic REST backend API.
|
||||
*
|
||||
@@ -23,8 +24,9 @@ class ResticRestBridge(
|
||||
private val transport: RemoteTransport,
|
||||
private val remoteBase: String,
|
||||
private val repoPath: String,
|
||||
private val cacheDir: File
|
||||
) : NanoHTTPD(0) {
|
||||
private val cacheDir: File,
|
||||
private val authToken: String = ""
|
||||
) : NanoHTTPD("127.0.0.1", 0) {
|
||||
|
||||
private val TAG = "ResticRestBridge"
|
||||
|
||||
@@ -39,6 +41,21 @@ class ResticRestBridge(
|
||||
val headers = session.headers
|
||||
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")
|
||||
|
||||
return try {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.example.androidbackupgui.backup
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.io.File
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import com.example.androidbackupgui.backup.AppError
|
||||
@@ -75,7 +76,7 @@ class ResticRestore(
|
||||
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)
|
||||
@@ -84,13 +85,13 @@ class ResticRestore(
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare, backendDomain,
|
||||
repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
) { bridgeUrl, authToken ->
|
||||
File(targetPath).mkdirs()
|
||||
|
||||
val args = mutableListOf("restore", snapshotId, "--target", targetPath, "--json")
|
||||
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 ->
|
||||
if (!coroutineContext.isActive) return@runResticStreaming
|
||||
try {
|
||||
@@ -104,7 +105,7 @@ class ResticRestore(
|
||||
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)
|
||||
@@ -142,8 +143,8 @@ class ResticRestore(
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare, backendDomain,
|
||||
repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir)
|
||||
) { bridgeUrl, authToken ->
|
||||
val env = envResolver.buildBridgeEnv(password, bridgeUrl, cacheDir, authToken)
|
||||
val result = runner.runRestic(env, "dump", snapshotId, filePath)
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
else err(AppError.Restic(result.stderr.ifEmpty { "restic dump 失败" }, result.exitCode, result.stderr))
|
||||
|
||||
@@ -65,11 +65,11 @@ class ResticSnapshotOps(
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
) { bridgeUrl, authToken ->
|
||||
val args = mutableListOf("snapshots", "--json")
|
||||
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)
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
@@ -121,7 +121,7 @@ class ResticSnapshotOps(
|
||||
bridgeRunner.withBridge(
|
||||
backend, backendUrl, backendUser, backendPass, backendShare,
|
||||
backendDomain, repoPath, File(cacheDir)
|
||||
) { bridgeUrl ->
|
||||
) { bridgeUrl, authToken ->
|
||||
val args = mutableListOf(
|
||||
"forget",
|
||||
"--keep-daily", keepDaily.toString(),
|
||||
@@ -130,7 +130,7 @@ class ResticSnapshotOps(
|
||||
)
|
||||
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)
|
||||
|
||||
if (result.exitCode == 0) AppResult.Success(result.stdout)
|
||||
|
||||
@@ -7,7 +7,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
@@ -82,7 +82,7 @@ object RestoreOperation {
|
||||
val failAtomic = AtomicInteger(0)
|
||||
|
||||
val semaphore = Semaphore(2)
|
||||
coroutineScope {
|
||||
supervisorScope {
|
||||
packages.forEachIndexed { index, pkg ->
|
||||
launch {
|
||||
if (!coroutineContext.isActive) return@launch
|
||||
@@ -298,11 +298,7 @@ object RestoreOperation {
|
||||
if (!result.isSuccess) return false
|
||||
return !result.output.lines().any { line ->
|
||||
val path = line.substringBefore(" -> ")
|
||||
val hasTraversal = 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
|
||||
path.trimStart('/').split("/").any { segment -> segment == ".." }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -70,11 +70,11 @@ class SmbTransport(
|
||||
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> =
|
||||
retryWithBackoff(TAG, "SMB 上传") {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val localFile = File(localPath)
|
||||
val remote = smbFile(remotePath)
|
||||
// Ensure parent directories exist (parent can be null at share root)
|
||||
val parentPath = remote.parent
|
||||
if (parentPath != null) {
|
||||
val parent = SmbFile(parentPath, context)
|
||||
@@ -96,16 +96,12 @@ class SmbTransport(
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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")
|
||||
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)
|
||||
@@ -116,8 +112,10 @@ class SmbTransport(
|
||||
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> =
|
||||
retryWithBackoff(TAG, "SMB 下载") {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val localFile = File(localPath)
|
||||
@@ -149,6 +147,7 @@ class SmbTransport(
|
||||
err(AppError.Remote("SMB 下载失败", "download", cause = e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listFiles(remoteDir: String): AppResult<List<RemoteTransport.RemoteFileInfo>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -7,20 +7,30 @@ import com.thegrizzlylabs.sardineandroid.impl.SardineException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import android.util.Base64
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
class WebdavTransport(
|
||||
private val baseUrl: String,
|
||||
private val username: 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 {
|
||||
|
||||
companion object { private const val TAG = "WebdavTransport" }
|
||||
|
||||
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()) {
|
||||
setCredentials(username, password)
|
||||
}
|
||||
@@ -33,6 +43,7 @@ class WebdavTransport(
|
||||
}
|
||||
|
||||
override suspend fun upload(localPath: String, remotePath: String, onProgress: suspend (RemoteTransport.TransferProgress) -> Unit, onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit): AppResult<Unit> =
|
||||
retryWithBackoff(TAG, "WebDAV 上传") {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = buildUrl(remotePath)
|
||||
@@ -43,7 +54,6 @@ class WebdavTransport(
|
||||
}
|
||||
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()
|
||||
@@ -68,16 +78,26 @@ class WebdavTransport(
|
||||
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> =
|
||||
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 ->
|
||||
localFile.outputStream().use { output ->
|
||||
partFile.outputStream().use { output ->
|
||||
onProgress(RemoteTransport.TransferProgress("transferring", 0, 1, remotePath))
|
||||
val buffer = ByteArray(bufferSize)
|
||||
var totalRead = 0L
|
||||
@@ -90,6 +110,11 @@ class WebdavTransport(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -100,6 +125,56 @@ class WebdavTransport(
|
||||
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>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -150,8 +225,8 @@ class WebdavTransport(
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "mkdirs failed: $remotePath — ${e.message}")
|
||||
AppResult.Success(Unit) // best-effort; upload will fail if dir can't be created
|
||||
Log.e(TAG, "mkdirs failed: $remotePath — ${e.message}")
|
||||
err(AppError.Remote("WebDAV mkdirs 失败", "mkdirs", cause = e))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.util.Log
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
@@ -63,7 +64,9 @@ object RootShell {
|
||||
suspend fun ensureSession(): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
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 =
|
||||
@@ -81,6 +84,8 @@ object RootShell {
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
Log.w(TAG, "exec timeout (${timeoutMs}ms): $command")
|
||||
ShellResult("", "Command timed out after ${timeoutMs}ms", -1)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "exec failed: $command", e)
|
||||
ShellResult("", e.message ?: "Unknown error", -1)
|
||||
|
||||
@@ -24,14 +24,7 @@ import com.example.androidbackupgui.backup.AppResult
|
||||
import com.example.androidbackupgui.databinding.FragmentBackupBinding
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
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.util.Locale
|
||||
|
||||
@@ -399,11 +392,11 @@ class BackupFragment : Fragment() {
|
||||
|
||||
|
||||
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) {
|
||||
withContext(Dispatchers.Main) { binding.statusText.text = text }
|
||||
withContext(Dispatchers.Main) { _binding?.statusText?.text = text }
|
||||
}
|
||||
|
||||
private fun updateOutputPathDisplay() {
|
||||
@@ -431,117 +424,9 @@ class BackupFragment : Fragment() {
|
||||
.show()
|
||||
}
|
||||
|
||||
// ── Space detection & streaming backup ────────────
|
||||
|
||||
/**
|
||||
* 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
|
||||
override fun onDestroyView() {
|
||||
_binding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,9 @@ class PackageListAdapter(
|
||||
setTextColor(
|
||||
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(tv)
|
||||
|
||||
@@ -392,15 +392,15 @@ class RestoreFragment : Fragment() {
|
||||
|
||||
|
||||
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) {
|
||||
binding.statusText.text = text
|
||||
_binding?.statusText?.text = text
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,4 +13,5 @@
|
||||
<string name="status_done">完成 (退出码: %d)</string>
|
||||
<string name="status_error">执行失败: %s</string>
|
||||
<string name="status_cancelled">已取消</string>
|
||||
<string name="exclude_data_toggle">切换数据排除</string>
|
||||
</resources>
|
||||
|
||||
88
docs/plans/roadmap.md
Normal file
88
docs/plans/roadmap.md
Normal 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 可并行执行**。
|
||||
247
docs/reviews/full-project-review-report.md
Normal file
247
docs/reviews/full-project-review-report.md
Normal 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 | 同 H5,CancellationException 被空 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 |
|
||||
|
||||
---
|
||||
|
||||
## 关键模式分析
|
||||
|
||||
### 模式 1:CancellationException 被吞没(全局性)
|
||||
|
||||
**影响面**: 项目几乎所有协程操作通过 `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:远程操作失败返回 Success(3 处)
|
||||
|
||||
**涉及文件**: `SmbTransport.kt:103-109`, `WebdavTransport.kt:153-155`, `BackupOperation.kt:255-257`
|
||||
|
||||
**影响**: 上层调用者无法区分"操作成功"和"操作失败但返回了 Success"。
|
||||
|
||||
### 模式 3:PackageName/UserId 值类未被方法签名采用
|
||||
|
||||
**涉及文件**: 全局,影响 `AppScanner`, `BackupOperation`, `RestoreOperation`, `StreamingBackup`, `PackageListAdapter`, `BackupProgress`, `RestoreProgress`
|
||||
|
||||
**影响**: 值类的类型安全收益完全丧失,编译器无法区分 `PackageName` 和任意 `String`。
|
||||
|
||||
### 模式 4:5 个子模块重复 local/remote 分支模式
|
||||
|
||||
**涉及文件**: `ResticBackup.kt`, `ResticRestore.kt`, `ResticRepoInit.kt`, `ResticMaintenance.kt`, `ResticSnapshotOps.kt`
|
||||
|
||||
**影响**: 每个方法都复制 `if (backend == "local")` 分支,增加维护成本和出错可能。
|
||||
|
||||
### 模式 5:BackupFragment 和 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 测试(持续集成未运行) |
|
||||
266
docs/reviews/refactor-cleaner-review.md
Normal file
266
docs/reviews/refactor-cleaner-review.md
Normal 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-546(3 个方法)| 删除或接入 |
|
||||
| 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% 的源代码量
|
||||
561
docs/superpowers/plans/security-review-report.md
Normal file
561
docs/superpowers/plans/security-review-report.md
Normal 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) // 明文写入备份输出
|
||||
}
|
||||
```
|
||||
|
||||
SSAID(Settings 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")
|
||||
```
|
||||
|
||||
SSAID(Settings 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
333
security-review-report.md
Normal 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
484
silent-failure-review.md
Normal 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` 被吞没的问题。
|
||||
Reference in New Issue
Block a user