4 Commits
v1.13 ... v1.14

Author SHA1 Message Date
sakuradairong
cffa9a2b8a release: v1.14
修复 shell 转义/管道死锁/配置序列化缺陷,新增配置导出与 BackupConfig 单元测试

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 20:48:52 +08:00
RainySY
834f515e01 test: 新增 BackupConfig 读写往返单元测试,修复 gitignore 误排除 src/test 2026-06-07 20:41:30 +08:00
RainySY
949d13f1ea Merge pull request #1 from sakuradairong/fix/shell-escape-and-config-export
fix: 修复 shell 转义/管道死锁/配置序列化缺陷,新增配置导出
2026-06-07 20:36:39 +08:00
RainySY
d701951338 fix: 修复 shell 转义/管道死锁/配置序列化缺陷,新增配置导出
- ResticCommandRunner: 用独立线程并发读取 stderr,消除非流式 runRestic 的管道死锁(stderr 缓冲区写满导致子进程与主线程互等,卡至 60s 超时)
- RestoreOperation.restoreSsaid: 对 ssaidValue 强制 hex 校验、id 强制 UUID 格式校验,避免在双引号 sed 表达式中被注入或写坏 settings_ssaid.xml(shellEscape 仅对单引号上下文有效)
- BackupConfig: 引号字段保留内部空格并对双引号/反斜杠做对称转义/反转义,修复含特殊字符或首尾空格的 restic 密码读写失真;兼容旧配置格式
- RootShell.configure: catch 范围扩到 Exception,异常 ROM 上不再崩溃启动
- ConfigScreen/ConfigViewModel: 新增配置导出(SAF CreateDocument),含明文密码时显示安全提示
2026-06-07 20:33:47 +08:00
10 changed files with 218 additions and 16 deletions

2
.gitignore vendored
View File

@@ -21,5 +21,5 @@ release.keystore
memory:*
# Restic test repository (contains encryption keys)
test/
/test/
kmboxnet

View File

@@ -104,6 +104,7 @@ restic 通过 REST HTTP API 与本地桥通信,桥接器将请求翻译为 SMB
| 版本 | 更新内容 |
|------|---------|
| v1.14 | 修复 shell 转义/管道死锁/配置序列化缺陷,新增配置导出与 BackupConfig 单元测试 |
| v1.13 | Compose Material 3 UI 重构、Unlock 支持、ResticBinary 启动初始化、修复 500 错误和刷新竞态 |
| v1.12 | 引擎 + Compose Material 3 UI 重构 |
| v1.11 | 构建系统改进、LSP 支持 |

View File

