diff --git a/app/src/main/java/com/example/androidbackupgui/backup/BackupAppDataOps.kt b/app/src/main/java/com/example/androidbackupgui/backup/BackupAppDataOps.kt index 87d5c11..21f4e7b 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/BackupAppDataOps.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/BackupAppDataOps.kt @@ -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" diff --git a/app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt b/app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt index 23052ed..80df6f7 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt @@ -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()) { - val cpOk = - paths.withIndex().all { (i, apkPath) -> - val destName = if (paths.size > 1) "${pkgName}_split_$i.apk" else "$pkgName.apk" - RootShell - .exec( - "cp '${apkPath.shellEscape()}' '${appDir.absolutePath.shellEscape()}/${destName.shellEscape()}'", - ).isSuccess - } - if (!cpOk) LogUtil.w(TAG, "backupApps: APK cp failed for $pkgName, continuing") + 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()}' '${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 { 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 + } } } diff --git a/app/src/main/java/com/example/androidbackupgui/backup/RestoreApkInstaller.kt b/app/src/main/java/com/example/androidbackupgui/backup/RestoreApkInstaller.kt index 4016264..eb16de4 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/RestoreApkInstaller.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/RestoreApkInstaller.kt @@ -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 = diff --git a/app/src/main/java/com/example/androidbackupgui/backup/RestoreAppDataOps.kt b/app/src/main/java/com/example/androidbackupgui/backup/RestoreAppDataOps.kt index b37a160..2c02da1 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/RestoreAppDataOps.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/RestoreAppDataOps.kt @@ -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 diff --git a/app/src/main/java/com/example/androidbackupgui/backup/RestoreArchiveSafety.kt b/app/src/main/java/com/example/androidbackupgui/backup/RestoreArchiveSafety.kt index fcbaf8b..4da5ff7 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/RestoreArchiveSafety.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/RestoreArchiveSafety.kt @@ -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, ): Boolean { - return (BUILTIN_ALLOWED_PREFIXES + additionalAllowedPrefixes).any { prefix -> + return matchesAllowedPrefix(rawPath, BUILTIN_ALLOWED_PREFIXES + additionalAllowedPrefixes) + } + + private fun matchesAllowedPrefix( + rawPath: String, + allowedPrefixes: List, + ): Boolean { + return allowedPrefixes.any { prefix -> rawPath == prefix.dropLast(1) || rawPath.startsWith(prefix) } } diff --git a/app/src/main/java/com/example/androidbackupgui/backup/RestoreOperation.kt b/app/src/main/java/com/example/androidbackupgui/backup/RestoreOperation.kt index 35bff2d..17fb5fb 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/RestoreOperation.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/RestoreOperation.kt @@ -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) {