diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootManager.kt index e8a900d9c..6e3f1e46e 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootManager.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootManager.kt @@ -6,7 +6,6 @@ import com.v2ray.ang.root.RootManager.refresh import com.v2ray.ang.util.LogUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.util.concurrent.TimeUnit /** * Detects whether the device grants root (`su`) access. @@ -43,21 +42,17 @@ object RootManager { private fun probe(): Boolean { return try { - val process = ProcessBuilder("su", "-c", "id -u") - .redirectErrorStream(true) - .start() - val output = process.inputStream.bufferedReader().use { it.readText() }.trim() - val finished = process.waitFor(10, TimeUnit.SECONDS) - if (!finished) { - process.destroy() + val result = RootProcessRunner.run(listOf("su", "-c", "id -u"), 10000, 1024) + if (result.code == -1) { LogUtil.w(AppConfig.TAG, "RootManager: su probe timed out") return false } - val isRoot = process.exitValue() == 0 && output.lineSequence().lastOrNull()?.trim() == "0" + val isRoot = result.code == 0 && result.output.lineSequence().filter { it.isNotBlank() }.lastOrNull()?.trim() == "0" LogUtil.i(AppConfig.TAG, "RootManager: root available = $isRoot") isRoot } catch (e: Exception) { - LogUtil.w(AppConfig.TAG, "RootManager: no root access (${e.message})") + if (e is InterruptedException) Thread.currentThread().interrupt() + LogUtil.w(AppConfig.TAG, "RootManager: root probe failed", e) false } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootProcessRunner.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootProcessRunner.kt new file mode 100644 index 000000000..163d6bdd8 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootProcessRunner.kt @@ -0,0 +1,41 @@ +package com.v2ray.ang.root + +import java.io.ByteArrayOutputStream +import java.util.concurrent.TimeUnit + +/** Owns one bounded root command. Neither inherited stdout nor output volume extends its deadline. */ +internal object RootProcessRunner { + data class Outcome(val code: Int, val output: String) + + fun run(command: List, timeoutMillis: Long, outputLimit: Int = 65536): Outcome { + val process = ProcessBuilder(command).redirectErrorStream(true).start() + val captured = ByteArrayOutputStream() + val buffer = ByteArray(4096) + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis) + var code = -1 + try { + // Process.waitFor(timeout, unit) is API 26. Polling supports API 24–25 too. + // Read only available bytes: a detached helper may retain stdout after su exits. + while (System.nanoTime() < deadline) { + val available = process.inputStream.available() + if (available > 0) { + val size = process.inputStream.read(buffer, 0, minOf(buffer.size, available)) + val remaining = outputLimit - captured.size() + if (size > 0 && remaining > 0) captured.write(buffer, 0, minOf(size, remaining)) + } + try { + code = process.exitValue() + if (process.inputStream.available() == 0) break + } catch (_: IllegalThreadStateException) { + if (available == 0) Thread.sleep(10) + } + } + } finally { + if (code == -1) process.destroy() + process.inputStream.close() + process.outputStream.close() + process.errorStream.close() + } + return Outcome(code, captured.toString("UTF-8")) + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootShell.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootShell.kt index e69ee8735..d9897a3fb 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootShell.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootShell.kt @@ -4,7 +4,6 @@ import android.content.Context import com.v2ray.ang.AppConfig import com.v2ray.ang.util.LogUtil import java.io.File -import java.util.concurrent.TimeUnit /** * Minimal root command runner backed by the `su` binary. @@ -24,32 +23,29 @@ object RootShell { val dir = File(context.filesDir, AppConfig.ROOT_RUNTIME_DIR).apply { mkdirs() } val file = File(dir, name).apply { writeText(script) - setExecutable(true, false) + setReadable(false, false) + setReadable(true, true) + setWritable(false, false) + setWritable(true, true) } val safePath = file.absolutePath.replace("'", "'\\''") - return exec("sh '$safePath'") + // Android toybox timeout owns the root shell, so killing the su client cannot leave + // a timed-out setup shell installing rules after rollback has already started. + return exec("timeout -s TERM -k 2 25 sh '$safePath'") } private fun exec(command: String, timeoutSeconds: Long = 30): Result { return try { - val process = ProcessBuilder("su", "-c", command) - .redirectErrorStream(true) - .start() - val output = process.inputStream.bufferedReader().use { it.readText() } - val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS) - if (!finished) { - process.destroy() - LogUtil.e(AppConfig.TAG, "RootShell: timed out: $command") - return Result(-1, output) - } - val result = Result(process.exitValue(), output) + val outcome = RootProcessRunner.run(listOf("su", "-c", command), timeoutSeconds * 1000) + val result = Result(outcome.code, outcome.output) if (!result.success) { - LogUtil.w(AppConfig.TAG, "RootShell: '$command' exited ${result.code}: ${output.trim()}") + LogUtil.w(AppConfig.TAG, "RootShell: root command exited ${result.code}: ${result.output.trim()}") } result } catch (e: Exception) { - LogUtil.e(AppConfig.TAG, "RootShell: failed to run '$command'", e) - Result(-1, e.message ?: e.javaClass.simpleName) + if (e is InterruptedException) Thread.currentThread().interrupt() + LogUtil.e(AppConfig.TAG, "RootShell: root command failed", e) + Result(-1, e.javaClass.simpleName) } } } diff --git a/V2rayNG/app/src/test/java/com/v2ray/ang/root/RootProcessRunnerTest.kt b/V2rayNG/app/src/test/java/com/v2ray/ang/root/RootProcessRunnerTest.kt new file mode 100644 index 000000000..4cfb7e6ea --- /dev/null +++ b/V2rayNG/app/src/test/java/com/v2ray/ang/root/RootProcessRunnerTest.kt @@ -0,0 +1,31 @@ +package com.v2ray.ang.root + +import org.junit.Assert.* +import org.junit.Test +import java.util.concurrent.TimeUnit + +class RootProcessRunnerTest { + @Test fun `a silent blocked command respects its deadline`() { + val start = System.nanoTime() + val result = RootProcessRunner.run(listOf("sh", "-c", "sleep 5"), 150) + assertEquals(-1, result.code) + assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) < 1500) + } + @Test fun `inherited stdout cannot hold a completed command open`() { + val start = System.nanoTime() + val result = RootProcessRunner.run(listOf("sh", "-c", "sleep 5 & echo complete"), 250) + assertEquals(0, result.code) + assertTrue(result.output.contains("complete")) + assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) < 1500) + } + @Test fun `large output is drained without unbounded retention`() { + val result = RootProcessRunner.run(listOf("sh", "-c", "yes x | head -c 1048576"), 3000, 4096) + assertEquals(0, result.code) + assertEquals(4096, result.output.length) + } + @Test fun `failure status and diagnostic output survive`() { + val result = RootProcessRunner.run(listOf("sh", "-c", "echo lock-unavailable >&2; exit 4"), 1000) + assertEquals(4, result.code) + assertTrue(result.output.contains("lock-unavailable")) + } +}