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
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user