diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt index f78caae..f82004b 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt @@ -451,19 +451,25 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { 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) - val shizukuTest = try { + val shizukuTests = try { ShizukuProbe().run(ctx, ids::uuid, ids::monoNs) } catch (t: Throwable) { - Test( + listOf(Test( id = ids.uuid(), type = TestType.LINK_IP_MONITOR, tier = Tier.SHIZUKU, startedMonoNs = ids.monoNs(), endedMonoNs = ids.monoNs(), 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) } diff --git a/echolot-app/core-shizuku/build.gradle.kts b/echolot-app/core-shizuku/build.gradle.kts index 5f3ada3..6d72ec0 100644 --- a/echolot-app/core-shizuku/build.gradle.kts +++ b/echolot-app/core-shizuku/build.gradle.kts @@ -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) } diff --git a/echolot-app/core-shizuku/src/main/kotlin/app/echo_lot/shizuku/DumpParsers.kt b/echolot-app/core-shizuku/src/main/kotlin/app/echo_lot/shizuku/DumpParsers.kt new file mode 100644 index 0000000..eba004c --- /dev/null +++ b/echolot-app/core-shizuku/src/main/kotlin/app/echo_lot/shizuku/DumpParsers.kt @@ -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 { + if (!captureUsable(raw)) return emptyList() + val routes = ArrayList() + 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 { + if (!captureUsable(raw)) return emptyMap() + val macs = LinkedHashMap() + 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 { + 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 `. An empty result is normal: a quiet 5 s window sees nothing. + */ + fun parseNeighborEvents(raw: String?): List { + if (!captureUsable(raw)) return emptyList() + val events = ArrayList() + 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): Map = + neighbors.mapNotNull { n -> n.lladdr?.let { n.ip to it } }.toMap() + + /** One neighbor row: ` dev [lladdr ] [router] [proxy] `. */ + private fun parseNeighborTokens(tok: List): 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+)]") +} diff --git a/echolot-app/core-shizuku/src/main/kotlin/app/echo_lot/shizuku/ShizukuProbe.kt b/echolot-app/core-shizuku/src/main/kotlin/app/echo_lot/shizuku/ShizukuProbe.kt index 23f5bc1..ff99ae2 100644 --- a/echolot-app/core-shizuku/src/main/kotlin/app/echo_lot/shizuku/ShizukuProbe.kt +++ b/echolot-app/core-shizuku/src/main/kotlin/app/echo_lot/shizuku/ShizukuProbe.kt @@ -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 = 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) } } diff --git a/echolot-app/core-shizuku/src/test/kotlin/app/echo_lot/shizuku/DumpParsersTest.kt b/echolot-app/core-shizuku/src/test/kotlin/app/echo_lot/shizuku/DumpParsersTest.kt new file mode 100644 index 0000000..e5e4d86 --- /dev/null +++ b/echolot-app/core-shizuku/src/test/kotlin/app/echo_lot/shizuku/DumpParsersTest.kt @@ -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: 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: 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: 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: 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: mtu 1480 qdisc noop state DOWN group default qlen 1000 + link/ipip 0.0.0.0 brd 0.0.0.0 + 6: gre0@NONE: 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: 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: 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: 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: mtu 1480 qdisc noop state DOWN group default qlen 1000 + link/ipip 0.0.0.0 brd 0.0.0.0 + 7: gretap0@NONE: 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: