mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
fix(android): type long ASCII via fast guarded path to stop keystroke escape
A 4096-char corpus string exceeded the fast input cap and fell to the per-character driver path, which takes ~120s. During that uninterruptible window focus could leave the app and the remaining keystrokes sprayed into the launcher search box. Route shell-safe ASCII of any length through adb input text, chunked, re-checking the foreground app between chunks and stopping if it changed.
This commit is contained in:
1 parent
3f8c6018f6
commit
77aa6778c3
2 files changed
+95
-21
No files matched your search
@@ -549,12 +549,39 @@ class StubDriverBackend(
|
|||||||
override fun metrics(bundleId: String): MetricsSample = readProcMetrics(null, bundleId)
|
override fun metrics(bundleId: String): MetricsSample = readProcMetrics(null, bundleId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FAST_INPUT_SAFE matches text that can be typed with adb `input text`: short,
|
// FAST_INPUT_SAFE matches text that can be typed with adb `input text`: ASCII,
|
||||||
// ASCII, and free of shell metacharacters and spaces. Anything else (unicode,
|
// free of shell metacharacters and spaces, regardless of length. Anything else
|
||||||
// injection payloads, overflow-length strings) falls back to the driver path.
|
// (unicode, injection payloads, whitespace) falls back to the driver path. The
|
||||||
// The first character excludes '-' so the text can never be read as an option
|
// first character excludes '-' so the text can never be read as an option by
|
||||||
// by `input text`.
|
// `input text`. Length is unbounded on purpose: the slow per-character driver
|
||||||
internal val FAST_INPUT_SAFE = Regex("^[A-Za-z0-9@._+][A-Za-z0-9@._+-]{0,63}$")
|
// path takes ~120s for a 4096-char string (blowing the RPC deadline) and leaves
|
||||||
|
// focus unguarded long enough to spray keystrokes into the launcher search box
|
||||||
|
// if the app loses the foreground mid-type; the shell path types in chunks with
|
||||||
|
// a foreground re-check between them (see typeShellSafe).
|
||||||
|
internal val FAST_INPUT_SAFE = Regex("^[A-Za-z0-9@._+][A-Za-z0-9@._+-]*$")
|
||||||
|
|
||||||
|
// INPUT_CHUNK_CHARS bounds each `input text` shell invocation so a long string
|
||||||
|
// is typed as a series of short, interruptible commands rather than one opaque
|
||||||
|
// ~18s call. Small enough that a foreground re-check between chunks catches a
|
||||||
|
// focus escape early; large enough that the per-chunk dumpsys cost stays minor.
|
||||||
|
internal const val INPUT_CHUNK_CHARS = 512
|
||||||
|
|
||||||
|
// chunkForInput splits text into pieces of at most `size` characters, never
|
||||||
|
// ending a piece right before a '-': a chunk that began with '-' would be read
|
||||||
|
// as an option by `input text`. The whole string's first character is already
|
||||||
|
// guaranteed non-'-' by FAST_INPUT_SAFE, so the first chunk is always safe too.
|
||||||
|
internal fun chunkForInput(text: String, size: Int): List<String> {
|
||||||
|
require(size > 0)
|
||||||
|
val chunks = mutableListOf<String>()
|
||||||
|
var start = 0
|
||||||
|
while (start < text.length) {
|
||||||
|
var end = minOf(start + size, text.length)
|
||||||
|
while (end < text.length && text[end] == '-') end++
|
||||||
|
chunks.add(text.substring(start, end))
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
class MaestroDriverBackend(private val serial: String?) : DriverBackend {
|
class MaestroDriverBackend(private val serial: String?) : DriverBackend {
|
||||||
private val dadb: dadb.Dadb
|
private val dadb: dadb.Dadb
|
||||||
@@ -606,17 +633,45 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
|
|||||||
|
|
||||||
override fun inputText(text: String) {
|
override fun inputText(text: String) {
|
||||||
if (FAST_INPUT_SAFE.matches(text)) {
|
if (FAST_INPUT_SAFE.matches(text)) {
|
||||||
// adb `input text` is ~5x faster than the driver's per-character
|
// adb `input text` is far faster than the driver's per-character
|
||||||
// path. Restricted to short shell-safe ASCII so unicode, injection
|
// path. Restricted to shell-safe ASCII so unicode and injection
|
||||||
// payloads, and overflow-length strings still go through the driver,
|
// payloads still go through the driver, which handles them. The
|
||||||
// which handles them correctly. The runner focuses the field with a
|
// runner focuses the field with a tap before InputText, so the
|
||||||
// tap before InputText, so the keystrokes land in it.
|
// keystrokes land in it.
|
||||||
dadb.shell("input text $text")
|
typeShellSafe(text)
|
||||||
} else {
|
} else {
|
||||||
driver.inputText(text)
|
driver.inputText(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// typeShellSafe types shell-safe ASCII through adb `input text` in chunks,
|
||||||
|
// re-checking the foreground app before each chunk. If the app the type
|
||||||
|
// started in has lost the foreground, the remaining keystrokes would spray
|
||||||
|
// into whatever window stole it (the launcher search box, in practice), so
|
||||||
|
// typing stops instead of leaking out of the app under test.
|
||||||
|
private fun typeShellSafe(text: String) {
|
||||||
|
val owner = foregroundPackage()
|
||||||
|
var typed = 0
|
||||||
|
for (chunk in chunkForInput(text, INPUT_CHUNK_CHARS)) {
|
||||||
|
if (owner != null && typed > 0 && foregroundPackage() != owner) {
|
||||||
|
System.err.println(
|
||||||
|
"warn: inputText stopped; foreground left $owner mid-type after $typed/${text.length} chars",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dadb.shell("input text $chunk")
|
||||||
|
typed += chunk.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// foregroundPackage returns the package of the top resumed activity, or null
|
||||||
|
// if it cannot be read. Used to detect mid-type focus escapes.
|
||||||
|
private fun foregroundPackage(): String? {
|
||||||
|
val output = adbOutput(serial, listOf("shell", "dumpsys", "activity", "activities"))
|
||||||
|
return Regex("""topResumedActivity=ActivityRecord\{\S+ \S+ ([^/\s]+)/""")
|
||||||
|
.find(output)?.groupValues?.get(1)
|
||||||
|
}
|
||||||
|
|
||||||
override fun eraseText(characterCount: Int) = driver.eraseText(characterCount)
|
override fun eraseText(characterCount: Int) = driver.eraseText(characterCount)
|
||||||
|
|
||||||
override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) =
|
override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) =
|
||||||
|
|||||||
@@ -7,18 +7,20 @@ import kotlin.test.assertTrue
|
|||||||
|
|
||||||
class InputTextTest {
|
class InputTextTest {
|
||||||
|
|
||||||
// The fast `adb input text` path only handles short shell-safe ASCII. Common
|
// The fast `adb input text` path handles shell-safe ASCII of any length;
|
||||||
// app inputs take it; unicode, injection payloads, whitespace, and
|
// unicode, injection payloads, and whitespace must fall back to the driver,
|
||||||
// overflow-length strings must fall back to the driver, which types them
|
// which types them correctly. The overflow-length string (4096 a's) is pure
|
||||||
// correctly. A regression here would corrupt edge-case input or shell-inject
|
// ASCII and MUST take the fast path: the per-character driver path takes
|
||||||
// the device.
|
// ~120s for it, blowing the RPC deadline and leaving focus unguarded long
|
||||||
@Test fun fastInputPathAcceptsOnlyShellSafeAscii() {
|
// enough for keystrokes to spray into the launcher search box. A regression
|
||||||
for (safe in listOf("[email protected]", "ledger123", "Checking", "1e10", "0.0000001", "42")) {
|
// here would corrupt edge-case input or shell-inject the device.
|
||||||
assertTrue(FAST_INPUT_SAFE.matches(safe), "expected fast path for: $safe")
|
@Test fun fastInputPathAcceptsShellSafeAsciiOfAnyLength() {
|
||||||
|
for (safe in listOf("[email protected]", "ledger123", "Checking", "1e10", "0.0000001", "42", "a".repeat(4096))) {
|
||||||
|
assertTrue(FAST_INPUT_SAFE.matches(safe), "expected fast path for length ${safe.length}")
|
||||||
}
|
}
|
||||||
val fallback = listOf(
|
val fallback = listOf(
|
||||||
"Emergency Fund", "🙂🔥💸", " ", "\t\n", "'; DROP TABLE--",
|
"Emergency Fund", "🙂🔥💸", " ", "\t\n", "'; DROP TABLE--",
|
||||||
"<script>alert(1)</script>", "../../etc/passwd", "%s%n", "", "a".repeat(4096),
|
"<script>alert(1)</script>", "../../etc/passwd", "%s%n", "",
|
||||||
"-1", "-rf", // a leading dash could be read as an option by `input text`
|
"-1", "-rf", // a leading dash could be read as an option by `input text`
|
||||||
)
|
)
|
||||||
for (text in fallback) {
|
for (text in fallback) {
|
||||||
@@ -26,6 +28,23 @@ class InputTextTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// chunkForInput must split long input but never start a chunk with '-',
|
||||||
|
// which `input text` would read as an option flag.
|
||||||
|
@Test fun chunkForInputSplitsToSizeAndReassembles() {
|
||||||
|
val text = "a".repeat(4096)
|
||||||
|
val chunks = chunkForInput(text, 512)
|
||||||
|
assertEquals(text, chunks.joinToString(""))
|
||||||
|
assertTrue(chunks.all { it.length <= 512 }, "no chunk may exceed the size")
|
||||||
|
assertTrue(chunks.size >= 8, "4096/512 should be at least 8 chunks")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun chunkForInputNeverStartsAChunkWithDash() {
|
||||||
|
// a boundary that would fall on '-' is pushed past the dashes
|
||||||
|
val chunks = chunkForInput("ab--cd", 2)
|
||||||
|
assertEquals("ab--cd", chunks.joinToString(""))
|
||||||
|
assertTrue(chunks.drop(1).none { it.startsWith("-") }, "no later chunk may start with '-'")
|
||||||
|
}
|
||||||
|
|
||||||
// A logical key name must map to the right Android keycode; a typo'd table
|
// A logical key name must map to the right Android keycode; a typo'd table
|
||||||
// entry would silently dispatch the wrong key (e.g. 'back' issuing HOME).
|
// entry would silently dispatch the wrong key (e.g. 'back' issuing HOME).
|
||||||
@Test fun pressKeyDispatchesMappedKeycode() {
|
@Test fun pressKeyDispatchesMappedKeycode() {
|
||||||
|
|||||||
Reference in new issue
Block a user