- 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 超时配置
19 KiB
静默失败审查报告 — android-backup-gui
审查日期: 2026-06-06 审查范围: 37 个 Kotlin 源文件 已排除: memory://root 已知的 7 个待处理项
严重程度分级
- CRITICAL: 数据静默损坏或丢失,用户无法感知
- HIGH: 错误被吞没,导致后续操作基于错误假设继续
- MEDIUM: 错误被吞没但影响范围有限,或仅影响辅助功能
- LOW: 微小错误处理缺失,实际影响小
发现清单
F1 [HIGH] — SMB 上传大小不匹配不报告错误
文件: SmbTransport.kt:103-109
类型: 未检查的返回值 / 静默数据损坏
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
类型: 错误替换/空回退
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
类型: 异步错误丢失
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
类型: 错误替换
} 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的失败仅被记录日志,不通知调用者。
- 实际上 line 63
建议: cp 或 chmod/chown 失败时应返回 false。当前 RestoreFragment 中 WifiManager.restore(dir) 的返回值没有被使用,但接口应该诚实。
F6 [MEDIUM] — ResticWrapper.getLatestSnapshotAppDetails 静默返回 null
文件: ResticWrapper.kt:270-275, ResticWrapper.kt:288
类型: 空回退
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
类型: 被吞没的异常
} 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
类型: 未检查的返回值
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
类型: 未检查的返回值
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
类型: 被吞没的异常
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
类型: 空回退
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
类型: 空回退
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
类型: 被吞没的异常
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
类型: 被吞没的异常
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
类型: 错误替换
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
类型: 资源泄露
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
类型: 被吞没的异常
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
类型: 空回退
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
类型: 未检查的返回值
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
类型: 未检查的返回值
WifiManager.backup(File(result.outputDir))
WiFi 配置备份的结果完全被忽略。如果 WiFi 备份失败,用户不会收到任何通知。WifiManager.backup() 可以返回 null(失败时),但调用者没有使用返回值。
建议: 至少记录结果,考虑在最终摘要中显示 WiFi 备份状态。
F21 [LOW] — estimateBackupSize 忽略 du 错误
文件: ui/BackupFragment.kt:440-449
类型: 未检查的返回值
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
类型: 未检查的返回值
RootShell.exec("chmod -R 0755 '${backupRoot.absolutePath}'")
备份完成后设置目录权限的结果未检查。虽然不影响备份数据的完整性,但如果 chmod 失败,后续读取备份的用户可能会遇到权限问题。
建议: 至少记录 chmod 失败日志。
F23 [LOW] — BackupFragment 中前台服务启动异常被吞没
文件: ui/BackupFragment.kt:193-195
类型: 被吞没的异常
try {
ContextCompat.startForegroundService(requireContext(), serviceIntent)
} catch (_: Exception) {}
如果前台服务启动失败(例如缺少权限、应用在后台),异常被完全吞没。备份操作仍然继续,但进程可能被 Android 杀死。
建议: 记录异常,考虑通知用户服务启动失败。
F24 [LOW] — ConfigFragment 中 OperationEvent 的 InitFailed/PruneFailed 不显示错误详情
文件: ui/ConfigFragment.kt:157-159
类型: 错误替换
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
类型: 资源泄露
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)
- F1 (
SmbTransport.kt:103-109) — SMB 上传后大小校验失败应返回错误,而非静默继续 - F2 (
BackupOperation.kt:255-257) —backupUserData全方式失败时返回true是在告知上层"数据已备份" - F3 (
ResticBackup.kt:55-58等) — 进度回调中的空 catch 吞没CancellationException,需添加重新抛出 - F4 (
WebdavTransport.kt:153-155) —mkdirs完全失败返回 Success 是错误替换
高优先级 (MEDIUM)
- F10 —
StreamingBackup.launchDataProducer不传播 tar 错误 - F12 —
SmbTransportlistFiles 返回 null 时可能是权限问题 - F13/F14 —
backupPermissions/backupSsaid静默跳过 - F8 —
StreamingBackup.mkfifo结果未检查
建议
整个代码库中使用 catch (_: Exception) 的模式需要系统性审查:应在所有协程 lambda 中的空 catch 前加 catch (e: CancellationException) { throw e }。关键入口点(如 ResticBackup.kt:58)已有 CancellationException 被吞没的问题。