server: send granted traffic from the address the session actually used
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 30s
server-release / release (push) Successful in 30s

fmr binds two IPv4 addresses. connFor picked whichever socket of the right
family came first in the bind list, so a downtrain for a session established on
.150 went out from .151 — and every packet was dropped by the client's NAT,
which has no mapping for that pair. tcpdump on the server showed all 50 leaving;
the client saw none. Read as "100% downstream loss", which is the worst kind of
wrong: a confident measurement of something that never happened.

Sessions now record which of our own bound addresses received their traffic, and
granted sends (and delayed echo) go back out through that socket. The fallback
to a family match is kept for the case where nothing has been received yet, and
the test pins both paths — a single-homed lab can never reproduce this.

Also: the client-side halves of the same work — anonymizer (core-privacy), local
run archive with retention (core-archive), upload client, and the app's settings
and history screens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 10:45:43 +02:00
co-authored by Claude Fable 5
parent 7a94c9a3d7
commit ce1aaa332a
31 changed files with 2018 additions and 50 deletions
+23
View File
@@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
// The anonymizer (measurement-schema.md §8). Pure Kotlin/JVM and deliberately
// dependency-free beyond JSON: it must be trivially auditable, because a bug
// here leaks a user's network onto someone else's server.
dependencies {
implementation(libs.kotlinx.serialization.json)
testImplementation(kotlin("test"))
}
kotlin {
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() }
@@ -0,0 +1,237 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package privacy implements the anonymization contract of measurement-schema.md §8.
//
// The threat model is specific. An engineer running their own server wants the full document —
// SSIDs and MACs are what make a run useful a week later. Someone measuring against a stranger's
// server wants the numbers to survive and the identifiers not to. So this is a *transform*, not a
// filter: the output is still a valid measurement document with the same tests, metrics and
// findings; only the identifying scalars change, and consistently, so "same SSID as last run" is
// still answerable from pseudonyms alone.
//
// Two properties are load-bearing and are what the tests pin:
// - Consistency within a document: one input value always maps to one pseudonym, so
// correlations inside a run survive.
// - No consistency *across* documents unless the user asks for it: the salt is per-run by
// default, so pseudonyms cannot be used to track a device between uploads. A stable salt is
// opt-in (`Salt.stable`) for people diffing their own history on their own server.
package app.echo_lot.privacy
import kotlinx.serialization.json.*
import java.security.MessageDigest
import java.util.Locale
/** How much to strip. Ordered: FULL < BALANCED < STRICT. Wire values match the server's. */
enum class PrivacyLevel(val wire: String) {
/** Nothing removed. The right choice for your own server. */
FULL("full"),
/**
* Identifiers pseudonymized, neighbour inventory dropped. Topology and timing survive:
* you can still see that the gateway is a MikroTik at a /24 boundary with 3 % loss, but not
* which MikroTik, on which SSID, next to whose Chromecast.
*/
BALANCED("balanced"),
/**
* Numbers only: tests keep their metrics and status, evidence is dropped, findings keep their
* codes and severities but lose descriptions (which quote real names). What is left cannot
* identify a network, and is still enough for aggregate "how common is this fault" work.
*/
STRICT("strict");
companion object {
fun fromWire(s: String?): PrivacyLevel =
entries.firstOrNull { it.wire == s?.lowercase(Locale.ROOT) } ?: FULL
/** The stricter of two levels — used to honour a server's minimum. */
fun max(a: PrivacyLevel, b: PrivacyLevel): PrivacyLevel = if (a.ordinal >= b.ordinal) a else b
}
}
/**
* The pseudonymization salt. Per-run by default: a fresh random salt means the same SSID uploaded
* twice yields two different pseudonyms, so an upload endpoint cannot link runs to a device.
* A stable salt trades that away for cross-run diffing and is only appropriate on a server you
* own — the app makes that an explicit choice, not a default.
*/
class Salt private constructor(internal val bytes: ByteArray, val stable: Boolean) {
companion object {
fun perRun(random: ByteArray): Salt = Salt(random.copyOf(), stable = false)
fun stable(secret: ByteArray): Salt = Salt(secret.copyOf(), stable = true)
}
}
/**
* Transforms a measurement document to [level].
*
* Field classification is by JSON key name, because the schema names things consistently
* (`ssid`, `bssid`, `mac`, `ip4`, `ip6`, `fqdn`, …) and a name-driven pass is auditable by
* reading one table. Anything unrecognized is treated as identifying when it is a string inside
* a known-sensitive container, and left alone otherwise — see [Classification].
*/
class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
private val cache = HashMap<String, String>()
fun anonymize(doc: JsonObject): JsonObject {
if (level == PrivacyLevel.FULL) return stamp(doc)
val walked = walkObject(doc, path = emptyList())
val out = if (level == PrivacyLevel.STRICT) strip(walked) else walked
return stamp(out)
}
/** Records what was done, so a reader of the archived/uploaded document is never guessing. */
private fun stamp(doc: JsonObject): JsonObject {
val run = doc["run"]?.jsonObject ?: return doc
val privacy = buildJsonObject {
put("anonymization", level.wire)
put("salt", if (salt.stable) "stable" else "per_run")
}
return JsonObject(doc + ("run" to JsonObject(run + ("privacy" to privacy))))
}
// ---- the tree walk -------------------------------------------------------------------
private fun walkObject(obj: JsonObject, path: List<String>): JsonObject = buildJsonObject {
for ((k, v) in obj) {
val childPath = path + k
when {
Classification.dropAtBalanced(childPath) -> Unit // omit entirely
else -> put(k, walk(k, v, childPath))
}
}
}
private fun walk(key: String, v: JsonElement, path: List<String>): JsonElement = when (v) {
is JsonObject -> walkObject(v, path)
is JsonArray -> JsonArray(v.map { walk(key, it, path) })
is JsonPrimitive ->
if (v.isString) transform(Classification.typeOf(key, path), v.content).let(::JsonPrimitive)
else v
}
private fun transform(type: LogicalType?, value: String): String = when (type) {
null -> value
LogicalType.SSID -> pseudo("ssid", value) { "net-" + it.take(6) }
LogicalType.MAC, LogicalType.BSSID -> macPreservingOui(value)
LogicalType.IP4 -> ip4(value)
LogicalType.IP6 -> ip6(value)
LogicalType.FQDN -> fqdn(value)
LogicalType.OPAQUE_ID -> "redacted"
LogicalType.FREETEXT -> "[removed: may contain identifying text]"
}
// ---- per-type transforms -------------------------------------------------------------
/**
* Keeps the OUI (which vendor) and pseudonymizes the NIC part (which unit). Vendor is the
* diagnostically valuable half — "the RA comes from a MikroTik" survives, "…from THAT
* MikroTik" does not.
*/
private fun macPreservingOui(value: String): String {
val sep = if (value.contains('-')) '-' else ':'
val parts = value.split(sep)
if (parts.size != 6 || parts.any { it.length != 2 }) return pseudo("mac", value) { "mac-" + it.take(8) }
val nic = pseudo("mac", value) { it }
return (parts.take(3) + listOf(nic.substring(0, 2), nic.substring(2, 4), nic.substring(4, 6)))
.joinToString(sep.toString())
.lowercase(Locale.ROOT)
}
/**
* Prefix-preserving within the same class, with reserved ranges kept verbatim: RFC1918 and
* CGNAT addresses say something about the topology and nothing about the person, and a run
* where 192.168.1.1 became a random public address would be actively misleading to read.
* Public addresses keep only their /16 so the network is still locatable at ISP granularity.
*/
private fun ip4(value: String): String {
val o = value.split(".")
if (o.size != 4 || o.any { it.toIntOrNull() == null }) return value
val n = o.map { it.toInt() }
val reserved = n[0] == 10 ||
(n[0] == 172 && n[1] in 16..31) ||
(n[0] == 192 && n[1] == 168) ||
(n[0] == 169 && n[1] == 254) ||
(n[0] == 100 && n[1] in 64..127) ||
n[0] == 127 || n[0] == 0 || n[0] >= 224
if (reserved) return value
val h = pseudo("ip4", value) { it }
return "${n[0]}.${n[1]}.${h.substring(0, 2).toInt(16)}.${h.substring(2, 4).toInt(16)}"
}
/**
* IPv6 keeps the scope and the first 32 bits (so 2001:db8:… still reads as global unicast in
* the same allocation) and pseudonymizes the rest — the interface identifier is the part that
* is a device fingerprint, especially with EUI-64.
*/
private fun ip6(value: String): String {
val v = value.lowercase(Locale.ROOT)
if (v == "::1" || v == "::" || v.startsWith("fe80:") || v.startsWith("ff")) return v
val groups = v.substringBefore('%').split(":")
if (groups.size < 3) return v
val h = pseudo("ip6", value) { it }
return "${groups[0]}:${groups[1]}:${h.substring(0, 4)}:${h.substring(4, 8)}::${h.substring(8, 12)}"
}
/**
* Per-label pseudonyms with the public suffix kept, so "it resolved somewhere under .local"
* or "…under example.com" survives without naming the host. The suffix list is deliberately
* short: guessing wrong keeps *more* pseudonymized, never less.
*/
private fun fqdn(value: String): String {
if (value.isEmpty()) return value
val trailing = value.endsWith(".")
val labels = value.trimEnd('.').split(".")
if (labels.size == 1) return pseudo("fqdn", value) { "host-" + it.take(6) }
val keep = if (labels.last() in publicSuffixes) 1 else 0
val head = labels.dropLast(keep).map { l -> pseudo("label", l) { "l-" + it.take(6) } }
return (head + labels.takeLast(keep)).joinToString(".") + if (trailing) "." else ""
}
// ---- STRICT ---------------------------------------------------------------------------
/**
* STRICT keeps the shape of the document and the numbers, and nothing that quotes the
* network back. Evidence goes (trains carry addresses and hostnames), finding prose goes
* (it interpolates real names), networks go entirely.
*/
private fun strip(doc: JsonObject): JsonObject = buildJsonObject {
for ((k, v) in doc) {
when (k) {
"networks", "server_sessions" -> Unit
"tests" -> put(k, JsonArray((v as? JsonArray ?: JsonArray(emptyList())).map { t ->
val o = t.jsonObject
JsonObject(o.filterKeys { it != "evidence" && it != "params" })
}))
"findings" -> put(k, JsonArray((v as? JsonArray ?: JsonArray(emptyList())).map { f ->
val o = f.jsonObject
JsonObject(o.filterKeys { it != "description" && it != "title" && it != "evidence_refs" })
}))
"run" -> put(k, JsonObject(v.jsonObject.filterKeys { it != "notes" }))
else -> put(k, v)
}
}
}
// ---- pseudonym machinery ---------------------------------------------------------------
/** Deterministic per (domain, value, salt); memoized so one value maps to one pseudonym. */
private fun pseudo(domain: String, value: String, shape: (String) -> String): String =
cache.getOrPut("$domain$value") {
val md = MessageDigest.getInstance("SHA-256")
md.update(salt.bytes)
md.update(domain.toByteArray())
md.update(0)
md.update(value.lowercase(Locale.ROOT).toByteArray())
shape(md.digest().joinToString("") { "%02x".format(it) })
}
private companion object {
val publicSuffixes = setOf(
"local", "lan", "home", "internal", "arpa",
"com", "net", "org", "io", "app", "dev", "at", "de", "eu", "uk",
)
}
}
@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.privacy
/** The logical types of measurement-schema.md §8. */
enum class LogicalType { IP4, IP6, MAC, BSSID, SSID, FQDN, OPAQUE_ID, FREETEXT }
/**
* Which fields hold which logical type, and which whole subtrees are dropped below FULL.
*
* This is a table on purpose. The alternative — annotating the Kotlin models and reflecting over
* them — spreads the answer across every module and makes "what exactly gets uploaded?" a
* question you answer by reading the whole app. Here it is one file a reviewer can check against
* the spec in a sitting, and a new field that nobody classified stays visible in the output
* rather than being silently mangled.
*
* The bias is toward over-classifying: a metric wrongly pseudonymized is a bug someone reports;
* an SSID wrongly kept is a leak nobody notices.
*/
object Classification {
private val byKey: Map<String, LogicalType> = buildMap {
listOf(
"ip4", "ipv4", "gateway_ip4", "dns_ip4", "src_ip4", "dst_ip4", "public_ip4",
"observed_ip4", "hop_ip4", "answer_ip4", "address_ip4", "server_ip4",
).forEach { put(it, LogicalType.IP4) }
listOf(
"ip6", "ipv6", "gateway_ip6", "dns_ip6", "src_ip6", "dst_ip6", "public_ip6",
"observed_ip6", "hop_ip6", "answer_ip6", "address_ip6", "server_ip6",
"link_local", "ra_source", "prefix",
).forEach { put(it, LogicalType.IP6) }
listOf("mac", "hw_addr", "gateway_mac", "router_mac", "sender_mac", "peer_mac")
.forEach { put(it, LogicalType.MAC) }
listOf("bssid", "ap_mac").forEach { put(it, LogicalType.BSSID) }
listOf("ssid", "network_name", "wifi_ssid").forEach { put(it, LogicalType.SSID) }
listOf(
"fqdn", "hostname", "host", "name", "reverse_dns", "ptr", "domain", "query_name",
"friendly_name", "server_name", "sni", "cname", "search_domain", "device_name",
).forEach { put(it, LogicalType.FQDN) }
listOf("session_id", "credential", "token", "device_id", "android_id", "serial", "imsi", "iccid")
.forEach { put(it, LogicalType.OPAQUE_ID) }
listOf("notes", "detail", "raw", "excerpt", "location", "model_description")
.forEach { put(it, LogicalType.FREETEXT) }
}
/**
* Whole subtrees that BALANCED removes rather than pseudonymizes.
*
* Neighbour inventories (SSDP/UPnP responders, ARP tables, discovered peers) are the clearest
* case: they describe other people's devices, they are a household fingerprint even with the
* names hashed, and no metric depends on them. Dropping beats mangling.
*/
private val droppedPaths: List<List<String>> = listOf(
listOf("networks", "neighbors"),
listOf("networks", "arp"),
listOf("networks", "wifi", "scan_results"),
listOf("run", "device", "security_patch"),
)
/** Key suffixes whose whole value is a neighbour inventory wherever they appear. */
private val droppedKeys = setOf(
"ssdp_responders", "upnp", "neighbors", "arp_table", "scan_results",
"nearby_networks", "peers", "raw_dump", "dumpsys",
)
fun typeOf(key: String, path: List<String>): LogicalType? {
byKey[key]?.let { return it }
// Inside a discovery/neighbour container every string is someone's device name until
// proven otherwise, so classify unknown strings there as free text rather than passing
// them through.
if (path.any { it in droppedKeys }) return LogicalType.FREETEXT
return null
}
fun dropAtBalanced(path: List<String>): Boolean {
if (path.isNotEmpty() && path.last() in droppedKeys) return true
return droppedPaths.any { dropped -> dropped.all { path.contains(it) } }
}
}
@@ -0,0 +1,185 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.privacy
import kotlinx.serialization.json.*
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* These tests are the audit of the anonymizer: each one states a property someone's privacy
* depends on, so a regression here fails loudly rather than quietly leaking.
*/
class AnonymizerTest {
private val json = Json { prettyPrint = false }
private val salt = Salt.perRun(ByteArray(32) { it.toByte() })
private fun sample(): JsonObject = json.parseToJsonElement(
"""
{
"schema": "echolot/measurement",
"run": {
"id": "0190-run", "trigger": "manual", "notes": "at Anna's flat",
"device": {"manufacturer": "OnePlus", "model": "CPH2747", "security_patch": "2026-06-05"}
},
"networks": [{
"id": "net-1", "ssid": "Rambossek WLAN", "bssid": "78:9a:18:aa:bb:cc",
"gateway_ip4": "192.168.1.1", "public_ip4": "89.185.109.150",
"gateway_ip6": "2001:1ad0:c4fe:6767::1", "link_local": "fe80::7a9a:18ff:feaa:bbcc",
"neighbors": [{"name": "Anna's Chromecast", "mac": "aa:bb:cc:dd:ee:ff"}],
"ssdp_responders": [{"friendly_name": "Living Room TV", "location": "http://192.168.1.44:8060/"}]
}],
"tests": [{
"id": "t1", "type": "train.udp_updown", "status": "ok",
"metrics": {"rtt_ms_avg": 12.4, "loss_pct": 0.0},
"evidence": {"seq": [0,1,2], "t_rx_ns": [1,2,3]}
}],
"findings": [{
"id": "f1", "code": "nat.udp_rebinding", "severity": "medium",
"title": "NAT remapped the port", "description": "server saw 89.185.109.150:41000"
}],
"summary": {"verdict": "warn"}
}
""".trimIndent(),
).jsonObject
private fun anon(level: PrivacyLevel, doc: JsonObject = sample()) = Anonymizer(level, salt).anonymize(doc)
private fun flat(e: JsonElement): String = e.toString()
@Test
fun fullLeavesTheDocumentAloneButRecordsThat() {
val out = anon(PrivacyLevel.FULL)
assertEquals("Rambossek WLAN", out["networks"]!!.jsonArray[0].jsonObject["ssid"]!!.jsonPrimitive.content)
assertEquals("full", out["run"]!!.jsonObject["privacy"]!!.jsonObject["anonymization"]!!.jsonPrimitive.content)
}
@Test
fun balancedRemovesTheSsidAndTheNotes() {
val text = flat(anon(PrivacyLevel.BALANCED))
assertFalse(text.contains("Rambossek"), "SSID survived: $text")
assertFalse(text.contains("Anna"), "free-text note or neighbour name survived: $text")
}
@Test
fun balancedDropsNeighbourInventoriesEntirely() {
val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject
assertNull(net["neighbors"], "neighbour list should be dropped, not pseudonymized")
assertNull(net["ssdp_responders"], "SSDP responders should be dropped, not pseudonymized")
}
@Test
fun balancedKeepsTheVendorHalfOfAMac() {
val bssid = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject["bssid"]!!.jsonPrimitive.content
assertTrue(bssid.startsWith("78:9a:18"), "OUI should survive so the vendor is still known: $bssid")
assertFalse(bssid.endsWith("aa:bb:cc"), "NIC part should be pseudonymized: $bssid")
}
@Test
fun privateAddressesAreKeptVerbatimAndPublicOnesAreNot() {
val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject
assertEquals("192.168.1.1", net["gateway_ip4"]!!.jsonPrimitive.content,
"RFC1918 says nothing about the user and everything about the topology")
assertNotEquals("89.185.109.150", net["public_ip4"]!!.jsonPrimitive.content)
assertTrue(net["public_ip4"]!!.jsonPrimitive.content.startsWith("89.185."),
"the /16 should survive for ISP-level context")
}
@Test
fun linkLocalIsKeptButGlobalV6IsNot() {
val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject
assertEquals("fe80::7a9a:18ff:feaa:bbcc", net["link_local"]!!.jsonPrimitive.content)
assertNotEquals("2001:1ad0:c4fe:6767::1", net["gateway_ip6"]!!.jsonPrimitive.content)
}
@Test
fun metricsAndVerdictsAreNeverTouched() {
for (level in PrivacyLevel.entries) {
val out = anon(level)
val t = out["tests"]!!.jsonArray[0].jsonObject
assertEquals(12.4, t["metrics"]!!.jsonObject["rtt_ms_avg"]!!.jsonPrimitive.double, 1e-9,
"$level changed a metric")
assertEquals("ok", t["status"]!!.jsonPrimitive.content)
assertEquals("warn", out["summary"]!!.jsonObject["verdict"]!!.jsonPrimitive.content)
}
}
@Test
fun findingCodesSurviveEveryLevelSoAggregationStillWorks() {
for (level in PrivacyLevel.entries) {
val f = anon(level)["findings"]!!.jsonArray[0].jsonObject
assertEquals("nat.udp_rebinding", f["code"]!!.jsonPrimitive.content, "$level lost the finding code")
assertEquals("medium", f["severity"]!!.jsonPrimitive.content)
}
}
@Test
fun strictDropsEvidenceAndProse() {
val out = anon(PrivacyLevel.STRICT)
assertNull(out["networks"], "STRICT should not describe the network at all")
assertNull(out["tests"]!!.jsonArray[0].jsonObject["evidence"])
assertNull(out["findings"]!!.jsonArray[0].jsonObject["description"])
assertFalse(flat(out).contains("89.185.109.150"), "an address leaked through finding prose")
}
@Test
fun pseudonymsAreConsistentWithinADocument() {
val doc = json.parseToJsonElement(
"""{"run":{"id":"r"},"networks":[{"ssid":"Home"},{"ssid":"Home"},{"ssid":"Other"}]}"""
).jsonObject
val nets = anon(PrivacyLevel.BALANCED, doc)["networks"]!!.jsonArray
val a = nets[0].jsonObject["ssid"]!!.jsonPrimitive.content
val b = nets[1].jsonObject["ssid"]!!.jsonPrimitive.content
val c = nets[2].jsonObject["ssid"]!!.jsonPrimitive.content
assertEquals(a, b, "the same SSID must map to the same pseudonym inside one run")
assertNotEquals(a, c, "different SSIDs must not collide")
}
@Test
fun perRunSaltsDoNotLinkTwoUploadsOfTheSameNetwork() {
val doc = json.parseToJsonElement("""{"run":{"id":"r"},"networks":[{"ssid":"Home"}]}""").jsonObject
val one = Anonymizer(PrivacyLevel.BALANCED, Salt.perRun(ByteArray(32) { 1 })).anonymize(doc)
val two = Anonymizer(PrivacyLevel.BALANCED, Salt.perRun(ByteArray(32) { 2 })).anonymize(doc)
assertNotEquals(
one["networks"]!!.jsonArray[0].jsonObject["ssid"],
two["networks"]!!.jsonArray[0].jsonObject["ssid"],
"a per-run salt must not produce a cross-run tracking identifier",
)
}
@Test
fun aStableSaltDoesLinkThemBecauseThatIsWhatItIsFor() {
val doc = json.parseToJsonElement("""{"run":{"id":"r"},"networks":[{"ssid":"Home"}]}""").jsonObject
val secret = ByteArray(32) { 7 }
val one = Anonymizer(PrivacyLevel.BALANCED, Salt.stable(secret)).anonymize(doc)
val two = Anonymizer(PrivacyLevel.BALANCED, Salt.stable(secret)).anonymize(doc)
assertEquals(
one["networks"]!!.jsonArray[0].jsonObject["ssid"],
two["networks"]!!.jsonArray[0].jsonObject["ssid"],
)
assertEquals("stable", one["run"]!!.jsonObject["privacy"]!!.jsonObject["salt"]!!.jsonPrimitive.content)
}
@Test
fun theDeclaredLevelMatchesWhatWasApplied() {
for (level in PrivacyLevel.entries) {
assertEquals(
level.wire,
anon(level)["run"]!!.jsonObject["privacy"]!!.jsonObject["anonymization"]!!.jsonPrimitive.content,
)
}
}
@Test
fun serverMinimumWins() {
assertEquals(PrivacyLevel.STRICT, PrivacyLevel.max(PrivacyLevel.FULL, PrivacyLevel.STRICT))
assertEquals(PrivacyLevel.BALANCED, PrivacyLevel.max(PrivacyLevel.BALANCED, PrivacyLevel.FULL))
assertEquals(PrivacyLevel.FULL, PrivacyLevel.fromWire("nonsense"))
}
}