From 1f8860f7f8a3e9313a35e1639aaa08547b2d27fc Mon Sep 17 00:00:00 2001 From: mrambossek Date: Fri, 31 Jul 2026 21:24:20 +0200 Subject: [PATCH] =?UTF-8?q?app:=20core-measurement=20=E2=80=94=20the=20mea?= =?UTF-8?q?surement-schema.md=20document=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure Kotlin/JVM, faithful to the schema contract: two-clock (wall RFC3339 + *_mono_ns), units in field names, observation/interpretation split (tests[] vs findings[]), columnar train evidence (nulls preserved per index), the full v1 test-type registry, the anonymization logical types as field notes, and a finding-requires-evidence invariant. The one piece with real logic — §7.3 deterministic verdict derivation (category = worst finding light; >50% failed/unsupported → inconclusive; overall = worst category, inconclusive only if all are) — is implemented in Verdicts and fully unit-tested. Document JSON round-trips (snake_case wire names, null-in-columns), typed builders for train/traceroute/resolver evidence. Co-Authored-By: Claude Opus 5 --- echolot-app/core-measurement/build.gradle.kts | 33 ++++ .../app/echo_lot/measurement/Document.kt | 100 ++++++++++++ .../app/echo_lot/measurement/Evidence.kt | 85 ++++++++++ .../app/echo_lot/measurement/Finding.kt | 68 ++++++++ .../app/echo_lot/measurement/Network.kt | 113 ++++++++++++++ .../app/echo_lot/measurement/Summary.kt | 90 +++++++++++ .../kotlin/app/echo_lot/measurement/Test.kt | 145 ++++++++++++++++++ .../echo_lot/measurement/SerializationTest.kt | 71 +++++++++ .../app/echo_lot/measurement/VerdictsTest.kt | 109 +++++++++++++ echolot-app/settings.gradle.kts | 1 + 10 files changed, 815 insertions(+) create mode 100644 echolot-app/core-measurement/build.gradle.kts create mode 100644 echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Document.kt create mode 100644 echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Evidence.kt create mode 100644 echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Finding.kt create mode 100644 echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Network.kt create mode 100644 echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Summary.kt create mode 100644 echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt create mode 100644 echolot-app/core-measurement/src/test/kotlin/app/echo_lot/measurement/SerializationTest.kt create mode 100644 echolot-app/core-measurement/src/test/kotlin/app/echo_lot/measurement/VerdictsTest.kt diff --git a/echolot-app/core-measurement/build.gradle.kts b/echolot-app/core-measurement/build.gradle.kts new file mode 100644 index 0000000..e1dd9eb --- /dev/null +++ b/echolot-app/core-measurement/build.gradle.kts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) +} + +// Pure Kotlin/JVM: the client half of probe-protocol.md. No Android deps, so +// the Android app modules can depend on it and it stays unit-testable (incl. +// live integration tests) on any JDK. Crypto, HTTP and UDP come from the JDK +// (javax.crypto, java.net.http, java.net) — only JSON needs a library. +dependencies { + implementation(libs.kotlinx.serialization.json) + testImplementation(kotlin("test")) +} + +kotlin { + // Build with the available JDK (Android Studio's JBR is 21) but emit + // Java-17 bytecode so the Android app modules can consume this library. + jvmToolchain(21) + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.test { + useJUnitPlatform() +} diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Document.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Document.kt new file mode 100644 index 0000000..c482607 --- /dev/null +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Document.kt @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package measurement models one measurement run (measurement-schema.md) — the archived, +// diffable, exportable unit. Design rules honored in the types: observation/interpretation +// separated (tests[] vs findings[]), two clocks (wall RFC3339 for humans, *_mono_ns for math), +// units in field names, columnar trains. params/evidence/metrics are per-test-type, so they are +// carried as JsonObject (the probe engine fills them; consumers ignore unknown fields). +package app.echo_lot.measurement + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +data class MeasurementDocument( + val schema: String = "echolot/measurement", + @SerialName("schema_version") val schemaVersion: String = "1.0.0", + val run: Run, + val networks: List = emptyList(), + @SerialName("server_sessions") val serverSessions: List = emptyList(), + val tests: List = emptyList(), + val findings: List = emptyList(), + val summary: Summary? = null, +) + +@Serializable +data class Run( + val id: String, // UUIDv7 + val trigger: Trigger, + @SerialName("started_at") val startedAt: String, // RFC3339 UTC, human correlation only + @SerialName("ended_at") val endedAt: String? = null, + val clock: Clock, + val app: AppInfo, + val device: DeviceInfo, + val tiers: Tiers, + @SerialName("profiles_used") val profilesUsed: List = emptyList(), + val notes: String? = null, +) + +@Serializable +enum class Trigger { + @SerialName("manual") MANUAL, + @SerialName("scheduled") SCHEDULED, + @SerialName("monitor") MONITOR, + @SerialName("peer") PEER, +} + +/** The two-clock anchor: mono_origin_wall maps the monotonic epoch to a wall time for humans; + * all math uses *_mono_ns relative to that monotonic origin. */ +@Serializable +data class Clock( + @SerialName("mono_origin_wall") val monoOriginWall: String, + @SerialName("ntp_offset_ms") val ntpOffsetMs: Double? = null, + @SerialName("ntp_offset_source") val ntpOffsetSource: String? = null, +) + +@Serializable +data class AppInfo( + val version: String, + val build: Int, + val git: String? = null, + val flavor: String? = null, +) + +@Serializable +data class DeviceInfo( + val manufacturer: String, + val model: String, + @SerialName("android_sdk") val androidSdk: Int, + @SerialName("android_release") val androidRelease: String, + @SerialName("security_patch") val securityPatch: String? = null, +) + +/** What each tier was *available*; each test records what it *used*. */ +@Serializable +data class Tiers( + val app: Boolean = true, + val shizuku: Boolean = false, + val root: Boolean = false, +) + +@Serializable +data class ServerSession( + val id: String, + @SerialName("profile_id") val profileId: String? = null, + @SerialName("profile_name") val profileName: String? = null, + @SerialName("control_url") val controlUrl: String, + @SerialName("server_version") val serverVersion: String? = null, + val capabilities: List = emptyList(), + @SerialName("session_id") val sessionId: String, + val target: SessionTarget, +) + +@Serializable +data class SessionTarget( + val ip4: String? = null, + val ip6: String? = null, + @SerialName("udp_port") val udpPort: Int = 0, +) diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Evidence.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Evidence.kt new file mode 100644 index 0000000..2580453 --- /dev/null +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Evidence.kt @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.measurement + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.encodeToJsonElement + +/** + * Typed builders for the per-test-type evidence shapes the schema fixes (§6.2/§6.3/§6.4). Probe + * code fills these and folds them into [Test.evidence] via [toEvidence]; keeping them typed here + * means the columnar/traceroute/resolver contracts live in one place. + */ + +@PublishedApi +internal val evidenceJson = Json { encodeDefaults = true; explicitNulls = true } + +/** Serialize any typed evidence object into the JsonObject the Test envelope carries. */ +inline fun T.toEvidence(): JsonObject = + evidenceJson.encodeToJsonElement(this) as JsonObject + +/** + * Packet-train evidence (§6.2): columnar parallel arrays, one index per probe packet. Missing + * observations are null at that index — a 10k-packet train stays in the hundreds of kB. Server + * columns use the server session epoch; only differences within one clock are meaningful unless a + * time.server_offset test maps them. + */ +@Serializable +data class TrainEvidence( + @SerialName("epoch_mono_ns") val epochMonoNs: Long, + val seq: List, + @SerialName("t_tx_ns") val tTxNs: List, + @SerialName("t_srv_rx_ns") val tSrvRxNs: List = emptyList(), + @SerialName("t_srv_tx_ns") val tSrvTxNs: List = emptyList(), + @SerialName("t_rx_ns") val tRxNs: List, + @SerialName("size_bytes") val sizeBytes: List, + @SerialName("dscp_sent") val dscpSent: Int? = null, + @SerialName("dscp_seen_by_server") val dscpSeenByServer: List = emptyList(), + @SerialName("ecn_sent") val ecnSent: Int? = null, + @SerialName("ecn_seen_by_server") val ecnSeenByServer: List = emptyList(), + @SerialName("ttl_seen_by_server") val ttlSeenByServer: List = emptyList(), + @SerialName("evidence_truncated") val evidenceTruncated: Boolean = false, +) + +/** Traceroute evidence (§6.3): fixed-tuple flow + per-TTL probe replies. */ +@Serializable +data class TracerouteEvidence(val flow: Flow, val hops: List) + +@Serializable +data class Flow( + @SerialName("src_port") val srcPort: Int, + @SerialName("dst_port") val dstPort: Int, + @SerialName("fixed_tuple") val fixedTuple: Boolean = true, +) + +@Serializable +data class Hop(val ttl: Int, val probes: List) + +@Serializable +data class HopProbe( + @SerialName("reply_from") val replyFrom: String? = null, + @SerialName("rtt_ns") val rttNs: Long? = null, + val icmp: String? = null, + @SerialName("reply_ttl") val replyTtl: Int? = null, +) + +/** Resolver under test (§6.4); every dns.* test carries this in params. */ +@Serializable +data class ResolverSpec( + val source: ResolverSource, + val address: String? = null, + val port: Int = 53, + val transport: String, // do53-udp | do53-tcp | dot | doh + @SerialName("doh_url") val dohUrl: String? = null, +) + +@Serializable +enum class ResolverSource { + @SerialName("system") SYSTEM, + @SerialName("manual") MANUAL, + @SerialName("server-recursive") SERVER_RECURSIVE, +} diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Finding.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Finding.kt new file mode 100644 index 0000000..5020313 --- /dev/null +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Finding.kt @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.measurement + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Interpretation with references back to evidence (measurement-schema.md §7.1). A finding with + * no evidence_refs is invalid — every finding must be re-derivable from the evidence alone. */ +@Serializable +data class Finding( + val id: String, // UUIDv7 + val code: String, // stable registry (findings-registry.md), lint-rule style + val category: Category, + val severity: Severity, + val confidence: Confidence, + @SerialName("network_ref") val networkRef: String? = null, + val title: String, + val description: String, + @SerialName("evidence_refs") val evidenceRefs: List, + val recommendation: String? = null, +) { + init { + require(evidenceRefs.isNotEmpty()) { "a finding must reference at least one piece of evidence" } + } +} + +@Serializable +data class EvidenceRef(val test: String, val pointer: String? = null) + +/** Fixed §7.2 categories; each maps to one traffic light. */ +@Serializable +enum class Category { + @SerialName("connectivity") CONNECTIVITY, + @SerialName("dns") DNS, + @SerialName("nat") NAT, + @SerialName("mtu") MTU, + @SerialName("ipv6") IPV6, + @SerialName("security") SECURITY, + @SerialName("performance") PERFORMANCE, + @SerialName("local") LOCAL, + @SerialName("wifi") WIFI, +} + +/** Ordered worst→best via [rank]; drives the §7.3 light mapping. */ +@Serializable +enum class Severity(val rank: Int) { + @SerialName("critical") CRITICAL(4), + @SerialName("high") HIGH(3), + @SerialName("medium") MEDIUM(2), + @SerialName("low") LOW(1), + @SerialName("info") INFO(0); + + /** §7.3: critical|high → red, medium|low → yellow, info → green. */ + fun toLight(): Verdict = when (this) { + CRITICAL, HIGH -> Verdict.RED + MEDIUM, LOW -> Verdict.YELLOW + INFO -> Verdict.GREEN + } +} + +@Serializable +enum class Confidence { + @SerialName("high") HIGH, + @SerialName("medium") MEDIUM, + @SerialName("low") LOW, +} diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Network.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Network.kt new file mode 100644 index 0000000..a432eb0 --- /dev/null +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Network.kt @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.measurement + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +/** One Android Network in play (measurement-schema.md §4). Shizuku-tier fields (route proto, + * lifetimes) are absent at app tier — absence means "not observed", never "not present". */ +@Serializable +data class Network( + val id: String, + val transport: Transport, + @SerialName("interface") val iface: String? = null, + val link: Link, + val wifi: Wifi? = null, + val cellular: Cellular? = null, + val changes: List = emptyList(), +) + +@Serializable +enum class Transport { + @SerialName("wifi") WIFI, + @SerialName("cellular") CELLULAR, + @SerialName("ethernet") ETHERNET, + @SerialName("vpn") VPN, + @SerialName("other") OTHER, +} + +@Serializable +data class Link( + val mtu: Int? = null, + val addresses: List
= emptyList(), + val routes: List = emptyList(), + val dns: DnsConfig? = null, + val dhcp: Dhcp? = null, + @SerialName("captive_portal") val captivePortal: CaptivePortal? = null, +) + +@Serializable +data class Address( + val addr: String, // ip4 | ip6 (logical type, §8) + @SerialName("prefix_len") val prefixLen: Int, + val scope: String? = null, + val flags: List = emptyList(), + @SerialName("valid_lft_s") val validLftS: Long? = null, + @SerialName("pref_lft_s") val prefLftS: Long? = null, +) + +@Serializable +data class Route( + val dst: String, + val gateway: String? = null, + val iface: String? = null, + val proto: RouteProto? = null, // shizuku tier; null = not observed + @SerialName("expires_s") val expiresS: Long? = null, +) + +@Serializable +enum class RouteProto { + @SerialName("dhcp") DHCP, + @SerialName("ra") RA, + @SerialName("static") STATIC, + @SerialName("unknown") UNKNOWN, +} + +@Serializable +data class DnsConfig( + val servers: List = emptyList(), + @SerialName("private_dns_mode") val privateDnsMode: String? = null, + @SerialName("private_dns_hostname") val privateDnsHostname: String? = null, + @SerialName("search_domains") val searchDomains: List = emptyList(), + @SerialName("nat64_prefix") val nat64Prefix: String? = null, +) + +@Serializable +data class Dhcp(val server: String? = null, @SerialName("lease_s") val leaseS: Long? = null) + +@Serializable +data class CaptivePortal( + val detected: Boolean = false, + @SerialName("api_url") val apiUrl: String? = null, + @SerialName("venue_url") val venueUrl: String? = null, +) + +@Serializable +data class Wifi( + val ssid: String? = null, // ssid (logical type) + val bssid: String? = null, // bssid (logical type) + @SerialName("rssi_dbm") val rssiDbm: Int? = null, + @SerialName("link_speed_mbps") val linkSpeedMbps: Int? = null, + @SerialName("frequency_mhz") val frequencyMhz: Int? = null, + @SerialName("channel_width_mhz") val channelWidthMhz: Int? = null, + val standard: String? = null, + val security: String? = null, + @SerialName("mac_randomization") val macRandomization: Boolean? = null, +) + +@Serializable +data class Cellular( + val rat: String? = null, + val operator: String? = null, + val band: String? = null, +) + +@Serializable +data class NetworkChange( + @SerialName("at_mono_ns") val atMonoNs: Long, + val kind: String, // lost | gained | link_changed + val detail: JsonObject? = null, +) diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Summary.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Summary.kt new file mode 100644 index 0000000..9cf23c3 --- /dev/null +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Summary.kt @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.measurement + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class Verdict { + @SerialName("green") GREEN, + @SerialName("yellow") YELLOW, + @SerialName("red") RED, + @SerialName("inconclusive") INCONCLUSIVE, +} + +@Serializable +data class Summary( + val overall: Verdict, + val categories: Map, +) + +@Serializable +data class CategorySummary( + val verdict: Verdict, + @SerialName("worst_finding") val worstFinding: String? = null, + @SerialName("tests_run") val testsRun: Int, + @SerialName("tests_failed") val testsFailed: Int, +) + +/** + * Deterministic verdict derivation, fixed by measurement-schema.md §7.3: + * + * - A category's verdict = the light of its worst-severity finding + * (critical|high → red, medium|low → yellow, info/none → green). + * - A category is `inconclusive` when > 50% of its tests are failed/unsupported. + * - Overall = the worst category light; `inconclusive` only when ALL categories are. + * + * The mapping test-type → category comes from [TestType.category]. Only categories that have + * findings or tests appear in the summary. + */ +object Verdicts { + + private fun isInconclusiveTest(s: TestStatus) = + s == TestStatus.FAILED || s == TestStatus.UNSUPPORTED + + fun derive(tests: List, findings: List): Summary { + val testsByCat = tests.groupBy { TestType.category(it.type) } + val findingsByCat = findings.groupBy { it.category } + val categories = (testsByCat.keys + findingsByCat.keys) + + val perCat = LinkedHashMap() + for (cat in Category.entries) { + if (cat !in categories) continue + val catTests = testsByCat[cat].orEmpty() + val catFindings = findingsByCat[cat].orEmpty() + + val failed = catTests.count { isInconclusiveTest(it.status) } + val inconclusive = catTests.isNotEmpty() && failed * 2 > catTests.size + + val worst = catFindings.maxByOrNull { it.severity.rank } + val verdict = when { + inconclusive -> Verdict.INCONCLUSIVE + worst == null -> Verdict.GREEN + else -> worst.severity.toLight() + } + perCat[serialName(cat)] = CategorySummary( + verdict = verdict, + worstFinding = worst?.id, + testsRun = catTests.size, + testsFailed = failed, + ) + } + + val overall = deriveOverall(perCat.values) + return Summary(overall = overall, categories = perCat) + } + + /** Overall = worst light; inconclusive only if every category is inconclusive. */ + private fun deriveOverall(cats: Collection): Verdict { + if (cats.isEmpty()) return Verdict.INCONCLUSIVE + if (cats.all { it.verdict == Verdict.INCONCLUSIVE }) return Verdict.INCONCLUSIVE + val rank = mapOf(Verdict.GREEN to 0, Verdict.YELLOW to 1, Verdict.RED to 2) + // Non-inconclusive categories decide the overall light. + return cats.filter { it.verdict != Verdict.INCONCLUSIVE } + .maxByOrNull { rank.getValue(it.verdict) }!!.verdict + } + + private fun serialName(cat: Category): String = cat.name.lowercase() +} diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt new file mode 100644 index 0000000..322c83b --- /dev/null +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.measurement + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +/** The generic test envelope (measurement-schema.md §6). params must fully reproduce the test; + * evidence is append-only raw truth; metrics must be recomputable from evidence. All three are + * per-test-type JSON, so they are carried as JsonObject. */ +@Serializable +data class Test( + val id: String, // UUIDv7 + val type: String, // TestType registry (§6.1) + @SerialName("network_ref") val networkRef: String? = null, + @SerialName("session_ref") val sessionRef: String? = null, // null for local-only tests + val tier: Tier, + @SerialName("started_mono_ns") val startedMonoNs: Long, + @SerialName("ended_mono_ns") val endedMonoNs: Long, + val status: TestStatus, + val error: TestError? = null, + val params: JsonObject? = null, + val evidence: JsonObject? = null, + val metrics: JsonObject? = null, +) + +@Serializable +enum class Tier { + @SerialName("app") APP, + @SerialName("shizuku") SHIZUKU, + @SerialName("root") ROOT, +} + +@Serializable +enum class TestStatus { + @SerialName("ok") OK, + @SerialName("failed") FAILED, + @SerialName("unsupported") UNSUPPORTED, + @SerialName("skipped") SKIPPED, + @SerialName("partial") PARTIAL, +} + +@Serializable +data class TestError(val code: String, val detail: String? = null) + +/** + * The v1 test-type registry (§6.1). String constants (dotted, family-first) so probe code and the + * server's measurement-schema test-type registry stay aligned. [category] maps a type to one of + * the fixed §7.2 categories for verdict rollup. + */ +object TestType { + // link + const val LINK_SNAPSHOT = "link.snapshot" + const val LINK_DHCP_RENEWAL_WATCH = "link.dhcp_renewal_watch" + const val LINK_IP_MONITOR = "link.ip_monitor" + // icmp + const val ICMP_PING4 = "icmp.ping4" + const val ICMP_PING6 = "icmp.ping6" + // trace + const val TRACEROUTE_UDP4 = "traceroute.udp4" + const val TRACEROUTE_UDP6 = "traceroute.udp6" + const val TRACEROUTE_ICMP4 = "traceroute.icmp4" + const val TRACEROUTE_ICMP6 = "traceroute.icmp6" + // train + const val TRAIN_UDP_UPDOWN = "train.udp_updown" + // mtu + const val MTU_PMTUD_UP = "mtu.pmtud_up" + const val MTU_PMTUD_DOWN = "mtu.pmtud_down" + const val MTU_BLACKHOLE = "mtu.blackhole" + const val MTU_MSS_OBSERVED = "mtu.mss_observed" + const val MTU_FRAG_DELIVERY = "mtu.frag_delivery" + // nat + const val NAT_STUN_5780 = "nat.stun_5780" + const val NAT_MAPPING_LIFETIME_UDP = "nat.mapping_lifetime_udp" + const val NAT_MAPPING_LIFETIME_TCP = "nat.mapping_lifetime_tcp" + const val NAT_HAIRPIN = "nat.hairpin" + const val NAT_CONNECT_BACK = "nat.connect_back" + const val NAT_CGNAT_DETECT = "nat.cgnat_detect" + // dns + const val DNS_RESOLVER_INVENTORY = "dns.resolver_inventory" + const val DNS_CANARY = "dns.canary" + const val DNS_INTERCEPTION = "dns.interception" + const val DNS_TTL_INTEGRITY = "dns.ttl_integrity" + const val DNS_ANSWER_INTEGRITY = "dns.answer_integrity" + const val DNS_DNSSEC = "dns.dnssec" + const val DNS_NXDOMAIN_WILDCARD = "dns.nxdomain_wildcard" + const val DNS_REBIND_FILTER = "dns.rebind_filter" + const val DNS_AAAA_FILTER = "dns.aaaa_filter" + const val DNS_DNS64 = "dns.dns64" + const val DNS_COMPARE = "dns.compare" + // sec + const val SEC_TLS_REFERENCE = "sec.tls_reference" + const val SEC_CLIENTHELLO_ECHO = "sec.clienthello_echo" + const val SEC_HTTP_ECHO = "sec.http_echo" + const val SEC_SNI_FILTER = "sec.sni_filter" + const val SEC_DSCP_ECN_SURVIVAL = "sec.dscp_ecn_survival" + const val SEC_ARP_WATCH = "sec.arp_watch" + // port + const val PORT_REACH_SWEEP = "port.reach_sweep" + const val PORT_UDP_USABILITY = "port.udp_usability" + // perf + const val PERF_THROUGHPUT_TCP = "perf.throughput_tcp" + const val PERF_THROUGHPUT_UDP = "perf.throughput_udp" + const val PERF_BUFFERBLOAT = "perf.bufferbloat" + const val PERF_RRC_LATENCY = "perf.rrc_latency" + // v6 + const val V6_DUALSTACK_COMPARE = "v6.dualstack_compare" + const val V6_HAPPY_EYEBALLS = "v6.happy_eyeballs" + const val V6_BROKENNESS = "v6.brokenness" + const val V6_NAT64_CLAT = "v6.nat64_clat" + // wifi + const val WIFI_ENVIRONMENT_SCAN = "wifi.environment_scan" + const val WIFI_ROAM_LOG = "wifi.roam_log" + const val WIFI_SIGNAL_LOG = "wifi.signal_log" + // local + const val LOCAL_MDNS_INVENTORY = "local.mdns_inventory" + const val LOCAL_SSDP_INVENTORY = "local.ssdp_inventory" + const val LOCAL_LLMNR_INVENTORY = "local.llmnr_inventory" + const val LOCAL_GATEWAY_SERVICES = "local.gateway_services" + const val LOCAL_NTP = "local.ntp" + // peer + const val PEER_REACHABILITY = "peer.reachability" + const val PEER_ISOLATION = "peer.isolation" + const val PEER_MULTICAST = "peer.multicast" + const val PEER_LAN_TRAIN = "peer.lan_train" + const val PEER_LEASE_DIFF = "peer.lease_diff" + // time + const val TIME_SERVER_OFFSET = "time.server_offset" + + /** Maps a dotted test type to its §7.2 category for verdict rollup. */ + fun category(type: String): Category = when (type.substringBefore('.')) { + "link", "icmp", "trace", "traceroute", "train", "port", "time" -> Category.CONNECTIVITY + "dns" -> Category.DNS + "nat" -> Category.NAT + "mtu" -> Category.MTU + "v6" -> Category.IPV6 + "sec" -> Category.SECURITY + "perf" -> Category.PERFORMANCE + "local", "peer" -> Category.LOCAL + "wifi" -> Category.WIFI + else -> Category.CONNECTIVITY + } +} diff --git a/echolot-app/core-measurement/src/test/kotlin/app/echo_lot/measurement/SerializationTest.kt b/echolot-app/core-measurement/src/test/kotlin/app/echo_lot/measurement/SerializationTest.kt new file mode 100644 index 0000000..6a23f0b --- /dev/null +++ b/echolot-app/core-measurement/src/test/kotlin/app/echo_lot/measurement/SerializationTest.kt @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.measurement + +import kotlinx.serialization.json.Json +import kotlin.test.Test as JTest +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SerializationTest { + + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + @JTest + fun documentRoundTrips() { + val doc = MeasurementDocument( + run = Run( + id = "0198c5f2-0000-7000-8000-000000000000", + trigger = Trigger.MANUAL, + startedAt = "2026-07-31T14:03:21.114Z", + clock = Clock(monoOriginWall = "2026-07-31T14:03:21.114Z"), + app = AppInfo(version = "0.1.0", build = 1), + device = DeviceInfo("OnePlus", "CPH2747", 36, "16"), + tiers = Tiers(app = true, shizuku = true), + ), + networks = listOf( + Network( + id = "net-1", transport = Transport.WIFI, iface = "wlan0", + link = Link(mtu = 1500, addresses = listOf(Address("192.0.2.23", 24, "global"))), + wifi = Wifi(ssid = "example", rssiDbm = -54), + ), + ), + tests = listOf( + Test( + id = "t-1", type = TestType.ICMP_PING4, networkRef = "net-1", tier = Tier.APP, + startedMonoNs = 0, endedMonoNs = 38_000_000, status = TestStatus.OK, + evidence = TrainEvidence( + epochMonoNs = 0, seq = listOf(0, 1), tTxNs = listOf(0L, 20_000_000L), + tRxNs = listOf(16_500_000L, null), sizeBytes = listOf(64, 64), + ).toEvidence(), + ), + ), + ) + + val encoded = json.encodeToString(MeasurementDocument.serializer(), doc) + val decoded = json.decodeFromString(MeasurementDocument.serializer(), encoded) + assertEquals(doc.run.id, decoded.run.id) + assertEquals(Transport.WIFI, decoded.networks[0].transport) + assertEquals(TestType.ICMP_PING4, decoded.tests[0].type) + // snake_case field names on the wire + assertTrue(encoded.contains("\"schema_version\"")) + assertTrue(encoded.contains("\"mono_origin_wall\"")) + assertTrue(encoded.contains("\"t_tx_ns\"")) + // null preserved at train index 1 + assertTrue(encoded.contains("[16500000,null]")) + } + + @JTest + fun findingRequiresEvidence() { + try { + Finding( + id = "f-1", code = "x", category = Category.DNS, severity = Severity.INFO, + confidence = Confidence.LOW, title = "t", description = "d", evidenceRefs = emptyList(), + ) + throw AssertionError("expected IllegalArgumentException for empty evidence_refs") + } catch (e: IllegalArgumentException) { + // expected — a finding with no evidence is invalid (§7.1) + } + } +} diff --git a/echolot-app/core-measurement/src/test/kotlin/app/echo_lot/measurement/VerdictsTest.kt b/echolot-app/core-measurement/src/test/kotlin/app/echo_lot/measurement/VerdictsTest.kt new file mode 100644 index 0000000..9314680 --- /dev/null +++ b/echolot-app/core-measurement/src/test/kotlin/app/echo_lot/measurement/VerdictsTest.kt @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.measurement + +import kotlin.test.Test as JTest +import kotlin.test.assertEquals + +class VerdictsTest { + + private fun test(type: String, status: TestStatus, id: String = type): Test = + Test(id = id, type = type, tier = Tier.APP, startedMonoNs = 0, endedMonoNs = 1, status = status) + + private fun finding(cat: Category, sev: Severity, id: String = "f-$cat-$sev"): Finding = + Finding( + id = id, code = "x.$cat", category = cat, severity = sev, confidence = Confidence.HIGH, + title = "t", description = "d", evidenceRefs = listOf(EvidenceRef("some-test")), + ) + + @JTest + fun categoryLightFromWorstSeverity() { + val tests = listOf(test(TestType.DNS_CANARY, TestStatus.OK)) + val findings = listOf( + finding(Category.DNS, Severity.LOW), + finding(Category.DNS, Severity.HIGH), // worst → red + finding(Category.DNS, Severity.INFO), + ) + val s = Verdicts.derive(tests, findings) + assertEquals(Verdict.RED, s.categories["dns"]!!.verdict) + assertEquals("f-DNS-HIGH", s.categories["dns"]!!.worstFinding) + assertEquals(Verdict.RED, s.overall) + } + + @JTest + fun noFindingsIsGreen() { + val s = Verdicts.derive(listOf(test(TestType.MTU_BLACKHOLE, TestStatus.OK)), emptyList()) + assertEquals(Verdict.GREEN, s.categories["mtu"]!!.verdict) + assertEquals(Verdict.GREEN, s.overall) + } + + @JTest + fun mediumAndLowAreYellow() { + val s = Verdicts.derive( + listOf(test(TestType.SEC_HTTP_ECHO, TestStatus.OK)), + listOf(finding(Category.SECURITY, Severity.MEDIUM)), + ) + assertEquals(Verdict.YELLOW, s.categories["security"]!!.verdict) + } + + @JTest + fun majorityFailedIsInconclusive() { + // 2 of 3 dns tests failed → > 50% → inconclusive, even with a finding present. + val tests = listOf( + test(TestType.DNS_CANARY, TestStatus.FAILED, "a"), + test(TestType.DNS_TTL_INTEGRITY, TestStatus.UNSUPPORTED, "b"), + test(TestType.DNS_COMPARE, TestStatus.OK, "c"), + ) + val s = Verdicts.derive(tests, listOf(finding(Category.DNS, Severity.HIGH))) + assertEquals(Verdict.INCONCLUSIVE, s.categories["dns"]!!.verdict) + assertEquals(2, s.categories["dns"]!!.testsFailed) + assertEquals(3, s.categories["dns"]!!.testsRun) + } + + @JTest + fun exactlyHalfFailedIsNotInconclusive() { + // 1 of 2 failed → not > 50% → the finding decides. + val tests = listOf( + test(TestType.NAT_HAIRPIN, TestStatus.FAILED, "a"), + test(TestType.NAT_CONNECT_BACK, TestStatus.OK, "b"), + ) + val s = Verdicts.derive(tests, listOf(finding(Category.NAT, Severity.CRITICAL))) + assertEquals(Verdict.RED, s.categories["nat"]!!.verdict) + } + + @JTest + fun overallIsWorstCategory() { + val tests = listOf( + test(TestType.DNS_CANARY, TestStatus.OK, "d"), + test(TestType.MTU_BLACKHOLE, TestStatus.OK, "m"), + ) + val findings = listOf( + finding(Category.DNS, Severity.MEDIUM), // yellow + finding(Category.MTU, Severity.CRITICAL), // red + ) + val s = Verdicts.derive(tests, findings) + assertEquals(Verdict.RED, s.overall) + } + + @JTest + fun overallInconclusiveOnlyWhenAllAre() { + val tests = listOf( + test(TestType.DNS_CANARY, TestStatus.FAILED, "d"), // dns inconclusive + test(TestType.MTU_BLACKHOLE, TestStatus.OK, "m"), // mtu green + ) + val s = Verdicts.derive(tests, emptyList()) + assertEquals(Verdict.INCONCLUSIVE, s.categories["dns"]!!.verdict) + assertEquals(Verdict.GREEN, s.categories["mtu"]!!.verdict) + assertEquals(Verdict.GREEN, s.overall) // not all inconclusive → mtu decides + } + + @JTest + fun categoryMappingCoversFamilies() { + assertEquals(Category.CONNECTIVITY, TestType.category(TestType.TRACEROUTE_UDP4)) + assertEquals(Category.IPV6, TestType.category(TestType.V6_BROKENNESS)) + assertEquals(Category.LOCAL, TestType.category(TestType.PEER_MULTICAST)) + assertEquals(Category.PERFORMANCE, TestType.category(TestType.PERF_BUFFERBLOAT)) + assertEquals(Category.CONNECTIVITY, TestType.category(TestType.TIME_SERVER_OFFSET)) + } +} diff --git a/echolot-app/settings.gradle.kts b/echolot-app/settings.gradle.kts index 0781d80..27114aa 100644 --- a/echolot-app/settings.gradle.kts +++ b/echolot-app/settings.gradle.kts @@ -22,3 +22,4 @@ rootProject.name = "echolot-app" // can run integration tests against a live server. Android modules // (core-probe, core-shizuku, app) join as they land. include(":core-protocol") +include(":core-measurement")