fix(security): 阶段1-3 核心安全修复
阶段1:阻断 Root 注入和路径穿越 - 使用 PackageName.safe() 过滤备份目录中的包名 - canonicalFile 校验防止路径穿越 - APK 文件名拒绝 / \ . .. 空白 - pm install 路径加引号 - RestoreArchiveSafety 拒绝相对路径如 etc/passwd - 压缩方式 allowlist (zstd/tar) - chmod/tar/cp 统一 quoting 阶段2:修复备份正确性 - 删除错误增量跳过逻辑 (APK version 不应跳过 app data) - APK copy 失败计入失败统计 - gzip/tar 参数顺序修正 - 权限收紧 chmod go-rwx - 归档安全检查增强 阶段3:恢复流程安全 UX - 默认不全选应用 - 全选应用/取消全选按钮 - 恢复确认弹窗 - Wi-Fi 恢复 opt-in - partial 终态保持 error 色
This commit is contained in:
@@ -49,7 +49,8 @@ object BackupAppDataOps {
|
||||
val bundledTar = BinaryResolver.tarPath(context)
|
||||
val tarCmd = bundledTar ?: "tar"
|
||||
|
||||
var isZstd = compression == "zstd"
|
||||
val compressionMethod = BackupConfig.normalizeCompressionMethod(compression)
|
||||
var isZstd = compressionMethod == "zstd"
|
||||
val bundledZstd = if (isZstd) BinaryResolver.zstdPath(context) else null
|
||||
val zstdCmd = bundledZstd ?: "zstd"
|
||||
if (isZstd && bundledZstd == null) {
|
||||
@@ -157,7 +158,7 @@ object BackupAppDataOps {
|
||||
) { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'",
|
||||
)
|
||||
} else {
|
||||
RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null")
|
||||
RootShell.exec("$tarCmd -czf '$outputFile.gz' $excludeArgs ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,8 +176,9 @@ object BackupAppDataOps {
|
||||
val escapedPkg = packageName.shellEscape()
|
||||
// Exclude cache and backup temp files from OBB archive
|
||||
val obbExcludes = "--exclude='cache' --exclude='Backup_*'"
|
||||
val compressionMethod = BackupConfig.normalizeCompressionMethod(compression)
|
||||
val result =
|
||||
when (compression) {
|
||||
when (compressionMethod) {
|
||||
"zstd" -> {
|
||||
RootShell.exec(
|
||||
"set -o pipefail; tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'",
|
||||
@@ -184,24 +186,24 @@ object BackupAppDataOps {
|
||||
}
|
||||
|
||||
else -> {
|
||||
RootShell.exec("tar -czf $obbExcludes '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
|
||||
RootShell.exec("tar -czf '$escapedAppDir/${escapedPkg}_obb.tar.gz' $obbExcludes '$obbDir' 2>/dev/null")
|
||||
}
|
||||
}
|
||||
if (!result.isSuccess) {
|
||||
Log.e(TAG, "Failed to backup OBB for $packageName: exit=${result.exitCode} err=${result.error}")
|
||||
return null
|
||||
}
|
||||
val obbArchiveExt = if (compression == "zstd") ".zst" else ".gz"
|
||||
val obbArchiveExt = if (compressionMethod == "zstd") ".zst" else ".gz"
|
||||
val obbFile = File(appDir, "${packageName}_obb.tar$obbArchiveExt")
|
||||
val obbArchivePath = obbFile.absolutePath.shellEscape()
|
||||
val verifyCmd = if (compression == "zstd") "zstd -t '$obbArchivePath' 2>/dev/null" else "gzip -t '$obbArchivePath' 2>/dev/null"
|
||||
val verifyCmd = if (compressionMethod == "zstd") "zstd -t '$obbArchivePath' 2>/dev/null" else "gzip -t '$obbArchivePath' 2>/dev/null"
|
||||
val verificationOk = RootShell.exec(verifyCmd).isSuccess
|
||||
if (!verificationOk) {
|
||||
Log.e(TAG, "OBB archive integrity check FAILED for $packageName")
|
||||
}
|
||||
// Validate OBB tar structure
|
||||
val tarListCmd =
|
||||
if (compression == "zstd") {
|
||||
if (compressionMethod == "zstd") {
|
||||
"zstd -d -c '$obbArchivePath' 2>/dev/null | tar -tf - > /dev/null 2>&1"
|
||||
} else {
|
||||
"tar -tf '$obbArchivePath' > /dev/null 2>&1"
|
||||
@@ -233,18 +235,19 @@ object BackupAppDataOps {
|
||||
return 0L // Not an error, just no data
|
||||
}
|
||||
|
||||
val archiveExt = if (compression == "zstd") ".zst" else ".gz"
|
||||
val compressionMethod = BackupConfig.normalizeCompressionMethod(compression)
|
||||
val archiveExt = if (compressionMethod == "zstd") ".zst" else ".gz"
|
||||
val archiveFile = File(appDir, "${packageName}_external_data.tar$archiveExt")
|
||||
val archivePath = archiveFile.absolutePath.shellEscape()
|
||||
val dataExcludes = "--exclude='cache' --exclude='Backup_*' --exclude='.ota'"
|
||||
|
||||
val result =
|
||||
if (compression == "zstd") {
|
||||
if (compressionMethod == "zstd") {
|
||||
RootShell.exec(
|
||||
"set -o pipefail; tar -cf - $dataExcludes '$externalDataDir' 2>/dev/null | zstd -T0 -o '$archivePath'",
|
||||
)
|
||||
} else {
|
||||
RootShell.exec("tar -czf $dataExcludes '$archivePath' '$externalDataDir' 2>/dev/null")
|
||||
RootShell.exec("tar -czf '$archivePath' $dataExcludes '$externalDataDir' 2>/dev/null")
|
||||
}
|
||||
|
||||
if (!result.isSuccess) {
|
||||
@@ -253,7 +256,7 @@ object BackupAppDataOps {
|
||||
}
|
||||
|
||||
// Verify compression integrity
|
||||
val verifyCmd = if (compression == "zstd") "zstd -t '$archivePath' 2>/dev/null" else "gzip -t '$archivePath' 2>/dev/null"
|
||||
val verifyCmd = if (compressionMethod == "zstd") "zstd -t '$archivePath' 2>/dev/null" else "gzip -t '$archivePath' 2>/dev/null"
|
||||
val verificationOk = RootShell.exec(verifyCmd).isSuccess
|
||||
if (!verificationOk) {
|
||||
Log.e(TAG, "backupExternalData: $packageName integrity check FAILED")
|
||||
@@ -262,7 +265,7 @@ object BackupAppDataOps {
|
||||
|
||||
// Validate tar structure
|
||||
val tarListCmd =
|
||||
if (compression == "zstd") {
|
||||
if (compressionMethod == "zstd") {
|
||||
"zstd -d -c '$archivePath' 2>/dev/null | tar -tf - > /dev/null 2>&1"
|
||||
} else {
|
||||
"tar -tf '$archivePath' > /dev/null 2>&1"
|
||||
|
||||
@@ -88,8 +88,10 @@ object BackupOperation {
|
||||
return@withContext BackupResult(0, 0, 0, absOut, 0)
|
||||
}
|
||||
|
||||
val compressionMethod = BackupConfig.normalizeCompressionMethod(config.compressionMethod)
|
||||
|
||||
// Create backup structure
|
||||
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
|
||||
val backupRoot = File(outputDir, "Backup_${compressionMethod}_$userId")
|
||||
if (!mkdirsForBackup(backupRoot)) {
|
||||
LogUtil.e(TAG, "backupApps: cannot create output dir ${backupRoot.absolutePath}")
|
||||
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
|
||||
@@ -170,7 +172,7 @@ object BackupOperation {
|
||||
app = app,
|
||||
backupRoot = backupRoot,
|
||||
oldMetaJson = oldMetaJson,
|
||||
config = config,
|
||||
config = config.copy(compressionMethod = compressionMethod),
|
||||
userId = userId,
|
||||
noDataBackup = noDataBackup,
|
||||
appInfoCache = appInfoCache,
|
||||
@@ -196,7 +198,7 @@ object BackupOperation {
|
||||
}
|
||||
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
RootShell.exec("chmod -R 0755 '${backupRoot.absolutePath}'")
|
||||
RootShell.exec("chmod -R go-rwx '${backupRoot.absolutePath.shellEscape()}'")
|
||||
val successCount = successAtomic.get()
|
||||
val failCount = failAtomic.get()
|
||||
val skippedCount = skippedAtomic.get()
|
||||
@@ -213,7 +215,7 @@ object BackupOperation {
|
||||
val integrityReport = BackupIntegrityChecker.checkBackupIntegrity(
|
||||
backupDir = backupRoot,
|
||||
packages = apps.map { it.packageName.value },
|
||||
compression = config.compressionMethod,
|
||||
compression = compressionMethod,
|
||||
)
|
||||
LogUtil.i(TAG, "backupApps: integrity check completed — ${integrityReport.passedPackages}/${integrityReport.checkedPackages} passed")
|
||||
|
||||
@@ -221,7 +223,7 @@ object BackupOperation {
|
||||
BackupIntegrityChecker.generateChecksumFile(
|
||||
backupDir = backupRoot,
|
||||
packages = apps.map { it.packageName.value },
|
||||
compression = config.compressionMethod,
|
||||
compression = compressionMethod,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -281,16 +283,24 @@ object BackupOperation {
|
||||
progressTracker.updateStage("apk", "正在备份 APK…")
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "apk", "正在备份 APK…"))
|
||||
val paths = appInfoCache.getApkPaths(pkgName)
|
||||
if (paths.isNotEmpty()) {
|
||||
if (paths.isEmpty()) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "appdone", "APK 路径为空"))
|
||||
return
|
||||
}
|
||||
val cpOk =
|
||||
paths.withIndex().all { (i, apkPath) ->
|
||||
val destName = if (paths.size > 1) "${pkgName}_split_$i.apk" else "$pkgName.apk"
|
||||
val dest = File(appDir, destName)
|
||||
RootShell
|
||||
.exec(
|
||||
"cp '${apkPath.shellEscape()}' '${appDir.absolutePath.shellEscape()}/${destName.shellEscape()}'",
|
||||
).isSuccess
|
||||
"cp '${apkPath.shellEscape()}' '${dest.absolutePath.shellEscape()}'",
|
||||
).isSuccess && BackupFileIO.backupPathExists(dest) && BackupFileIO.backupFileSize(dest) > 0L
|
||||
}
|
||||
if (!cpOk) LogUtil.w(TAG, "backupApps: APK cp failed for $pkgName, continuing")
|
||||
if (!cpOk) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "appdone", "APK 备份失败"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
skippedAtomic.incrementAndGet()
|
||||
@@ -302,28 +312,8 @@ object BackupOperation {
|
||||
val hasKeystore = appInfoCache.hasKeystore(pkgName) ?: false
|
||||
if (hasKeystore) emit(BackupProgress(index + 1, totalCount, pkgName, "data", "⚠ 包含密钥库条目"))
|
||||
|
||||
// ── Size-based data incremental skip ──
|
||||
var skipData = false
|
||||
if (!apkChanged) {
|
||||
val oldUserSize =
|
||||
try {
|
||||
oldEntry?.optJSONObject("user")?.optString("Size", null)?.toLongOrNull()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val oldObbSize =
|
||||
try {
|
||||
oldEntry?.optJSONObject("obb")?.optString("Size", null)?.toLongOrNull()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (oldUserSize != null || oldObbSize != null) {
|
||||
skipData = true
|
||||
Log.d(TAG, "backupApps: $pkgName data sizes known from backup, skipping data backup (incremental)")
|
||||
progressTracker.skipApp(pkgName, "数据大小已知,跳过数据备份")
|
||||
}
|
||||
}
|
||||
|
||||
// App data changes independently of APK version; do not skip mutable
|
||||
// data based only on stale metadata from a previous backup.
|
||||
var userSize: Long? = null
|
||||
var userDeSize: Long? = null
|
||||
var dataSize: Long? = null
|
||||
@@ -331,14 +321,14 @@ object BackupOperation {
|
||||
|
||||
// Force-stop before data backup for consistency.
|
||||
// Exclude the app itself (avoid suicide) and well-known persistent apps.
|
||||
if (config.backupMode == 1 && !skipData) {
|
||||
if (config.backupMode == 1) {
|
||||
if (pkgName !in listOf("bin.mt.plus", "com.termux", "bin.mt.plus.canary", context.packageName)) {
|
||||
RootShell.exec("am force-stop --user ${userId.shellEscape()} '${pkgName.shellEscape()}' 2>/dev/null")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Backup user data
|
||||
if (config.backupMode == 1 && config.backupUserData == 1 && !skipData) {
|
||||
if (config.backupMode == 1 && config.backupUserData == 1) {
|
||||
if (pkgName in noDataBackup) {
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "跳过数据备份(已排除)"))
|
||||
} else {
|
||||
@@ -354,12 +344,10 @@ object BackupOperation {
|
||||
return
|
||||
}
|
||||
}
|
||||
} else if (skipData) {
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "数据无变化,跳过"))
|
||||
}
|
||||
|
||||
// 3. Backup OBB
|
||||
if (config.backupMode == 1 && config.backupObbData == 1 && !skipData) {
|
||||
if (config.backupMode == 1 && config.backupObbData == 1) {
|
||||
val hasObb = AppScanner.hasObbData(pkgName)
|
||||
if (hasObb) {
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "obb", "正在备份 OBB…"))
|
||||
@@ -373,10 +361,15 @@ object BackupOperation {
|
||||
}
|
||||
|
||||
// 3.5 Backup external data
|
||||
if (config.backupMode == 1 && config.backupUserData == 1 && !skipData) {
|
||||
if (config.backupMode == 1 && config.backupUserData == 1) {
|
||||
if (pkgName !in noDataBackup) {
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "正在备份外部数据…"))
|
||||
dataSize = BackupAppDataOps.backupExternalData(pkgName, appDir, userId, config.compressionMethod)
|
||||
if (dataSize == null) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(BackupProgress(index + 1, totalCount, pkgName, "appdone", "外部数据备份失败"))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,10 @@ object RestoreApkInstaller {
|
||||
LogUtil.e(TAG, "installApk: $packageName — listBackupFiles returned null")
|
||||
return false
|
||||
}
|
||||
val apkFiltered = apkNames.filter { it.endsWith(".apk") }.sorted()
|
||||
val apkFiltered =
|
||||
apkNames
|
||||
.filter { it.endsWith(".apk") && !it.contains('/') && !it.contains('\\') && it != "." && it != ".." }
|
||||
.sorted()
|
||||
LogUtil.i(TAG, "installApk: $packageName apkFiltered=$apkFiltered")
|
||||
if (apkFiltered.isEmpty()) return false
|
||||
|
||||
@@ -61,7 +64,7 @@ object RestoreApkInstaller {
|
||||
}
|
||||
|
||||
suspend fun doInstall(): Boolean {
|
||||
val apkPaths = localApks.joinToString(" ") { it.absolutePath.shellEscape() }
|
||||
val apkPaths = localApks.joinToString(" ") { "'${it.absolutePath.shellEscape()}'" }
|
||||
if (localApks.size > 1) {
|
||||
val result = RootShell.exec("pm install-create -r -t 2>/dev/null")
|
||||
val sessionId =
|
||||
|
||||
@@ -69,7 +69,11 @@ object RestoreAppDataOps {
|
||||
for (archive in dataFiles) {
|
||||
val archivePath = archive.absolutePath.shellEscape()
|
||||
Log.d(TAG, "restoreData: found archive ${archive.name}")
|
||||
if (!RestoreArchiveSafety.isArchiveSafe(archive, zstdCmd)) {
|
||||
if (!RestoreArchiveSafety.isArchiveSafe(
|
||||
archive,
|
||||
zstdCmd,
|
||||
additionalAllowedPrefixes = dataPaths.map { "$it/" },
|
||||
)) {
|
||||
Log.e(TAG, "restoreData: archive UNSAFE, ABORTING restore for $packageName: ${archive.name}")
|
||||
return false
|
||||
}
|
||||
@@ -82,11 +86,11 @@ object RestoreAppDataOps {
|
||||
}
|
||||
|
||||
archive.name.endsWith(".gz") -> {
|
||||
"$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null"
|
||||
"$tarCmd -xzf '$archivePath' $excludeArgs -C / 2>/dev/null"
|
||||
}
|
||||
|
||||
archive.name.endsWith(".tar") -> {
|
||||
"$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null"
|
||||
"$tarCmd -xf '$archivePath' $excludeArgs -C / 2>/dev/null"
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -156,8 +160,8 @@ object RestoreAppDataOps {
|
||||
var anyExtracted = false
|
||||
for (archive in obbFiles) {
|
||||
if (!RestoreArchiveSafety.isArchiveSafe(archive, zstdCmd, additionalAllowedPrefixes = listOf(
|
||||
"/storage/emulated/0/Android/obb/",
|
||||
"/data/media/$userId/Android/obb/",
|
||||
"/storage/emulated/0/Android/obb/$packageName/",
|
||||
"/data/media/$userId/Android/obb/$packageName/",
|
||||
))) {
|
||||
Log.e(TAG, "restoreObb: archive UNSAFE, ABORTING OBB restore for $packageName: ${archive.name}")
|
||||
return false
|
||||
@@ -170,11 +174,11 @@ object RestoreAppDataOps {
|
||||
}
|
||||
|
||||
archive.name.endsWith(".gz") -> {
|
||||
RootShell.exec("$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null")
|
||||
RootShell.exec("$tarCmd -xzf '$archivePath' $excludeArgs -C / 2>/dev/null")
|
||||
}
|
||||
|
||||
archive.name.endsWith(".tar") -> {
|
||||
RootShell.exec("$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null")
|
||||
RootShell.exec("$tarCmd -xf '$archivePath' $excludeArgs -C / 2>/dev/null")
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -227,8 +231,8 @@ object RestoreAppDataOps {
|
||||
for (name in extNames) {
|
||||
val archive = File(appDir, name)
|
||||
if (!RestoreArchiveSafety.isArchiveSafe(archive, zstdCmd, additionalAllowedPrefixes = listOf(
|
||||
"/data/media/$userId/Android/data/",
|
||||
"/storage/emulated/0/Android/data/",
|
||||
"/data/media/$userId/Android/data/$packageName/",
|
||||
"/storage/emulated/0/Android/data/$packageName/",
|
||||
))) {
|
||||
Log.e(TAG, "restoreExternalData: archive UNSAFE, ABORTING external data restore for $packageName: $name")
|
||||
return false
|
||||
|
||||
@@ -53,17 +53,17 @@ object RestoreArchiveSafety {
|
||||
result = RootShell.exec(fallbackCmd)
|
||||
}
|
||||
if (!result.isSuccess) return false
|
||||
val allowedPrefixes = additionalAllowedPrefixes.ifEmpty { BUILTIN_ALLOWED_PREFIXES }
|
||||
return !result.output.lines().any { line ->
|
||||
val parts = line.split(" -> ", limit = 2)
|
||||
val rawPath = parts[0]
|
||||
val path = rawPath.trimStart('/')
|
||||
val normalizedPath = "/$path"
|
||||
val linkTarget = parts.getOrNull(1)
|
||||
|
||||
// 1. 拒绝绝对路径(以 / 开头)——防止 tar -C / 写入系统文件
|
||||
// 但允许内置的 app data 前缀和调用方指定的额外前缀。
|
||||
if (rawPath.startsWith("/") && !isPathAllowed(rawPath, additionalAllowedPrefixes)) {
|
||||
return@any true
|
||||
}
|
||||
// 1. 恢复使用 tar -C /,所以相对路径 etc/passwd 也会写入
|
||||
// /etc/passwd。所有条目必须落在调用方允许的目标前缀内。
|
||||
if (!matchesAllowedPrefix(normalizedPath, allowedPrefixes)) return@any true
|
||||
|
||||
// 2. 拒绝路径遍历
|
||||
if (path.split("/").any { it == ".." }) return@any true
|
||||
@@ -88,7 +88,14 @@ object RestoreArchiveSafety {
|
||||
rawPath: String,
|
||||
additionalAllowedPrefixes: List<String>,
|
||||
): Boolean {
|
||||
return (BUILTIN_ALLOWED_PREFIXES + additionalAllowedPrefixes).any { prefix ->
|
||||
return matchesAllowedPrefix(rawPath, BUILTIN_ALLOWED_PREFIXES + additionalAllowedPrefixes)
|
||||
}
|
||||
|
||||
private fun matchesAllowedPrefix(
|
||||
rawPath: String,
|
||||
allowedPrefixes: List<String>,
|
||||
): Boolean {
|
||||
return allowedPrefixes.any { prefix ->
|
||||
rawPath == prefix.dropLast(1) || rawPath.startsWith(prefix)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,12 +69,15 @@ object RestoreOperation {
|
||||
LogUtil.i(TAG, "restoreApps: appListContent=${appListContent?.substringBefore("\n")?.take(100)}")
|
||||
val allPackages =
|
||||
appListContent?.let { content ->
|
||||
content.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
content.lines()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
.mapNotNull { PackageName.safe(it)?.value }
|
||||
} ?: run {
|
||||
LogUtil.i(TAG, "restoreApps: readTextFile returned null, trying listBackupFiles")
|
||||
val children = BackupOperation.listBackupFiles(backupDir)
|
||||
LogUtil.i(TAG, "restoreApps: listBackupFiles returned ${children?.size} children")
|
||||
children?.filter { name ->
|
||||
children?.mapNotNull { name -> PackageName.safe(name)?.value }?.filter { name ->
|
||||
val apkFile = File(File(backupDir, name), "$name.apk")
|
||||
val exists = BackupOperation.backupPathExists(apkFile)
|
||||
LogUtil.i(TAG, "restoreApps: child $name apkExists=$exists")
|
||||
@@ -104,12 +107,19 @@ object RestoreOperation {
|
||||
val semaphore = Semaphore(concurrencyConfig.maxConcurrency)
|
||||
LogUtil.i(TAG, "restoreApps: ${concurrencyConfig.reason}")
|
||||
|
||||
val backupCanonical = backupDir.canonicalFile
|
||||
|
||||
supervisorScope {
|
||||
packages.forEachIndexed { index, pkg ->
|
||||
launch {
|
||||
if (!coroutineContext.isActive) return@launch
|
||||
semaphore.withPermit {
|
||||
val appBackupDir = File(backupDir, pkg)
|
||||
val appBackupDir = File(backupCanonical, pkg).canonicalFile
|
||||
if (!appBackupDir.path.startsWith(backupCanonical.path + File.separator)) {
|
||||
failAtomic.incrementAndGet()
|
||||
emit(RestoreProgress(index + 1, packages.size, pkg, "appdone", "备份目录路径非法"))
|
||||
return@withPermit
|
||||
}
|
||||
val dirExists = BackupFileIO.backupPathExists(appBackupDir)
|
||||
LogUtil.i(TAG, "restoreApps: pkg=$pkg appBackupDir=${appBackupDir.absolutePath} exists=$dirExists")
|
||||
if (!dirExists) {
|
||||
|
||||
Reference in New Issue
Block a user