fix(security): 阶段1-3 核心安全修复
Some checks failed
Android CI / build (push) Has been cancelled
CI / build (push) Has been cancelled

阶段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:
sakuradairong
2026-06-17 11:25:07 +08:00
parent 189f46aebd
commit f233198639
6 changed files with 95 additions and 75 deletions

View File

@@ -49,7 +49,8 @@ object BackupAppDataOps {
val bundledTar = BinaryResolver.tarPath(context) val bundledTar = BinaryResolver.tarPath(context)
val tarCmd = bundledTar ?: "tar" 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 bundledZstd = if (isZstd) BinaryResolver.zstdPath(context) else null
val zstdCmd = bundledZstd ?: "zstd" val zstdCmd = bundledZstd ?: "zstd"
if (isZstd && bundledZstd == null) { if (isZstd && bundledZstd == null) {
@@ -157,7 +158,7 @@ object BackupAppDataOps {
) { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'", ) { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'",
) )
} else { } 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() val escapedPkg = packageName.shellEscape()
// Exclude cache and backup temp files from OBB archive // Exclude cache and backup temp files from OBB archive
val obbExcludes = "--exclude='cache' --exclude='Backup_*'" val obbExcludes = "--exclude='cache' --exclude='Backup_*'"
val compressionMethod = BackupConfig.normalizeCompressionMethod(compression)
val result = val result =
when (compression) { when (compressionMethod) {
"zstd" -> { "zstd" -> {
RootShell.exec( RootShell.exec(
"set -o pipefail; tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'", "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 -> { 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) { if (!result.isSuccess) {
Log.e(TAG, "Failed to backup OBB for $packageName: exit=${result.exitCode} err=${result.error}") Log.e(TAG, "Failed to backup OBB for $packageName: exit=${result.exitCode} err=${result.error}")
return null 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 obbFile = File(appDir, "${packageName}_obb.tar$obbArchiveExt")
val obbArchivePath = obbFile.absolutePath.shellEscape() 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 val verificationOk = RootShell.exec(verifyCmd).isSuccess
if (!verificationOk) { if (!verificationOk) {
Log.e(TAG, "OBB archive integrity check FAILED for $packageName") Log.e(TAG, "OBB archive integrity check FAILED for $packageName")
} }
// Validate OBB tar structure // Validate OBB tar structure
val tarListCmd = val tarListCmd =
if (compression == "zstd") { if (compressionMethod == "zstd") {
"zstd -d -c '$obbArchivePath' 2>/dev/null | tar -tf - > /dev/null 2>&1" "zstd -d -c '$obbArchivePath' 2>/dev/null | tar -tf - > /dev/null 2>&1"
} else { } else {
"tar -tf '$obbArchivePath' > /dev/null 2>&1" "tar -tf '$obbArchivePath' > /dev/null 2>&1"
@@ -233,18 +235,19 @@ object BackupAppDataOps {
return 0L // Not an error, just no data 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 archiveFile = File(appDir, "${packageName}_external_data.tar$archiveExt")
val archivePath = archiveFile.absolutePath.shellEscape() val archivePath = archiveFile.absolutePath.shellEscape()
val dataExcludes = "--exclude='cache' --exclude='Backup_*' --exclude='.ota'" val dataExcludes = "--exclude='cache' --exclude='Backup_*' --exclude='.ota'"
val result = val result =
if (compression == "zstd") { if (compressionMethod == "zstd") {
RootShell.exec( RootShell.exec(
"set -o pipefail; tar -cf - $dataExcludes '$externalDataDir' 2>/dev/null | zstd -T0 -o '$archivePath'", "set -o pipefail; tar -cf - $dataExcludes '$externalDataDir' 2>/dev/null | zstd -T0 -o '$archivePath'",
) )
} else { } else {
RootShell.exec("tar -czf $dataExcludes '$archivePath' '$externalDataDir' 2>/dev/null") RootShell.exec("tar -czf '$archivePath' $dataExcludes '$externalDataDir' 2>/dev/null")
} }
if (!result.isSuccess) { if (!result.isSuccess) {
@@ -253,7 +256,7 @@ object BackupAppDataOps {
} }
// Verify compression integrity // 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 val verificationOk = RootShell.exec(verifyCmd).isSuccess
if (!verificationOk) { if (!verificationOk) {
Log.e(TAG, "backupExternalData: $packageName integrity check FAILED") Log.e(TAG, "backupExternalData: $packageName integrity check FAILED")
@@ -262,7 +265,7 @@ object BackupAppDataOps {
// Validate tar structure // Validate tar structure
val tarListCmd = val tarListCmd =
if (compression == "zstd") { if (compressionMethod == "zstd") {
"zstd -d -c '$archivePath' 2>/dev/null | tar -tf - > /dev/null 2>&1" "zstd -d -c '$archivePath' 2>/dev/null | tar -tf - > /dev/null 2>&1"
} else { } else {
"tar -tf '$archivePath' > /dev/null 2>&1" "tar -tf '$archivePath' > /dev/null 2>&1"

View File

@@ -88,8 +88,10 @@ object BackupOperation {
return@withContext BackupResult(0, 0, 0, absOut, 0) return@withContext BackupResult(0, 0, 0, absOut, 0)
} }
val compressionMethod = BackupConfig.normalizeCompressionMethod(config.compressionMethod)
// Create backup structure // Create backup structure
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId") val backupRoot = File(outputDir, "Backup_${compressionMethod}_$userId")
if (!mkdirsForBackup(backupRoot)) { if (!mkdirsForBackup(backupRoot)) {
LogUtil.e(TAG, "backupApps: cannot create output dir ${backupRoot.absolutePath}") LogUtil.e(TAG, "backupApps: cannot create output dir ${backupRoot.absolutePath}")
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0) return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
@@ -170,7 +172,7 @@ object BackupOperation {
app = app, app = app,
backupRoot = backupRoot, backupRoot = backupRoot,
oldMetaJson = oldMetaJson, oldMetaJson = oldMetaJson,
config = config, config = config.copy(compressionMethod = compressionMethod),
userId = userId, userId = userId,
noDataBackup = noDataBackup, noDataBackup = noDataBackup,
appInfoCache = appInfoCache, appInfoCache = appInfoCache,
@@ -196,7 +198,7 @@ object BackupOperation {
} }
val elapsed = System.currentTimeMillis() - startTime 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 successCount = successAtomic.get()
val failCount = failAtomic.get() val failCount = failAtomic.get()
val skippedCount = skippedAtomic.get() val skippedCount = skippedAtomic.get()
@@ -213,7 +215,7 @@ object BackupOperation {
val integrityReport = BackupIntegrityChecker.checkBackupIntegrity( val integrityReport = BackupIntegrityChecker.checkBackupIntegrity(
backupDir = backupRoot, backupDir = backupRoot,
packages = apps.map { it.packageName.value }, packages = apps.map { it.packageName.value },
compression = config.compressionMethod, compression = compressionMethod,
) )
LogUtil.i(TAG, "backupApps: integrity check completed — ${integrityReport.passedPackages}/${integrityReport.checkedPackages} passed") LogUtil.i(TAG, "backupApps: integrity check completed — ${integrityReport.passedPackages}/${integrityReport.checkedPackages} passed")
@@ -221,7 +223,7 @@ object BackupOperation {
BackupIntegrityChecker.generateChecksumFile( BackupIntegrityChecker.generateChecksumFile(
backupDir = backupRoot, backupDir = backupRoot,
packages = apps.map { it.packageName.value }, packages = apps.map { it.packageName.value },
compression = config.compressionMethod, compression = compressionMethod,
) )
} }
@@ -281,16 +283,24 @@ object BackupOperation {
progressTracker.updateStage("apk", "正在备份 APK…") progressTracker.updateStage("apk", "正在备份 APK…")
emit(BackupProgress(index + 1, totalCount, pkgName, "apk", "正在备份 APK…")) emit(BackupProgress(index + 1, totalCount, pkgName, "apk", "正在备份 APK…"))
val paths = appInfoCache.getApkPaths(pkgName) val paths = appInfoCache.getApkPaths(pkgName)
if (paths.isNotEmpty()) { if (paths.isEmpty()) {
val cpOk = failAtomic.incrementAndGet()
paths.withIndex().all { (i, apkPath) -> emit(BackupProgress(index + 1, totalCount, pkgName, "appdone", "APK 路径为空"))
val destName = if (paths.size > 1) "${pkgName}_split_$i.apk" else "$pkgName.apk" return
RootShell }
.exec( val cpOk =
"cp '${apkPath.shellEscape()}' '${appDir.absolutePath.shellEscape()}/${destName.shellEscape()}'", paths.withIndex().all { (i, apkPath) ->
).isSuccess val destName = if (paths.size > 1) "${pkgName}_split_$i.apk" else "$pkgName.apk"
} val dest = File(appDir, destName)
if (!cpOk) LogUtil.w(TAG, "backupApps: APK cp failed for $pkgName, continuing") RootShell
.exec(
"cp '${apkPath.shellEscape()}' '${dest.absolutePath.shellEscape()}'",
).isSuccess && BackupFileIO.backupPathExists(dest) && BackupFileIO.backupFileSize(dest) > 0L
}
if (!cpOk) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, pkgName, "appdone", "APK 备份失败"))
return
} }
} else { } else {
skippedAtomic.incrementAndGet() skippedAtomic.incrementAndGet()
@@ -302,28 +312,8 @@ object BackupOperation {
val hasKeystore = appInfoCache.hasKeystore(pkgName) ?: false val hasKeystore = appInfoCache.hasKeystore(pkgName) ?: false
if (hasKeystore) emit(BackupProgress(index + 1, totalCount, pkgName, "data", "⚠ 包含密钥库条目")) if (hasKeystore) emit(BackupProgress(index + 1, totalCount, pkgName, "data", "⚠ 包含密钥库条目"))
// ── Size-based data incremental skip ── // App data changes independently of APK version; do not skip mutable
var skipData = false // data based only on stale metadata from a previous backup.
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, "数据大小已知,跳过数据备份")
}
}
var userSize: Long? = null var userSize: Long? = null
var userDeSize: Long? = null var userDeSize: Long? = null
var dataSize: Long? = null var dataSize: Long? = null
@@ -331,14 +321,14 @@ object BackupOperation {
// Force-stop before data backup for consistency. // Force-stop before data backup for consistency.
// Exclude the app itself (avoid suicide) and well-known persistent apps. // 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)) { 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") RootShell.exec("am force-stop --user ${userId.shellEscape()} '${pkgName.shellEscape()}' 2>/dev/null")
} }
} }
// 2. Backup user data // 2. Backup user data
if (config.backupMode == 1 && config.backupUserData == 1 && !skipData) { if (config.backupMode == 1 && config.backupUserData == 1) {
if (pkgName in noDataBackup) { if (pkgName in noDataBackup) {
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "跳过数据备份(已排除)")) emit(BackupProgress(index + 1, totalCount, pkgName, "data", "跳过数据备份(已排除)"))
} else { } else {
@@ -354,12 +344,10 @@ object BackupOperation {
return return
} }
} }
} else if (skipData) {
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "数据无变化,跳过"))
} }
// 3. Backup OBB // 3. Backup OBB
if (config.backupMode == 1 && config.backupObbData == 1 && !skipData) { if (config.backupMode == 1 && config.backupObbData == 1) {
val hasObb = AppScanner.hasObbData(pkgName) val hasObb = AppScanner.hasObbData(pkgName)
if (hasObb) { if (hasObb) {
emit(BackupProgress(index + 1, totalCount, pkgName, "obb", "正在备份 OBB…")) emit(BackupProgress(index + 1, totalCount, pkgName, "obb", "正在备份 OBB…"))
@@ -373,10 +361,15 @@ object BackupOperation {
} }
// 3.5 Backup external data // 3.5 Backup external data
if (config.backupMode == 1 && config.backupUserData == 1 && !skipData) { if (config.backupMode == 1 && config.backupUserData == 1) {
if (pkgName !in noDataBackup) { if (pkgName !in noDataBackup) {
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "正在备份外部数据…")) emit(BackupProgress(index + 1, totalCount, pkgName, "data", "正在备份外部数据…"))
dataSize = BackupAppDataOps.backupExternalData(pkgName, appDir, userId, config.compressionMethod) dataSize = BackupAppDataOps.backupExternalData(pkgName, appDir, userId, config.compressionMethod)
if (dataSize == null) {
failAtomic.incrementAndGet()
emit(BackupProgress(index + 1, totalCount, pkgName, "appdone", "外部数据备份失败"))
return
}
} }
} }

View File

@@ -38,7 +38,10 @@ object RestoreApkInstaller {
LogUtil.e(TAG, "installApk: $packageName — listBackupFiles returned null") LogUtil.e(TAG, "installApk: $packageName — listBackupFiles returned null")
return false 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") LogUtil.i(TAG, "installApk: $packageName apkFiltered=$apkFiltered")
if (apkFiltered.isEmpty()) return false if (apkFiltered.isEmpty()) return false
@@ -61,7 +64,7 @@ object RestoreApkInstaller {
} }
suspend fun doInstall(): Boolean { suspend fun doInstall(): Boolean {
val apkPaths = localApks.joinToString(" ") { it.absolutePath.shellEscape() } val apkPaths = localApks.joinToString(" ") { "'${it.absolutePath.shellEscape()}'" }
if (localApks.size > 1) { if (localApks.size > 1) {
val result = RootShell.exec("pm install-create -r -t 2>/dev/null") val result = RootShell.exec("pm install-create -r -t 2>/dev/null")
val sessionId = val sessionId =

View File

@@ -69,7 +69,11 @@ object RestoreAppDataOps {
for (archive in dataFiles) { for (archive in dataFiles) {
val archivePath = archive.absolutePath.shellEscape() val archivePath = archive.absolutePath.shellEscape()
Log.d(TAG, "restoreData: found archive ${archive.name}") 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}") Log.e(TAG, "restoreData: archive UNSAFE, ABORTING restore for $packageName: ${archive.name}")
return false return false
} }
@@ -82,11 +86,11 @@ object RestoreAppDataOps {
} }
archive.name.endsWith(".gz") -> { archive.name.endsWith(".gz") -> {
"$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null" "$tarCmd -xzf '$archivePath' $excludeArgs -C / 2>/dev/null"
} }
archive.name.endsWith(".tar") -> { archive.name.endsWith(".tar") -> {
"$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null" "$tarCmd -xf '$archivePath' $excludeArgs -C / 2>/dev/null"
} }
else -> { else -> {
@@ -156,8 +160,8 @@ object RestoreAppDataOps {
var anyExtracted = false var anyExtracted = false
for (archive in obbFiles) { for (archive in obbFiles) {
if (!RestoreArchiveSafety.isArchiveSafe(archive, zstdCmd, additionalAllowedPrefixes = listOf( if (!RestoreArchiveSafety.isArchiveSafe(archive, zstdCmd, additionalAllowedPrefixes = listOf(
"/storage/emulated/0/Android/obb/", "/storage/emulated/0/Android/obb/$packageName/",
"/data/media/$userId/Android/obb/", "/data/media/$userId/Android/obb/$packageName/",
))) { ))) {
Log.e(TAG, "restoreObb: archive UNSAFE, ABORTING OBB restore for $packageName: ${archive.name}") Log.e(TAG, "restoreObb: archive UNSAFE, ABORTING OBB restore for $packageName: ${archive.name}")
return false return false
@@ -170,11 +174,11 @@ object RestoreAppDataOps {
} }
archive.name.endsWith(".gz") -> { 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") -> { 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 -> { else -> {
@@ -227,8 +231,8 @@ object RestoreAppDataOps {
for (name in extNames) { for (name in extNames) {
val archive = File(appDir, name) val archive = File(appDir, name)
if (!RestoreArchiveSafety.isArchiveSafe(archive, zstdCmd, additionalAllowedPrefixes = listOf( if (!RestoreArchiveSafety.isArchiveSafe(archive, zstdCmd, additionalAllowedPrefixes = listOf(
"/data/media/$userId/Android/data/", "/data/media/$userId/Android/data/$packageName/",
"/storage/emulated/0/Android/data/", "/storage/emulated/0/Android/data/$packageName/",
))) { ))) {
Log.e(TAG, "restoreExternalData: archive UNSAFE, ABORTING external data restore for $packageName: $name") Log.e(TAG, "restoreExternalData: archive UNSAFE, ABORTING external data restore for $packageName: $name")
return false return false

View File

@@ -53,17 +53,17 @@ object RestoreArchiveSafety {
result = RootShell.exec(fallbackCmd) result = RootShell.exec(fallbackCmd)
} }
if (!result.isSuccess) return false if (!result.isSuccess) return false
val allowedPrefixes = additionalAllowedPrefixes.ifEmpty { BUILTIN_ALLOWED_PREFIXES }
return !result.output.lines().any { line -> return !result.output.lines().any { line ->
val parts = line.split(" -> ", limit = 2) val parts = line.split(" -> ", limit = 2)
val rawPath = parts[0] val rawPath = parts[0]
val path = rawPath.trimStart('/') val path = rawPath.trimStart('/')
val normalizedPath = "/$path"
val linkTarget = parts.getOrNull(1) val linkTarget = parts.getOrNull(1)
// 1. 拒绝绝对路径(以 / 开头)——防止 tar -C / 写入系统文件 // 1. 恢复使用 tar -C /,所以相对路径 etc/passwd 也会写入
// 但允许内置的 app data 前缀和调用方指定的额外前缀。 // /etc/passwd。所有条目必须落在调用方允许的目标前缀
if (rawPath.startsWith("/") && !isPathAllowed(rawPath, additionalAllowedPrefixes)) { if (!matchesAllowedPrefix(normalizedPath, allowedPrefixes)) return@any true
return@any true
}
// 2. 拒绝路径遍历 // 2. 拒绝路径遍历
if (path.split("/").any { it == ".." }) return@any true if (path.split("/").any { it == ".." }) return@any true
@@ -88,7 +88,14 @@ object RestoreArchiveSafety {
rawPath: String, rawPath: String,
additionalAllowedPrefixes: List<String>, additionalAllowedPrefixes: List<String>,
): Boolean { ): 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) rawPath == prefix.dropLast(1) || rawPath.startsWith(prefix)
} }
} }

View File

@@ -69,12 +69,15 @@ object RestoreOperation {
LogUtil.i(TAG, "restoreApps: appListContent=${appListContent?.substringBefore("\n")?.take(100)}") LogUtil.i(TAG, "restoreApps: appListContent=${appListContent?.substringBefore("\n")?.take(100)}")
val allPackages = val allPackages =
appListContent?.let { content -> 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 { } ?: run {
LogUtil.i(TAG, "restoreApps: readTextFile returned null, trying listBackupFiles") LogUtil.i(TAG, "restoreApps: readTextFile returned null, trying listBackupFiles")
val children = BackupOperation.listBackupFiles(backupDir) val children = BackupOperation.listBackupFiles(backupDir)
LogUtil.i(TAG, "restoreApps: listBackupFiles returned ${children?.size} children") 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 apkFile = File(File(backupDir, name), "$name.apk")
val exists = BackupOperation.backupPathExists(apkFile) val exists = BackupOperation.backupPathExists(apkFile)
LogUtil.i(TAG, "restoreApps: child $name apkExists=$exists") LogUtil.i(TAG, "restoreApps: child $name apkExists=$exists")
@@ -104,12 +107,19 @@ object RestoreOperation {
val semaphore = Semaphore(concurrencyConfig.maxConcurrency) val semaphore = Semaphore(concurrencyConfig.maxConcurrency)
LogUtil.i(TAG, "restoreApps: ${concurrencyConfig.reason}") LogUtil.i(TAG, "restoreApps: ${concurrencyConfig.reason}")
val backupCanonical = backupDir.canonicalFile
supervisorScope { supervisorScope {
packages.forEachIndexed { index, pkg -> packages.forEachIndexed { index, pkg ->
launch { launch {
if (!coroutineContext.isActive) return@launch if (!coroutineContext.isActive) return@launch
semaphore.withPermit { 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) val dirExists = BackupFileIO.backupPathExists(appBackupDir)
LogUtil.i(TAG, "restoreApps: pkg=$pkg appBackupDir=${appBackupDir.absolutePath} exists=$dirExists") LogUtil.i(TAG, "restoreApps: pkg=$pkg appBackupDir=${appBackupDir.absolutePath} exists=$dirExists")
if (!dirExists) { if (!dirExists) {