app: parse the Shizuku dumps into link.ra_source and sec.arp_watch

Pure-Kotlin parsers over the captures the battery already makes, tested
against the real archived dumps from both devices - including the
Lenovo's 1000000015 route tables (table ids stay strings), the OnePlus's
stray uid=2000 prefix and mid-line hoplimit, and an ip_monitor that only
ever said EXEC_TIMEOUT. ShizukuProbe now returns three tests; a failed
capture is SKIPPED with its reason so no test type silently vanishes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-02 13:34:52 +02:00
co-authored by Claude Opus 5
parent 515a6aef04
commit 3214cc877a
6 changed files with 647 additions and 17 deletions
@@ -451,19 +451,25 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
remainingMs -= p.estimatedMs remainingMs -= p.estimatedMs
} }
// Shizuku shell tier — self-degrades to UNSUPPORTED when Shizuku isn't running. // Shizuku shell tier — self-degrades to UNSUPPORTED when Shizuku isn't running. One
// battery, three tests: the raw captures plus the parsed ra_source/arp_watch views.
step("link.ip_monitor (shizuku)", done = probes.size, total = totalSteps, etaMs = shizukuEstimateMs) step("link.ip_monitor (shizuku)", done = probes.size, total = totalSteps, etaMs = shizukuEstimateMs)
val shizukuTest = try { val shizukuTests = try {
ShizukuProbe().run(ctx, ids::uuid, ids::monoNs) ShizukuProbe().run(ctx, ids::uuid, ids::monoNs)
} catch (t: Throwable) { } catch (t: Throwable) {
Test( listOf(Test(
id = ids.uuid(), type = TestType.LINK_IP_MONITOR, tier = Tier.SHIZUKU, id = ids.uuid(), type = TestType.LINK_IP_MONITOR, tier = Tier.SHIZUKU,
startedMonoNs = ids.monoNs(), endedMonoNs = ids.monoNs(), startedMonoNs = ids.monoNs(), endedMonoNs = ids.monoNs(),
status = TestStatus.FAILED, error = TestError("uncaught", t.message ?: t.javaClass.simpleName), status = TestStatus.FAILED, error = TestError("uncaught", t.message ?: t.javaClass.simpleName),
) ))
}
tests.addAll(shizukuTests); collected.addAll(shizukuTests)
// "Shizuku tier ran" is the battery's verdict — the derived tests can be PARTIAL on a
// perfectly healthy shell tier (e.g. an RA-less v4-only link).
runShizukuOk = shizukuTests.any {
it.type == TestType.LINK_IP_MONITOR &&
(it.status == TestStatus.OK || it.status == TestStatus.PARTIAL)
} }
tests.add(shizukuTest); collected.add(shizukuTest)
runShizukuOk = shizukuTest.status == TestStatus.OK || shizukuTest.status == TestStatus.PARTIAL
return buildDocument(tests) return buildDocument(tests)
} }
@@ -32,4 +32,8 @@ dependencies {
implementation(libs.shizuku.provider) implementation(libs.shizuku.provider)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
// JVM unit tests for the pure dump parsers (DumpParsers.kt) against the archived
// vendor fixtures — no device, no Android runtime.
testImplementation(libs.kotlin.test.junit)
testImplementation(libs.junit4)
} }
@@ -0,0 +1,174 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.shizuku
/**
* Parsed view of one IPv6 default route from `ip -6 route show table all`.
*
* [table] stays a string: Android's per-network route tables use ids past Int range (the Lenovo
* TB330FU prints `table 1000000015`), and `table local` is not a number at all — parsing to a
* numeric type either overflows or silently drops rows, and the id is only ever compared, never
* computed with.
*/
data class V6DefaultRoute(
/** Link-local address of the advertising router; null for gateway-less defaults (dummy0). */
val gateway: String?,
val dev: String,
val table: String?, // null = main table (`ip` omits the token there)
val proto: String?, // "ra" marks a route installed from a Router Advertisement
val metric: Long?,
/** Remaining RA route lifetime (`expires NNNsec`); null when the route does not age out. */
val expiresSec: Long?,
)
/** One `ip neigh show` row. [lladdr] is null for FAILED/INCOMPLETE entries — the kernel tried to
* resolve and has nothing, which is itself signal. */
data class NeighborEntry(
val ip: String,
val dev: String?,
val lladdr: String?,
val state: String?, // REACHABLE/STALE/FAILED/... — kept verbatim, the kernel's vocabulary
val router: Boolean,
)
/** A NEIGH transition seen inside the `ip monitor` window. */
data class NeighborEvent(val entry: NeighborEntry, val deleted: Boolean)
/**
* Pure-string parsers for the shell battery's `ip` command outputs. No Android imports on
* purpose: these run (and are unit-tested) on the JVM against the real vendor dumps archived
* from the prober, which is the only way to catch a vendor format drift before it ships.
*
* All parsers degrade to an empty result on missing or unrecognized input — the battery's
* captures are best-effort (the Lenovo's `ip monitor` times out under newProcess, the
* UserService path prepends a stray `uid=2000` line, evidence strings are trimmed mid-line
* at 1200 chars), so an exception here would turn a degraded capture into a lost test.
*/
object DumpParsers {
/** True when [raw] is real command output rather than an executor error sentinel. */
fun captureUsable(raw: String?): Boolean {
if (raw.isNullOrBlank()) return false
val t = raw.trimStart()
return !t.startsWith("SHIZUKU_") && !t.startsWith("EXEC_") && !t.startsWith("NEWPROCESS_")
}
/** Extracts every `default …` route from `ip -6 route show table all` output. */
fun parseV6DefaultRoutes(raw: String?): List<V6DefaultRoute> {
if (!captureUsable(raw)) return emptyList()
val routes = ArrayList<V6DefaultRoute>()
for (line in raw!!.lineSequence()) {
val tok = line.trim().split(WS)
if (tok.firstOrNull() != "default") continue
var gateway: String? = null; var dev: String? = null; var table: String? = null
var proto: String? = null; var metric: Long? = null; var expires: Long? = null
var i = 1
while (i < tok.size - 1) {
when (tok[i]) {
"via" -> gateway = tok[i + 1]
"dev" -> dev = tok[i + 1]
"table" -> table = tok[i + 1]
"proto" -> proto = tok[i + 1]
"metric" -> metric = tok[i + 1].toLongOrNull()
// `expires 1269sec` — the unit is glued to the number.
"expires" -> expires = tok[i + 1].removeSuffix("sec").toLongOrNull()
}
i++
}
// A default route without a device is not something `ip` prints; treat it as a
// truncated/garbled line rather than fabricating a partial route.
if (dev != null) routes.add(V6DefaultRoute(gateway, dev, table, proto, metric, expires))
}
return routes
}
/**
* Maps interface name → link-layer address from `ip addr show`. Only `link/ether` counts:
* loopback/ipip/gre pseudo-addresses are not identities, and the RA-source cross-reference
* this feeds compares Ethernet MACs.
*/
fun parseInterfaceMacs(raw: String?): Map<String, String> {
if (!captureUsable(raw)) return emptyMap()
val macs = LinkedHashMap<String, String>()
var current: String? = null
for (line in raw!!.lineSequence()) {
val header = STANZA_HEADER.find(line)
if (header != null) {
// "5: tunl0@NONE:" — the name is the part before an optional @suffix.
current = header.groupValues[1].substringBefore('@')
continue
}
val dev = current ?: continue
val tok = line.trim().split(WS)
if (tok.size >= 2 && tok[0] == "link/ether" && MAC.matches(tok[1])) {
macs.putIfAbsent(dev, tok[1])
}
}
return macs
}
/** Parses `ip neigh show` output into entries; non-neighbor lines (uid noise) are skipped. */
fun parseNeighbors(raw: String?): List<NeighborEntry> {
if (!captureUsable(raw)) return emptyList()
return raw!!.lineSequence()
.mapNotNull { parseNeighborTokens(it.trim().split(WS)) }
.toList()
}
/**
* Extracts NEIGH transitions from an `ip monitor all` capture. Each event line carries a
* `[NEIGH]` label (other families — ROUTE, ADDR, LINK — are ignored) and deletions are
* printed as `Deleted <entry>`. An empty result is normal: a quiet 5 s window sees nothing.
*/
fun parseNeighborEvents(raw: String?): List<NeighborEvent> {
if (!captureUsable(raw)) return emptyList()
val events = ArrayList<NeighborEvent>()
for (line in raw!!.lineSequence()) {
val m = MONITOR_LABEL.find(line.trim()) ?: continue
if (!m.groupValues[1].equals("NEIGH", ignoreCase = true)) continue
var rest = line.trim().removeRange(m.range).trim()
val deleted = rest.startsWith("Deleted ", ignoreCase = true)
if (deleted) rest = rest.substring("Deleted ".length)
parseNeighborTokens(rest.split(WS))?.let { events.add(NeighborEvent(it, deleted)) }
}
return events
}
/**
* (ip → lladdr) for every neighbor that has one. This is the comparison surface the future
* gateway-MAC-change finding diffs across runs, so it is computed here — in the tested,
* pure layer — rather than re-derived from JSON by each consumer.
*/
fun lladdrByIp(neighbors: List<NeighborEntry>): Map<String, String> =
neighbors.mapNotNull { n -> n.lladdr?.let { n.ip to it } }.toMap()
/** One neighbor row: `<ip> dev <if> [lladdr <mac>] [router] [proxy] <STATE>`. */
private fun parseNeighborTokens(tok: List<String>): NeighborEntry? {
val ip = tok.firstOrNull() ?: return null
// The first token must look like an address — this is what drops the UserService path's
// stray "uid=2000" line and any grep noise without needing to know every noise shape.
if (!IP_LIKE.matches(ip) || (!ip.contains('.') && !ip.contains(':'))) return null
var dev: String? = null; var lladdr: String? = null; var state: String? = null
var router = false
var i = 1
while (i < tok.size) {
when (tok[i]) {
"dev" -> { dev = tok.getOrNull(i + 1); i++ }
"lladdr" -> { lladdr = tok.getOrNull(i + 1); i++ }
"router" -> router = true
"proxy" -> {} // recorded nowhere: proxy entries have no bearing on ARP watching
else -> if (STATE.matches(tok[i])) state = tok[i]
}
i++
}
return NeighborEntry(ip, dev, lladdr, state, router)
}
private val WS = Regex("\\s+")
private val STANZA_HEADER = Regex("^\\d+:\\s+([^:\\s]+):")
private val MAC = Regex("^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$")
private val IP_LIKE = Regex("^[0-9a-fA-F:.]+(%[\\w-]+)?$")
private val STATE = Regex("^(REACHABLE|STALE|DELAY|PROBE|FAILED|INCOMPLETE|PERMANENT|NOARP|NONE)$")
private val MONITOR_LABEL = Regex("^\\[(\\w+)]")
}
@@ -10,15 +10,24 @@ import app.echo_lot.measurement.TestType
import app.echo_lot.measurement.Tier import app.echo_lot.measurement.Tier
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import kotlinx.serialization.json.addJsonObject
/** /**
* The Shizuku shell-tier probe: runs the privileged command battery (neighbor table, RA routes * The Shizuku shell-tier probe: runs the privileged command battery (neighbor table, RA routes
* with lifetimes, netlink monitor, IpClient DHCP logs, wifi dump) that the app UID cannot, and * with lifetimes, netlink monitor, IpClient DHCP logs, wifi dump) that the app UID cannot, and
* captures the real per-device dump formats the production parsers must handle. Emitted as a * captures the real per-device dump formats the production parsers must handle. Emits three
* shizuku-tier `link.ip_monitor` test (the representative shell-tier link test); `exec_path` * shizuku-tier tests from the one battery:
* records whether the UserService or the newProcess fallback carried it. * - `link.ip_monitor` — the raw captures (the shell tier's ground truth), `exec_path` records
* whether the UserService or the newProcess fallback carried it;
* - `link.ra_source` — parsed from the v6 route table + `ip addr`: who advertises IPv6 here;
* - `sec.arp_watch` — parsed from the neighbor table + monitor window: (ip → lladdr) pairs for
* gateway-MAC-change detection.
* The battery runs once; the derived tests parse its captures, so they share its time window.
*/ */
class ShizukuProbe { class ShizukuProbe {
val type = TestType.LINK_IP_MONITOR val type = TestType.LINK_IP_MONITOR
@@ -34,24 +43,34 @@ class ShizukuProbe {
"wifi_dump" to "dumpsys wifi 2>/dev/null | grep -iA1 -m 20 -e 'mDhcpResults' -e 'Gateway' -e 'DNS' || true", "wifi_dump" to "dumpsys wifi 2>/dev/null | grep -iA1 -m 20 -e 'mDhcpResults' -e 'Gateway' -e 'DNS' || true",
) )
/** Runs the battery and returns a Test. [uuid]/[monoNs] come from the run's id/clock source. */ /**
suspend fun run(context: Context, uuid: () -> String, monoNs: () -> Long): Test = withContext(Dispatchers.IO) { * Runs the battery and returns the three tests, battery first. [uuid]/[monoNs] come from the
val id = uuid() * run's id/clock source. When the shell tier is unavailable all three come back UNSUPPORTED —
* one silent test would leave the other two types missing from the document, which reads as
* "never attempted" rather than "tier absent".
*/
suspend fun run(context: Context, uuid: () -> String, monoNs: () -> Long): List<Test> = withContext(Dispatchers.IO) {
val started = monoNs() val started = monoNs()
val runner = ShizukuRunner(context) val runner = ShizukuRunner(context)
val st = runner.status() val st = runner.status()
fun envelope(status: TestStatus, evidence: kotlinx.serialization.json.JsonObject, metrics: kotlinx.serialization.json.JsonObject? = null) = fun envelope(type: String, status: TestStatus, evidence: JsonObject, metrics: JsonObject? = null) =
Test(id = id, type = type, tier = tier, startedMonoNs = started, endedMonoNs = monoNs(), Test(id = uuid(), type = type, tier = tier, startedMonoNs = started, endedMonoNs = monoNs(),
status = status, evidence = evidence, metrics = metrics) status = status, evidence = evidence, metrics = metrics)
fun allUnsupported(evidence: JsonObject) = listOf(
envelope(TestType.LINK_IP_MONITOR, TestStatus.UNSUPPORTED, evidence),
envelope(TestType.LINK_RA_SOURCE, TestStatus.UNSUPPORTED, evidence),
envelope(TestType.SEC_ARP_WATCH, TestStatus.UNSUPPORTED, evidence),
)
if (!st.binderAlive) { if (!st.binderAlive) {
return@withContext envelope(TestStatus.UNSUPPORTED, buildJsonObject { return@withContext allUnsupported(buildJsonObject {
put("binder_alive", false); put("detail", "Shizuku not running") put("binder_alive", false); put("detail", "Shizuku not running")
}) })
} }
if (!st.permissionGranted && !runner.requestPermission()) { if (!st.permissionGranted && !runner.requestPermission()) {
return@withContext envelope(TestStatus.UNSUPPORTED, buildJsonObject { return@withContext allUnsupported(buildJsonObject {
put("binder_alive", true); put("permission", false) put("binder_alive", true); put("permission", false)
}) })
} }
@@ -77,6 +96,125 @@ class ShizukuProbe {
ok >= 1 -> TestStatus.PARTIAL ok >= 1 -> TestStatus.PARTIAL
else -> TestStatus.FAILED else -> TestStatus.FAILED
} }
envelope(status, evidence, metrics) listOf(
envelope(type, status, evidence, metrics),
raSourceTest(batch, ::envelope),
arpWatchTest(batch, ::envelope),
)
}
/** `link.ra_source` from the battery's `ip -6 route` / `ip addr` / `ip neigh` captures. */
private fun raSourceTest(
batch: ShizukuRunner.BatchResult,
envelope: (String, TestStatus, JsonObject, JsonObject?) -> Test,
): Test {
val routeRaw = batch.results["ip6_route"]
if (!DumpParsers.captureUsable(routeRaw)) {
// The source command failed (executor sentinel or empty) — say so instead of
// presenting "no default routes" as a measurement of the network.
return envelope(TestType.LINK_RA_SOURCE, TestStatus.SKIPPED, buildJsonObject {
put("exec_path", batch.execPath)
put("reason", "ip -6 route capture unavailable: ${(routeRaw ?: "absent").take(80)}")
}, null)
}
val routes = DumpParsers.parseV6DefaultRoutes(routeRaw)
val macs = DumpParsers.parseInterfaceMacs(batch.results["ip_addr"])
// The RA sender's own identity: its link-local gateway address resolved through the
// neighbor table gives the router's MAC, which is what survives address renumbering.
val neighMacs = DumpParsers.lladdrByIp(DumpParsers.parseNeighbors(batch.results["ip_neigh"]))
val evidence = buildJsonObject {
put("exec_path", batch.execPath)
putJsonArray("default_routes") {
for (r in routes) addJsonObject {
r.gateway?.let { put("gateway", it) }
put("dev", r.dev)
r.table?.let { put("table", it) }
r.proto?.let { put("proto", it) }
r.metric?.let { put("metric", it) }
r.expiresSec?.let { put("expires_sec", it) }
r.gateway?.let { gw -> neighMacs[gw]?.let { put("gateway_lladdr", it) } }
}
}
putJsonObject("interface_mac") {
// Only interfaces that actually carry a default route: the full MAC inventory
// belongs to the raw capture, not to this test's claim.
for (dev in routes.map { it.dev }.distinct()) macs[dev]?.let { put(dev, it) }
}
}
val metrics = buildJsonObject {
put("routes_total", routes.size)
put("routes_ra", routes.count { it.proto == "ra" })
}
val status = when {
routes.any { it.proto == "ra" } -> TestStatus.OK
// Routes parsed but none RA-installed, or a capture we couldn't parse a single
// default from: could be a genuinely RA-less link, could be vendor format drift —
// PARTIAL keeps it visible either way instead of quietly claiming success.
else -> TestStatus.PARTIAL
}
return envelope(TestType.LINK_RA_SOURCE, status, evidence, metrics)
}
/** `sec.arp_watch` from the battery's `ip neigh` snapshot + `ip monitor` window. */
private fun arpWatchTest(
batch: ShizukuRunner.BatchResult,
envelope: (String, TestStatus, JsonObject, JsonObject?) -> Test,
): Test {
val neighRaw = batch.results["ip_neigh"]
val monitorRaw = batch.results["ip_monitor"]
val neighUsable = DumpParsers.captureUsable(neighRaw)
// The monitor window is best-effort (EXEC_TIMEOUT under newProcess on the Lenovo); the
// snapshot alone still yields the (ip → lladdr) pairs the MAC-change finding diffs.
val monitorRan = DumpParsers.captureUsable(monitorRaw)
if (!neighUsable && !monitorRan) {
return envelope(TestType.SEC_ARP_WATCH, TestStatus.SKIPPED, buildJsonObject {
put("exec_path", batch.execPath)
put("reason", "ip neigh capture unavailable: ${(neighRaw ?: "absent").take(80)}")
}, null)
}
val neighbors = DumpParsers.parseNeighbors(neighRaw)
val events = DumpParsers.parseNeighborEvents(monitorRaw)
val evidence = buildJsonObject {
put("exec_path", batch.execPath)
putJsonArray("neighbors") {
for (n in neighbors) addJsonObject {
put("ip", n.ip)
n.dev?.let { put("dev", it) }
n.lladdr?.let { put("lladdr", it) }
n.state?.let { put("state", it) }
if (n.router) put("router", true)
}
}
// The comparison surface, precomputed: a MAC-change finding diffs this map between
// runs without re-walking the neighbor array.
putJsonObject("lladdr_by_ip") {
for ((ip, mac) in DumpParsers.lladdrByIp(neighbors)) put(ip, mac)
}
put("monitor_ran", monitorRan)
if (!monitorRan) put("monitor_reason", (monitorRaw ?: "absent").take(80))
putJsonArray("monitor_events") {
for (e in events) addJsonObject {
put("ip", e.entry.ip)
e.entry.dev?.let { put("dev", it) }
e.entry.lladdr?.let { put("lladdr", it) }
e.entry.state?.let { put("state", it) }
if (e.deleted) put("deleted", true)
}
}
}
val metrics = buildJsonObject {
put("neighbors_total", neighbors.size)
put("neighbors_with_lladdr", neighbors.count { it.lladdr != null })
put("monitor_events", events.size)
}
val status = when {
neighbors.isNotEmpty() -> TestStatus.OK
// A snapshot that parsed to nothing (or a monitor-only capture) is thin evidence:
// usable command output with zero entries is unusual enough to flag, not to fail.
else -> TestStatus.PARTIAL
}
return envelope(TestType.SEC_ARP_WATCH, status, evidence, metrics)
} }
} }
@@ -0,0 +1,303 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.shizuku
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The fixtures below are the REAL shell-battery captures from the two archived prober reports
* (echolot-prober/reports/CPH2747-android16-sdk36-build5.json — OnePlus 15, UserService path;
* TB330FU-android15-sdk35-build5.json — Lenovo TB330FU, newProcess fallback), trimmed to the
* relevant lines but otherwise verbatim. That includes their warts on purpose: the UserService
* path's stray `uid=2000` first line, the 1200-char evidence trim cutting the last line mid-word,
* the Lenovo's 10-digit route table ids and its `EXEC_TIMEOUT(newProcess)` monitor sentinel.
* A parser that only survives clean textbook output has not been tested.
*/
class DumpParsersTest {
// ---- OnePlus 15 (CPH2747, Android 16) — UserService exec path ----
private val onePlusIp6Route = """
uid=2000
fe80::/64 dev wlan0 table 1028 proto kernel metric 256 pref medium
fe80::/64 dev wlan0 table 1028 proto static metric 1024 pref medium
default via fe80::7a9a:18ff:fe54:b8f9 dev wlan0 table 1028 proto ra metric 1024 expires 1269sec pref medium
fe80::/64 dev vgate0 table 1031 proto kernel metric 256 pref medium
2001:4bb8:417:bd78::/64 dev rmnet_data4 table 1032 proto kernel metric 256 pref medium
2001:4bb8:417:bd78::/64 dev rmnet_data4 table 1032 proto static metric 1024 pref medium
fe80::/64 dev rmnet_data4 table 1032 proto kernel metric 256 pref medium
default via fe80::246f:12be:21ef:1b54 dev rmnet_data4 table 1032 proto ra metric 1024 expires 64373sec hoplimit 255 pref medium
2001:4bb8:2fb:fe4c::/64 dev rmnet_data2 table 1000000022 proto static metric 1024 pref medium
fe80::/64 dev wlan0 table 1000000028 proto static metric 1024 pref medium
2001:4bb8:417:bd78::/64 dev rmnet_data4 table 1000000032 proto static metric 1024 pref medium
fe80::/64 dev dummy0 table 1002 proto kernel metric 256 pref medium
default dev dummy0 table 1002 proto static metric 1024 pref medium
fe80::/64 dev ifb0 table 1003 proto kernel metric 256 pref medium
fe80::/64 dev ifb1 table 1004 proto kerne
""".trimIndent()
private val onePlusIpNeigh = """
uid=2000
10.13.102.111 dev wlan0 FAILED
10.13.102.50 dev wlan0 lladdr 50:57:9c:4f:7a:3c STALE
10.13.102.31 dev wlan0 lladdr 98:5f:d3:f6:f1:75 STALE
10.13.102.116 dev wlan0 lladdr 0c:08:b4:03:68:0e STALE
10.13.102.120 dev wlan0 lladdr 0e:d8:14:58:6c:8b STALE
10.13.102.5 dev wlan0 lladdr 90:09:d0:1a:83:e4 STALE
10.13.102.1 dev wlan0 lladdr 78:9a:18:54:b8:f9 REACHABLE
10.13.102.21 dev wlan0 lladdr c8:7f:54:01:94:7c STALE
fe80::babe:f4ff:febc:caf9 dev wlan0 lladdr b8:be:f4:bc:ca:f9 REACHABLE
fe80::7a9a:18ff:fe54:b8f9 dev wlan0 lladdr 78:9a:18:54:b8:f9 router STALE
fe80::babe:f4ff:febc:cacf dev wlan0 lladdr b8:be:f4:bc:ca:cf REACHABLE
fe80::babe:f4ff:fec2:bf14 dev wlan0 lladdr b8:be:f4:c2:bf:14 REACHABLE
""".trimIndent()
// The 1200-char trim cut this capture off before wlan0's stanza — so the real archived
// evidence has NO MAC for the interface that carries the default route. The parser must
// yield what is there and nothing else; the probe records the gap instead of inventing one.
private val onePlusIpAddr = """
uid=2000
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: dummy0: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN group default qlen 1000
link/ether be:3d:e2:93:78:b9 brd ff:ff:ff:ff:ff:ff
inet6 fe80::bc3d:e2ff:fe93:78b9/64 scope link
valid_lft forever preferred_lft forever
3: ifb0: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc htb state UNKNOWN group default qlen 1000
link/ether ba:6e:46:b5:3d:bb brd ff:ff:ff:ff:ff:ff
4: ifb1: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc htb state UNKNOWN group default qlen 1000
link/ether d6:2a:e2:f5:93:8f brd ff:ff:ff:ff:ff:ff
5: tunl0@NONE: <NOARP> mtu 1480 qdisc noop state DOWN group default qlen 1000
link/ipip 0.0.0.0 brd 0.0.0.0
6: gre0@NONE: <NO
""".trimIndent()
// A monitor window that ran but saw nothing: the capture is "usable", just empty of events.
private val onePlusIpMonitor = "uid=2000"
// ---- Lenovo TB330FU (Android 15) — newProcess fallback ----
private val lenovoIp6Route = """
fe80::/64 dev wlan0 table 1000000015 proto static metric 1024 pref medium
fe80::/64 dev dummy0 table 1002 proto kernel metric 256 pref medium
default dev dummy0 table 1002 proto static metric 1024 pref medium
fe80::/64 dev wlan0 table 1015 proto kernel metric 256 pref medium
fe80::/64 dev wlan0 table 1015 proto static metric 1024 pref medium
default via fe80::7a9a:18ff:fe54:b8f9 dev wlan0 table 1015 proto ra metric 1024 expires 1622sec pref medium
local ::1 dev lo table local proto kernel metric 0 pref medium
local fe80::416:b9ff:feac:5b65 dev wlan0 table local proto kernel metric 0 pref medium
local fe80::1450:43ff:feec:93c4 dev dummy0 table local proto kernel metric 0 pref medium
multicast ff00::/8 dev dummy0 table local proto kernel metric 256 pref medium
multicast ff00::/8 dev wlan0 table local proto kernel metric 256 pref medium
""".trimIndent()
private val lenovoIpNeigh = """
10.13.102.21 dev wlan0 lladdr c8:7f:54:01:94:7c STALE
10.13.102.64 dev wlan0 lladdr b8:be:f4:c2:bf:14 STALE
10.13.102.5 dev wlan0 lladdr 90:09:d0:1a:83:e4 STALE
10.13.102.1 dev wlan0 lladdr 78:9a:18:54:b8:f9 STALE
10.13.102.79 dev wlan0 lladdr 02:11:32:25:63:bb STALE
fe80::babe:f4ff:febc:cacf dev wlan0 lladdr b8:be:f4:bc:ca:cf STALE
fe80::7a9a:18ff:fe54:b8f9 dev wlan0 lladdr 78:9a:18:54:b8:f9 router STALE
fe80::babe:f4ff:fec2:bf14 dev wlan0 lladdr b8:be:f4:c2:bf:14 STALE
fe80::babe:f4ff:febc:caf9 dev wlan0 lladdr b8:be:f4:bc:ca:f9 STALE
""".trimIndent()
private val lenovoIpAddr = """
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
2: dummy0: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN group default qlen 1000
link/ether 16:50:43:ec:93:c4 brd ff:ff:ff:ff:ff:ff
inet6 fe80::1450:43ff:feec:93c4/64 scope link
valid_lft forever preferred_lft forever
3: ifb0: <BROADCAST,NOARP> mtu 1500 qdisc noop state DOWN group default qlen 32
link/ether f6:d4:d4:9b:51:9c brd ff:ff:ff:ff:ff:ff
4: ifb1: <BROADCAST,NOARP> mtu 1500 qdisc noop state DOWN group default qlen 32
link/ether fe:16:ea:60:a2:d1 brd ff:ff:ff:ff:ff:ff
5: tunl0@NONE: <NOARP> mtu 1480 qdisc noop state DOWN group default qlen 1000
link/ipip 0.0.0.0 brd 0.0.0.0
7: gretap0@NONE: <BROADCAST,MULTICAST> mtu 1462 qdisc noop state DOWN group default qlen 1000
link/ether 00:00:00:00:00:00 brd ff:ff:ff:ff:f
""".trimIndent()
// On the Lenovo the 5 s monitor window exceeds the newProcess exec timeout — the executor's
// sentinel is all we get, and the arp_watch test must still stand on the snapshot alone.
private val lenovoIpMonitor = "EXEC_TIMEOUT(newProcess)"
// ---- link.ra_source: v6 default routes ----
@Test
fun onePlusDefaultRoutesParsed() {
val routes = DumpParsers.parseV6DefaultRoutes(onePlusIp6Route)
assertEquals(3, routes.size)
val wlan = routes.single { it.dev == "wlan0" }
assertEquals("fe80::7a9a:18ff:fe54:b8f9", wlan.gateway)
assertEquals("1028", wlan.table)
assertEquals("ra", wlan.proto)
assertEquals(1024L, wlan.metric)
assertEquals(1269L, wlan.expiresSec)
// The cellular default: `hoplimit 255` sits between expires and pref and must not derail
// the token walk.
val rmnet = routes.single { it.dev == "rmnet_data4" }
assertEquals("fe80::246f:12be:21ef:1b54", rmnet.gateway)
assertEquals("1032", rmnet.table)
assertEquals(64373L, rmnet.expiresSec)
// Android's gateway-less dummy0 default is a real route; it is the proto that tells a
// consumer it is not an RA.
val dummy = routes.single { it.dev == "dummy0" }
assertNull(dummy.gateway)
assertEquals("static", dummy.proto)
assertNull(dummy.expiresSec)
}
@Test
fun lenovoNumberedTablesAllCaptured() {
val routes = DumpParsers.parseV6DefaultRoutes(lenovoIp6Route)
// Two default routes: the RA one in table 1015 and the dummy0 one in 1002. The 10-digit
// table 1000000015 and the `table local` rows carry no default and must neither appear
// nor break parsing.
assertEquals(setOf("1002", "1015"), routes.map { it.table }.toSet())
val ra = routes.single { it.proto == "ra" }
assertEquals("fe80::7a9a:18ff:fe54:b8f9", ra.gateway)
assertEquals("wlan0", ra.dev)
assertEquals("1015", ra.table)
assertEquals(1622L, ra.expiresSec)
}
@Test
fun tenDigitTableIdOnADefaultRouteSurvives() {
// Not seen on a default route in the wild yet, but the Lenovo proves vendors put routes
// in tables past Int range — the day one holds a default, it must not overflow away.
val routes = DumpParsers.parseV6DefaultRoutes(
"default via fe80::1 dev wlan0 table 1000000015 proto ra metric 1024 expires 100sec pref medium"
)
assertEquals(1, routes.size)
assertEquals("1000000015", routes[0].table)
assertEquals(100L, routes[0].expiresSec)
}
// ---- link.ra_source: interface MACs ----
@Test
fun onePlusInterfaceMacsParsed() {
val macs = DumpParsers.parseInterfaceMacs(onePlusIpAddr)
assertEquals("be:3d:e2:93:78:b9", macs["dummy0"])
assertEquals("ba:6e:46:b5:3d:bb", macs["ifb0"])
// link/loopback and link/ipip are not identities.
assertFalse("lo" in macs)
assertFalse("tunl0" in macs)
// The capture is cut mid-stanza-header ("6: gre0@NONE: <NO") — no exception, no entry.
assertNull(macs["gre0"])
}
@Test
fun lenovoInterfaceMacsParsed() {
val macs = DumpParsers.parseInterfaceMacs(lenovoIpAddr)
assertEquals("16:50:43:ec:93:c4", macs["dummy0"])
assertEquals("fe:16:ea:60:a2:d1", macs["ifb1"])
// The @-suffixed stanza name resolves to the bare interface name.
assertEquals("00:00:00:00:00:00", macs["gretap0"])
}
// ---- sec.arp_watch: neighbor snapshot ----
@Test
fun onePlusNeighborsParsed() {
val n = DumpParsers.parseNeighbors(onePlusIpNeigh)
assertEquals(12, n.size) // the `uid=2000` noise line is not a neighbor
val failed = n.single { it.ip == "10.13.102.111" }
assertNull(failed.lladdr)
assertEquals("FAILED", failed.state)
assertEquals("wlan0", failed.dev)
val gw = n.single { it.ip == "10.13.102.1" }
assertEquals("78:9a:18:54:b8:f9", gw.lladdr)
assertEquals("REACHABLE", gw.state)
val v6gw = n.single { it.ip == "fe80::7a9a:18ff:fe54:b8f9" }
assertTrue(v6gw.router)
assertEquals("78:9a:18:54:b8:f9", v6gw.lladdr)
}
@Test
fun lenovoNeighborsParsed() {
val n = DumpParsers.parseNeighbors(lenovoIpNeigh)
assertEquals(9, n.size)
assertTrue(n.all { it.lladdr != null && it.state == "STALE" })
assertEquals(1, n.count { it.router })
}
@Test
fun lladdrByIpIsTheComparisonSurface() {
val pairs = DumpParsers.lladdrByIp(DumpParsers.parseNeighbors(onePlusIpNeigh))
// 12 neighbors, 11 with a MAC — the FAILED entry must drop out, or a diff against a
// later run would flag "null → MAC" as a gateway change.
assertEquals(11, pairs.size)
assertEquals("78:9a:18:54:b8:f9", pairs["10.13.102.1"])
assertFalse("10.13.102.111" in pairs)
}
// ---- sec.arp_watch: monitor window ----
@Test
fun monitorSentinelIsUnusableAndYieldsNoEvents() {
assertFalse(DumpParsers.captureUsable(lenovoIpMonitor))
assertTrue(DumpParsers.parseNeighborEvents(lenovoIpMonitor).isEmpty())
}
@Test
fun quietMonitorWindowIsUsableButEmpty() {
// OnePlus: the monitor ran (only the uid noise line came back) — "ran and saw nothing"
// must stay distinguishable from "never ran".
assertTrue(DumpParsers.captureUsable(onePlusIpMonitor))
assertTrue(DumpParsers.parseNeighborEvents(onePlusIpMonitor).isEmpty())
}
@Test
fun monitorNeighEventsParsedFromLabeledLines() {
// Synthetic, in `ip monitor all` label format — neither archived run caught a live
// transition, but the format is fixed by iproute2's print_neigh/print_headers.
val sample = """
[NEIGH]10.13.102.1 dev wlan0 lladdr 78:9a:18:54:b8:f9 REACHABLE
[NEIGH]Deleted 10.13.102.5 dev wlan0 lladdr 90:09:d0:1a:83:e4 STALE
[ROUTE]default via 10.13.102.1 dev wlan0 table 1015
[NEIGH]fe80::7a9a:18ff:fe54:b8f9 dev wlan0 lladdr 78:9a:18:54:b8:f9 router STALE
""".trimIndent()
val events = DumpParsers.parseNeighborEvents(sample)
assertEquals(3, events.size) // the ROUTE line belongs to a different family
assertEquals("10.13.102.1", events[0].entry.ip)
assertFalse(events[0].deleted)
assertTrue(events[1].deleted)
assertEquals("90:09:d0:1a:83:e4", events[1].entry.lladdr)
assertTrue(events[2].entry.router)
}
// ---- degradation: missing or garbage input ----
@Test
fun missingAndGarbageInputYieldsEmptyResultsNotExceptions() {
for (bad in listOf(null, "", " \n ", "EXEC_TIMEOUT(newProcess)", "SHIZUKU_BINDER_DEAD",
"NEWPROCESS_UNAVAILABLE", "total garbage\nno routes here at all\ndefault", "default")) {
assertTrue(DumpParsers.parseV6DefaultRoutes(bad).isEmpty(), "routes from: $bad")
assertTrue(DumpParsers.parseNeighbors(bad).isEmpty(), "neighbors from: $bad")
assertTrue(DumpParsers.parseInterfaceMacs(bad).isEmpty(), "macs from: $bad")
assertTrue(DumpParsers.parseNeighborEvents(bad).isEmpty(), "events from: $bad")
}
}
}
+5
View File
@@ -8,6 +8,7 @@ lifecycle = "2.8.7"
activityCompose = "1.9.3" activityCompose = "1.9.3"
composeBom = "2024.10.01" composeBom = "2024.10.01"
shizuku = "13.1.5" shizuku = "13.1.5"
junit4 = "4.13.2"
[libraries] [libraries]
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
@@ -23,6 +24,10 @@ androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
shizuku-api = { group = "dev.rikka.shizuku", name = "api", version.ref = "shizuku" } shizuku-api = { group = "dev.rikka.shizuku", name = "api", version.ref = "shizuku" }
# Android-module unit tests run on JUnit 4 (AGP's default); the JVM modules use kotlin("test")
# with the JUnit Platform instead — that helper isn't available under AGP 9's built-in Kotlin.
kotlin-test-junit = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit", version.ref = "kotlin" }
junit4 = { group = "junit", name = "junit", version.ref = "junit4" }
shizuku-provider = { group = "dev.rikka.shizuku", name = "provider", version.ref = "shizuku" } shizuku-provider = { group = "dev.rikka.shizuku", name = "provider", version.ref = "shizuku" }
[plugins] [plugins]