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:
co-authored by
Claude Opus 5
parent
515a6aef04
commit
3214cc877a
@@ -32,4 +32,8 @@ dependencies {
|
||||
implementation(libs.shizuku.provider)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
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
|
||||
* 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
|
||||
* shizuku-tier `link.ip_monitor` test (the representative shell-tier link test); `exec_path`
|
||||
* records whether the UserService or the newProcess fallback carried it.
|
||||
* captures the real per-device dump formats the production parsers must handle. Emits three
|
||||
* shizuku-tier tests from the one battery:
|
||||
* - `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 {
|
||||
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",
|
||||
)
|
||||
|
||||
/** 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) {
|
||||
val id = uuid()
|
||||
/**
|
||||
* Runs the battery and returns the three tests, battery first. [uuid]/[monoNs] come from the
|
||||
* 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 runner = ShizukuRunner(context)
|
||||
val st = runner.status()
|
||||
|
||||
fun envelope(status: TestStatus, evidence: kotlinx.serialization.json.JsonObject, metrics: kotlinx.serialization.json.JsonObject? = null) =
|
||||
Test(id = id, type = type, tier = tier, startedMonoNs = started, endedMonoNs = monoNs(),
|
||||
fun envelope(type: String, status: TestStatus, evidence: JsonObject, metrics: JsonObject? = null) =
|
||||
Test(id = uuid(), type = type, tier = tier, startedMonoNs = started, endedMonoNs = monoNs(),
|
||||
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) {
|
||||
return@withContext envelope(TestStatus.UNSUPPORTED, buildJsonObject {
|
||||
return@withContext allUnsupported(buildJsonObject {
|
||||
put("binder_alive", false); put("detail", "Shizuku not running")
|
||||
})
|
||||
}
|
||||
if (!st.permissionGranted && !runner.requestPermission()) {
|
||||
return@withContext envelope(TestStatus.UNSUPPORTED, buildJsonObject {
|
||||
return@withContext allUnsupported(buildJsonObject {
|
||||
put("binder_alive", true); put("permission", false)
|
||||
})
|
||||
}
|
||||
@@ -77,6 +96,125 @@ class ShizukuProbe {
|
||||
ok >= 1 -> TestStatus.PARTIAL
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user