@@ -25,7 +25,7 @@ android {
minSdk 24
targetSdk 34
versionCode 14
versionName "1.13"
versionName "1.14"
}
buildFeatures {
compose = true

View File

@@ -76,9 +76,41 @@ data class BackupConfig(
val resticBackendDomain: String = "" // SMB domain (optional, for NTLM)
) {
companion object {
/**
* Unescape a quoted config value. Reverses [escapeValue]: turns \\ and \"
* back into \ and ". Applied only to values that were stored inside quotes.
*/
private fun unescapeValue(s: String): String {
if (s.indexOf('\\') < 0) return s
val sb = StringBuilder(s.length)
var i = 0
while (i < s.length) {
val c = s[i]
if (c == '\\' && i + 1 < s.length) {
sb.append(s[i + 1]); i += 2
} else {
sb.append(c); i++
}
}
return sb.toString()
}
/** Escape a value for safe storage inside double quotes. */
private fun escapeValue(s: String): String =
s.replace("\\", "\\\\").replace("\"", "\\\"")
fun fromFile(file: File): BackupConfig {
if (!file.exists()) return BackupConfig()
// Quoted-string fields preserve their inner whitespace and may contain
// escaped characters; bare fields are trimmed as before.
val quotedKeys = setOf(
"Output_path", "list_location", "mount_point",
"restic_repo", "restic_password", "restic_backend_url",
"restic_backend_user", "restic_backend_pass",
"restic_backend_share", "restic_backend_domain"
)
val props = mutableMapOf<String, String>()
file.forEachLine { line ->
val trimmed = line.trim()
@@ -86,8 +118,21 @@ data class BackupConfig(
val eq = trimmed.indexOf('=')
if (eq < 0) return@forEachLine
val key = trimmed.substring(0, eq).trim()
val value = trimmed.substring(eq + 1).trim().removeSurrounding("\"")
props[key] = value
val rawValue = trimmed.substring(eq + 1)
props[key] = if (key in quotedKeys) {
// Strip the surrounding quotes (if present) WITHOUT trimming the
// inner content, so leading/trailing spaces in e.g. a password
// survive a save/load round trip. Then unescape.
val v = rawValue
if (v.length >= 2 && v.startsWith("\"") && v.endsWith("\"")) {
unescapeValue(v.substring(1, v.length - 1))
} else {
// Legacy/unquoted value — fall back to trimmed form.
unescapeValue(v.trim().removeSurrounding("\""))
}
} else {
rawValue.trim().removeSurrounding("\"")
}
}
fun int(key: String, default: Int = 0) = props[key]?.toIntOrNull() ?: default
@@ -146,11 +191,11 @@ data class BackupConfig(
appendLine("background_execution=${config.backgroundExecution}")
appendLine("setDisplayPowerMode=${config.setDisplayPowerMode}")
appendLine("Shell_LANG=${config.shellLang}")
appendLine("Output_path=\"${config.outputPath}\"")
appendLine("list_location=\"${config.listLocation}\"")
appendLine("Output_path=\"${escapeValue(config.outputPath)}\"")
appendLine("list_location=\"${escapeValue(config.listLocation)}\"")
appendLine("update=${config.update}")
appendLine("cdn=${config.cdn}")
appendLine("mount_point=\"${config.mountPoint}\"")
appendLine("mount_point=\"${escapeValue(config.mountPoint)}\"")
appendLine("user=${config.user}")
appendLine("Backup_Mode=${config.backupMode}")
appendLine("Backup_user_data=${config.backupUserData}")
@@ -177,14 +222,14 @@ data class BackupConfig(
appendLine("rgb_c=${config.rgbC}")
appendLine("backup_wifi=${config.backupWifi}")
appendLine("restic_enabled=${config.resticEnabled}")
appendLine("restic_repo=\"${config.resticRepo}\"")
appendLine("restic_password=\"${config.resticPassword}\"")
appendLine("restic_repo=\"${escapeValue(config.resticRepo)}\"")
appendLine("restic_password=\"${escapeValue(config.resticPassword)}\"")
appendLine("restic_backend=${config.resticBackend}")
appendLine("restic_backend_url=\"${config.resticBackendUrl}\"")
appendLine("restic_backend_user=\"${config.resticBackendUser}\"")
appendLine("restic_backend_pass=\"${config.resticBackendPass}\"")
appendLine("restic_backend_share=\"${config.resticBackendShare}\"")
appendLine("restic_backend_domain=\"${config.resticBackendDomain}\"")
appendLine("restic_backend_url=\"${escapeValue(config.resticBackendUrl)}\"")
appendLine("restic_backend_user=\"${escapeValue(config.resticBackendUser)}\"")
appendLine("restic_backend_pass=\"${escapeValue(config.resticBackendPass)}\"")
appendLine("restic_backend_share=\"${escapeValue(config.resticBackendShare)}\"")
appendLine("restic_backend_domain=\"${escapeValue(config.resticBackendDomain)}\"")
})
file.setReadable(true, true) // owner only
file.setWritable(true, true) // owner only

View File

@@ -66,11 +66,23 @@ class ResticCommandRunner {
pb.redirectErrorStream(false)
val process = pb.start()
// Drain stderr on a separate daemon thread to avoid a pipe deadlock:
// if stderr's buffer fills while we're still reading stdout, the child
// process blocks on writing stderr and we block on reading stdout.
var stderrBytes = byteArrayOf()
val stderrThread = Thread {
try {
stderrBytes = process.errorStream.use { it.readAllBytesCompat() }
} catch (_: Exception) {
// stream closed early; leave stderrBytes empty
}
}.apply { isDaemon = true; start() }
val stdout = process.inputStream.bufferedReader().use(BufferedReader::readText)
val stderrBytes = process.errorStream.use { it.readAllBytesCompat() }
val exitCode = try {
process.waitForCompat()
} catch (_: Exception) { -1 }
try { stderrThread.join(1_000) } catch (_: InterruptedException) {}
val stderrText = stderrBytes.decodeToString()
Log.i(TAG, "runRestic exitCode=$exitCode stdout_len=${stdout.length}")
if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText.trim()}")

View File

@@ -344,6 +344,14 @@ object RestoreOperation {
val ssaidValue = ssaidFile.readText().trim()
if (ssaidValue.isBlank()) return
// SSAID is a hex token. Reject anything else so it can never break out of
// the sed expression below (shellEscape only protects single-quote context,
// not the double-quoted sed string).
if (!ssaidValue.matches(Regex("^[0-9a-fA-F]+$"))) {
Log.w(TAG, "restoreSsaid: ssaid value is not hex, skipping XML edit for $packageName")
return
}
// Resolve the app's UID
val uidResult = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep 'userId=' | head -1")
val uid = uidResult.output
@@ -371,7 +379,8 @@ object RestoreOperation {
// Generate a UUID for the new entry
val uuidResult = RootShell.exec("cat /proc/sys/kernel/random/uuid 2>/dev/null")
val id = uuidResult.output.trim()
if (id.length != 36) { // UUID format check
// Strict UUID format check (also keeps the value safe inside the sed string)
if (!id.matches(Regex("^[0-9a-fA-F-]{36}$"))) {
Log.w(TAG, "restoreSsaid: could not generate UUID (got '$id'), falling back")
return@run false
}

View File

@@ -64,6 +64,9 @@ object RootShell {
// Shell already created (e.g. from Application superclass or prior session).
// The default builder is already in effect — our custom config is ignored
// but the shell is still functional.
} catch (e: Exception) {
// Some ROMs throw other exceptions during root init; don't crash startup.
Log.w(TAG, "configure: failed to set default builder", e)
}
}

View File

@@ -1,11 +1,14 @@
package com.example.androidbackupgui.ui
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -93,6 +96,8 @@ fun ConfigScreen(
is OperationEvent.PruneStarted -> "正在清理快照…"
is OperationEvent.PruneCompleted -> "清理完成"
is OperationEvent.PruneFailed -> "清理失败"
is OperationEvent.ConfigExported -> "配置已导出"
is OperationEvent.ConfigExportFailed -> "配置导出失败"
else -> null
}
if (msg != null) {
@@ -103,6 +108,13 @@ fun ConfigScreen(
val scrollState = rememberScrollState()
// SAF launcher: create a .conf document at a user-chosen location, then export.
val exportLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/plain")
) { uri ->
if (uri != null) viewModel.exportConfig(uri)
}
Column(
modifier = Modifier
.fillMaxSize()
@@ -382,6 +394,23 @@ fun ConfigScreen(
Text("保存配置")
}
// ── Export config button ──
OutlinedButton(
onClick = { exportLauncher.launch("backup_settings.conf") },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Filled.FileUpload, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("导出配置")
}
if (resticEnabled && resticPassword.isNotEmpty()) {
Text(
text = "注意:导出的配置包含明文 Restic 密码,请妥善保管导出的文件。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
Spacer(Modifier.height(32.dp))
}
}

View File

@@ -72,6 +72,8 @@ sealed interface OperationEvent {
data object PruneStarted : OperationEvent
data object PruneFailed : OperationEvent
data object PruneCompleted : OperationEvent
data object ConfigExported : OperationEvent
data object ConfigExportFailed : OperationEvent
}
class ConfigViewModel(application: Application) : AndroidViewModel(application) {
@@ -170,6 +172,45 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
}
}
/**
* Export the current saved config to a user-selected destination [Uri] (SAF).
* Writes the same on-disk config format, including the plaintext restic password,
* so the warning is surfaced in the UI before export.
*/
fun exportConfig(uri: android.net.Uri) {
viewModelScope.launch {
val ok = withContext(Dispatchers.IO) {
try {
// Ensure the latest saved config exists; serialize current UI config
// if the file isn't there yet.
val content = if (configFile.exists()) {
configFile.readText()
} else {
val tmp = File.createTempFile("cfg", ".conf", getApplication<Application>().cacheDir)
BackupConfig.toFile(_uiState.value.config, tmp)
tmp.readText().also { tmp.delete() }
}
getApplication<Application>().contentResolver
.openOutputStream(uri)?.use { out ->
out.write(content.toByteArray())
out.flush()
} ?: return@withContext false
true
} catch (e: Exception) {
Log.e(TAG, "exportConfig failed", e)
false
}
}
if (ok) {
_operationEvents.emit(OperationEvent.ConfigExported)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "配置已导出")) }
} else {
_operationEvents.emit(OperationEvent.ConfigExportFailed)
_uiState.update { it.copy(resticStatus = it.resticStatus.copy(message = "配置导出失败")) }
}
}
}
/** Prepare ResticWrapper (binary, temp dir, domain) from application context. */
private fun prepareRestic(): Boolean {
val ctx = getApplication<Application>()

View File

@@ -0,0 +1,62 @@
package com.example.androidbackupgui.backup
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.io.File
class BackupConfigTest : FunSpec({
// Helper: write config to temp file, read it back
fun roundTrip(config: BackupConfig): BackupConfig {
val tmp = File.createTempFile("cfg_test", ".conf")
try {
BackupConfig.toFile(config, tmp)
return BackupConfig.fromFile(tmp)
} finally {
tmp.delete()
}
}
test("plain password survives round trip") {
val c = BackupConfig(resticPassword = "simple123")
roundTrip(c).resticPassword shouldBe "simple123"
}
test("password with double-quote survives round trip") {
val c = BackupConfig(resticPassword = "pa\"ss\"word")
roundTrip(c).resticPassword shouldBe "pa\"ss\"word"
}
test("password with backslash survives round trip") {
val c = BackupConfig(resticPassword = "p\\a\\ss")
roundTrip(c).resticPassword shouldBe "p\\a\\ss"
}
test("password with leading and trailing spaces survives round trip") {
val c = BackupConfig(resticPassword = " sp ace ")
roundTrip(c).resticPassword shouldBe " sp ace "
}
test("password with special shell characters survives round trip") {
val c = BackupConfig(resticPassword = "p@\$s#w!ord&")
roundTrip(c).resticPassword shouldBe "p@\$s#w!ord&"
}
test("restic_backend_pass with quote and backslash survives round trip") {
val c = BackupConfig(resticBackendPass = "a\\\"b")
roundTrip(c).resticBackendPass shouldBe "a\\\"b"
}
test("output path with spaces survives round trip") {
val c = BackupConfig(outputPath = "/sdcard/my backups/")
roundTrip(c).outputPath shouldBe "/sdcard/my backups/"
}
test("non-restic fields are unaffected") {
val c = BackupConfig(backupMode = 1, backupWifi = 0, compressionMethod = "zstd")
val out = roundTrip(c)
out.backupMode shouldBe 1
out.backupWifi shouldBe 0
out.compressionMethod shouldBe "zstd"
}
})