compat: SemVer version windows between app and server
Both sides now declare what they will talk to, and enforce it. Two axes kept
deliberately separate, because conflating them is the trap:
protocol_version — CAN these builds talk. The correctness axis. Below 1.0.0
the minor is the breaking axis, per SemVer §4.
release window — MAY they, per policy. [min, max), advertised in the
profile, overridable by the operator.
The server refuses out-of-window apps with 426 and a body naming both versions
and the accepted range; the app checks the profile in both directions before a
run rather than discovering mid-measurement that it will be refused.
Three rules that shape the rest:
- GET /v1/profile is never gated. It is where a refused client learns which
version it needs; gating it leaves the user with a network error instead of
an answer, which is precisely the confusion this exists to remove.
- An unparseable or absent version is "unknown", and is allowed. Development
builds report "dev", and a client too old to send the header cannot be
identified anyway.
- Bounds sit at breaking boundaries, not at releases, so shipping a patch
never requires editing a range. The app's server minimum is 0.4.2 for a
stated reason: earlier multi-homed servers mis-addressed granted sends and
the client measured 100% downstream loss that never happened.
The app's versionCode is now derived from its SemVer instead of being a second
number someone has to remember to bump.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
277e33da75
commit
0c5b021b63
@@ -8,6 +8,20 @@ plugins {
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
// The app's version is SemVer and lives here, once. versionCode is derived from it rather than
|
||||
// maintained alongside: Play/F-Droid need a monotonically increasing integer, but a second number
|
||||
// that a human has to remember to bump is a number that eventually disagrees with the first — and
|
||||
// the version is now load-bearing, since the server decides whether to serve us by it.
|
||||
//
|
||||
// major*1_000_000 + minor*10_000 + patch*10 leaves room for 9 patch-level rebuilds (the trailing
|
||||
// digit) without disturbing the mapping, and stays inside the 2_100_000_000 ceiling until major 2100.
|
||||
val appVersionName = "0.2.0"
|
||||
|
||||
fun versionCodeOf(semver: String): Int {
|
||||
val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt)
|
||||
return major * 1_000_000 + minor * 10_000 + patch * 10
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.echo_lot.app"
|
||||
compileSdk = 36
|
||||
@@ -16,12 +30,15 @@ android {
|
||||
applicationId = "app.echo_lot.app"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 2
|
||||
versionName = "0.2.0"
|
||||
versionCode = versionCodeOf(appVersionName)
|
||||
versionName = appVersionName
|
||||
// Automation: `adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true`
|
||||
// runs a measurement immediately and POSTs the report here (dev collection endpoint).
|
||||
buildConfigField("String", "REPORT_UPLOAD_URL", "\"http://89.185.109.150:443/report\"")
|
||||
buildConfigField("String", "REPORT_UPLOAD_SECRET", "\"D4OmG5gGJsElqVVbtYIZbR\"")
|
||||
// The bare SemVer, without the debug build's "-dev" suffix stripped away by the server's
|
||||
// parser anyway — sent to servers so they can apply their compatibility window.
|
||||
buildConfigField("String", "APP_SEMVER", "\"$appVersionName\"")
|
||||
}
|
||||
buildTypes {
|
||||
release { isMinifyEnabled = false }
|
||||
|
||||
@@ -88,6 +88,8 @@ class MainActivity : ComponentActivity() {
|
||||
lifecycleScope.launch { preview = vm.uploadPreview(r.id) }
|
||||
}
|
||||
},
|
||||
onCheckServer = vm::checkServer,
|
||||
serverStatus = vm.state.archiveStatus,
|
||||
onBack = { screen = Screen.RUN },
|
||||
)
|
||||
Screen.HISTORY -> HistoryScreen(
|
||||
|
||||
@@ -10,8 +10,10 @@ import app.echo_lot.measurement.MeasurementDocument
|
||||
import app.echo_lot.privacy.Anonymizer
|
||||
import app.echo_lot.privacy.PrivacyLevel
|
||||
import app.echo_lot.privacy.Salt
|
||||
import app.echo_lot.protocol.Compat
|
||||
import app.echo_lot.protocol.ControlClient
|
||||
import app.echo_lot.protocol.UploadRefused
|
||||
import app.echo_lot.protocol.VersionRefused
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
@@ -66,10 +68,46 @@ class RunStore(context: Context, private val settings: Settings) {
|
||||
data class Sent(val serverName: String, val detail: String) : UploadOutcome
|
||||
/** The operator's policy says no. Not retryable, and not the user's fault. */
|
||||
data class Refused(val reason: String) : UploadOutcome
|
||||
/**
|
||||
* The two builds do not go together. Kept apart from [Refused] and [Failed] because the
|
||||
* remedy is different and specific — install a particular version — and a message that
|
||||
* says so is worth more than one that says "upload failed".
|
||||
*/
|
||||
data class Incompatible(val reason: String) : UploadOutcome
|
||||
data class Failed(val detail: String) : UploadOutcome
|
||||
data object NotConfigured : UploadOutcome
|
||||
}
|
||||
|
||||
private fun client() = ControlClient(
|
||||
settings.serverUrl, setOf(settings.serverPin), BuildConfig.APP_SEMVER,
|
||||
)
|
||||
|
||||
/**
|
||||
* Checks the configured server without uploading anything: reachable, pinned, compatible, and
|
||||
* willing to accept runs. Lets the user find out in settings rather than from a failed run.
|
||||
*/
|
||||
fun checkServer(): String {
|
||||
if (!settings.serverConfigured) return "Fill in the server URL, pin and credential first."
|
||||
return try {
|
||||
val profile = client().profile(settings.serverCredential)
|
||||
val compat = Compat.check(profile, BuildConfig.APP_SEMVER)
|
||||
val head = "${profile.name} · server ${profile.serverVersion} · " +
|
||||
"protocol ${profile.compat.protocolVersion.ifBlank { "unstated" }}"
|
||||
when {
|
||||
compat.message != null -> head + "\n" + compat.message
|
||||
else -> {
|
||||
val uploads = profile.uploads.refusalReason()
|
||||
?: "uploads accepted (min anonymization: ${profile.uploads.minAnonymization})"
|
||||
head + "\nCompatible. " + uploads
|
||||
}
|
||||
}
|
||||
} catch (e: VersionRefused) {
|
||||
"This server will not serve this app: ${e.message}"
|
||||
} catch (t: Throwable) {
|
||||
"Could not reach the server: ${t.message ?: t.javaClass.simpleName}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads one archived run to the configured server, redacting first.
|
||||
*
|
||||
@@ -81,8 +119,14 @@ class RunStore(context: Context, private val settings: Settings) {
|
||||
if (!settings.serverConfigured) return UploadOutcome.NotConfigured
|
||||
val docJson = read(runId) ?: return UploadOutcome.Failed("run $runId is not in the archive")
|
||||
return try {
|
||||
val client = ControlClient(settings.serverUrl, setOf(settings.serverPin))
|
||||
val client = client()
|
||||
val profile = client.profile(settings.serverCredential)
|
||||
|
||||
// Compatibility before policy: an incompatible server may well advertise an upload
|
||||
// policy it would never actually apply to us.
|
||||
val compat = Compat.check(profile, BuildConfig.APP_SEMVER)
|
||||
if (!compat.usable) return UploadOutcome.Incompatible(compat.message ?: "incompatible versions")
|
||||
|
||||
profile.uploads.refusalReason()?.let { return UploadOutcome.Refused(it) }
|
||||
|
||||
val level = PrivacyLevel.max(
|
||||
@@ -93,6 +137,8 @@ class RunStore(context: Context, private val settings: Settings) {
|
||||
val reply = client.uploadRun(settings.serverCredential, body)
|
||||
archive.markUploaded(runId, profile.name)
|
||||
UploadOutcome.Sent(profile.name, "as $level, ${body.toByteArray().size} bytes: ${reply.take(120)}")
|
||||
} catch (e: VersionRefused) {
|
||||
UploadOutcome.Incompatible(e.message ?: "the server refused this app's version")
|
||||
} catch (e: UploadRefused) {
|
||||
UploadOutcome.Refused(e.message ?: "refused by the server")
|
||||
} catch (t: Throwable) {
|
||||
|
||||
@@ -144,6 +144,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private fun describe(o: RunStore.UploadOutcome): String = when (o) {
|
||||
is RunStore.UploadOutcome.Sent -> "uploaded to ${o.serverName} (${o.detail})"
|
||||
is RunStore.UploadOutcome.Refused -> "server refused the upload: ${o.reason}"
|
||||
is RunStore.UploadOutcome.Incompatible -> "version mismatch: ${o.reason}"
|
||||
is RunStore.UploadOutcome.Failed -> "upload failed: ${o.detail}"
|
||||
RunStore.UploadOutcome.NotConfigured -> "no server configured, so nothing was uploaded"
|
||||
}
|
||||
@@ -195,6 +196,14 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
fun archivedBytes(): Long = store.totalBytes()
|
||||
|
||||
/** Settings-screen action: report what the configured server is and whether we can use it. */
|
||||
fun checkServer() {
|
||||
viewModelScope.launch {
|
||||
state = state.copy(archiveStatus = "checking server …")
|
||||
state = state.copy(archiveStatus = withContext(Dispatchers.IO) { store.checkServer() })
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-applies retention after the user changes the limits. */
|
||||
fun applyRetention() {
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -46,6 +46,8 @@ fun SettingsScreen(
|
||||
onApplyRetention: () -> Unit,
|
||||
onDeleteAll: () -> Unit,
|
||||
onPreviewUpload: () -> Unit,
|
||||
onCheckServer: () -> Unit,
|
||||
serverStatus: String?,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
// SharedPreferences is not observable, so mirror each value into Compose state and write
|
||||
@@ -166,9 +168,23 @@ fun SettingsScreen(
|
||||
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Button(onClick = onCheckServer) { Text("Check server") }
|
||||
Text(
|
||||
" " + if (settings.serverConfigured) "Configured."
|
||||
else "Uploads stay off until all three fields are set.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
// Version compatibility is checked here rather than discovered mid-run: a server
|
||||
// that will refuse this build should say so before a measurement is wasted.
|
||||
serverStatus?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Text(
|
||||
if (settings.serverConfigured) "Server configured."
|
||||
else "Uploads stay off until all three fields are set.",
|
||||
"This app is ${BuildConfig.APP_SEMVER} and speaks probe protocol " +
|
||||
"${app.echo_lot.protocol.Compat.PROTOCOL_VERSION}. It works with servers " +
|
||||
"${app.echo_lot.protocol.Compat.serverRange}.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.protocol
|
||||
|
||||
/**
|
||||
* Version compatibility, the client side of the same question the server asks about us.
|
||||
*
|
||||
* Versions are SemVer, but what is really being checked is whether the peer speaks a wire protocol
|
||||
* and schema this build understands; the version is a proxy, and it only works because the
|
||||
* breaking axis is bumped when the contract changes. Bounds therefore sit at breaking boundaries
|
||||
* rather than at individual releases — a server patch release must never make the app refuse to
|
||||
* talk to it.
|
||||
*
|
||||
* Mirrors `server/internal/compat`. Two implementations of one rule is a duplication worth
|
||||
* accepting: each side must be able to state and enforce its own limits without asking the other,
|
||||
* which is the entire point of a compatibility check.
|
||||
*/
|
||||
data class SemVer(
|
||||
val major: Int,
|
||||
val minor: Int,
|
||||
val patch: Int,
|
||||
val pre: String = "",
|
||||
) : Comparable<SemVer> {
|
||||
|
||||
override fun compareTo(other: SemVer): Int {
|
||||
(major - other.major).let { if (it != 0) return it.coerceIn(-1, 1) }
|
||||
(minor - other.minor).let { if (it != 0) return it.coerceIn(-1, 1) }
|
||||
(patch - other.patch).let { if (it != 0) return it.coerceIn(-1, 1) }
|
||||
// A pre-release sorts below the same version without one (SemVer §11).
|
||||
return when {
|
||||
pre == other.pre -> 0
|
||||
pre.isEmpty() -> 1
|
||||
other.pre.isEmpty() -> -1
|
||||
else -> pre.compareTo(other.pre).coerceIn(-1, 1)
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String = "$major.$minor.$patch" + if (pre.isEmpty()) "" else "-$pre"
|
||||
|
||||
/**
|
||||
* The first version that may break compatibility with this one. Below 1.0.0 the minor is the
|
||||
* breaking axis (SemVer §4), so 0.4.2's next break is 0.5.0 — treating it as 1.0.0 would let
|
||||
* this build accept a peer it cannot actually talk to.
|
||||
*/
|
||||
fun nextBreaking(): SemVer =
|
||||
if (major == 0) SemVer(0, minor + 1, 0) else SemVer(major + 1, 0, 0)
|
||||
|
||||
companion object {
|
||||
/** Accepts "1.2.3", "v1.2.3" and namespaced tags like "server-v1.2.3". Null if unusable. */
|
||||
fun parse(raw: String?): SemVer? {
|
||||
var s = raw?.trim().orEmpty()
|
||||
if (s.isEmpty()) return null
|
||||
// Strip a tag prefix ending in "v", guarded on the prefix having no digits so a
|
||||
// pre-release identifier containing a "v" is left alone.
|
||||
val v = s.lastIndexOf('v')
|
||||
if (v >= 0 && v + 1 < s.length && s[v + 1].isDigit() && s.take(v).none { it.isDigit() }) {
|
||||
s = s.substring(v + 1)
|
||||
}
|
||||
s = s.substringBefore('+')
|
||||
val pre = s.substringAfter('-', "")
|
||||
val core = s.substringBefore('-')
|
||||
val parts = core.split(".")
|
||||
if (parts.size != 3) return null
|
||||
val nums = parts.map { it.toIntOrNull() ?: return null }
|
||||
if (nums.any { it < 0 }) return null
|
||||
return SemVer(nums[0], nums[1], nums[2], pre)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** [min, max): minimum inclusive, maximum exclusive. A null max is unbounded. */
|
||||
data class VersionRange(val min: SemVer, val max: SemVer? = null) {
|
||||
operator fun contains(v: SemVer): Boolean = v >= min && (max == null || v < max)
|
||||
override fun toString(): String = ">= $min" + (max?.let { ", < $it" } ?: "")
|
||||
|
||||
companion object {
|
||||
fun of(min: String, max: String?): VersionRange {
|
||||
val lo = requireNotNull(SemVer.parse(min)) { "bad minimum version: $min" }
|
||||
val hi = max?.takeIf { it.isNotBlank() }?.let {
|
||||
requireNotNull(SemVer.parse(it)) { "bad maximum version: $it" }
|
||||
}
|
||||
require(hi == null || hi > lo) { "maximum $max is not above minimum $min" }
|
||||
return VersionRange(lo, hi)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What this build of the app requires of a server, and what it tells servers about itself.
|
||||
*
|
||||
* Two separate questions, deliberately not conflated:
|
||||
*
|
||||
* - **Protocol version** — can these builds talk at all? This is the correctness axis, and its
|
||||
* breaking boundary is enforced strictly.
|
||||
* - **Release-version window** — should they, per policy? A coarse safety net over the peer's
|
||||
* SemVer, with bounds at breaking boundaries so a patch release never strands anyone. Both
|
||||
* sides publish their own, and the operator can tighten the server's.
|
||||
*
|
||||
* `MIN_SERVER` is 0.4.2 for a concrete reason, not caution: below it a multi-homed server sent
|
||||
* granted traffic from an address the session never used, so every downstream packet was dropped
|
||||
* in transit and reported as 100 % downstream loss. A confidently wrong measurement is worse than
|
||||
* a refused one, so talking to those builds is not something to allow "just in case".
|
||||
*/
|
||||
object Compat {
|
||||
/** Header the app sets on every control-plane request. */
|
||||
const val APP_VERSION_HEADER = "X-Echolot-App-Version"
|
||||
|
||||
/** The wire contract (probe-protocol.md) this build implements. */
|
||||
const val PROTOCOL_VERSION = "1.0.0"
|
||||
|
||||
const val MIN_SERVER = "0.4.2"
|
||||
|
||||
/** Exclusive. The next breaking series is refused until this app is taught about it. */
|
||||
const val MAX_SERVER = "1.0.0"
|
||||
|
||||
val serverRange: VersionRange = VersionRange.of(MIN_SERVER, MAX_SERVER)
|
||||
|
||||
enum class Verdict { OK, PROTOCOL_MISMATCH, SERVER_TOO_OLD, SERVER_TOO_NEW, APP_REFUSED, UNKNOWN }
|
||||
|
||||
/**
|
||||
* The result of checking a server, with a message written for the person holding the phone.
|
||||
* [usable] is what callers branch on; [message] is what they show.
|
||||
*/
|
||||
data class Result(val verdict: Verdict, val message: String?) {
|
||||
val usable: Boolean get() = verdict == Verdict.OK || verdict == Verdict.UNKNOWN
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a profile both ways: is the server within our range, and are we within the server's.
|
||||
*
|
||||
* Asking both is the point of advertising the window in the profile. Discovering that the
|
||||
* server will refuse us only when a measurement fails halfway through is a much worse
|
||||
* experience than being told before the run starts.
|
||||
*/
|
||||
fun check(profile: Profile, appVersion: String): Result {
|
||||
// The protocol version is the axis that decides whether these two builds *can* talk;
|
||||
// the release-version window below is the operator's policy about whether they *may*.
|
||||
// Checking the real thing first means a mismatch is reported as what it is.
|
||||
val ours = SemVer.parse(PROTOCOL_VERSION)!!
|
||||
val theirs = SemVer.parse(profile.compat.protocolVersion)
|
||||
if (theirs != null && theirs >= ours.nextBreaking()) {
|
||||
return Result(
|
||||
Verdict.PROTOCOL_MISMATCH,
|
||||
"This server speaks probe protocol ${profile.compat.protocolVersion}; this app " +
|
||||
"speaks $PROTOCOL_VERSION and does not understand that revision. Update the app.",
|
||||
)
|
||||
}
|
||||
if (theirs != null && ours >= theirs.nextBreaking()) {
|
||||
return Result(
|
||||
Verdict.PROTOCOL_MISMATCH,
|
||||
"This server speaks probe protocol ${profile.compat.protocolVersion}, which this " +
|
||||
"app ($PROTOCOL_VERSION) has moved past. Update the server.",
|
||||
)
|
||||
}
|
||||
|
||||
val server = SemVer.parse(profile.serverVersion)
|
||||
?: return Result(
|
||||
Verdict.UNKNOWN,
|
||||
"Server did not report a usable version (\"${profile.serverVersion}\") — " +
|
||||
"continuing without a compatibility check.",
|
||||
)
|
||||
|
||||
if (server < serverRange.min) {
|
||||
return Result(
|
||||
Verdict.SERVER_TOO_OLD,
|
||||
"This server runs $server; Echolot needs $serverRange. " +
|
||||
"Measurements against older servers can be wrong rather than merely missing, " +
|
||||
"so update the server.",
|
||||
)
|
||||
}
|
||||
if (serverRange.max != null && server >= serverRange.max) {
|
||||
return Result(
|
||||
Verdict.SERVER_TOO_NEW,
|
||||
"This server runs $server, which is newer than this app understands " +
|
||||
"($serverRange). Update the app.",
|
||||
)
|
||||
}
|
||||
|
||||
// And the server's own view of us.
|
||||
val app = SemVer.parse(appVersion)
|
||||
val serverWantsMin = SemVer.parse(profile.compat.appMin)
|
||||
val serverWantsMax = SemVer.parse(profile.compat.appMax)
|
||||
if (app != null && serverWantsMin != null) {
|
||||
if (app < serverWantsMin) {
|
||||
return Result(
|
||||
Verdict.APP_REFUSED,
|
||||
"This server only accepts Echolot $serverWantsMin or newer; this app is " +
|
||||
"$app. Update the app.",
|
||||
)
|
||||
}
|
||||
if (serverWantsMax != null && app >= serverWantsMax) {
|
||||
return Result(
|
||||
Verdict.APP_REFUSED,
|
||||
"This server refuses Echolot $serverWantsMax and newer; this app is $app. " +
|
||||
"Use an older app, or a server that has caught up.",
|
||||
)
|
||||
}
|
||||
}
|
||||
return Result(Verdict.OK, null)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,16 @@ import kotlinx.serialization.json.Json
|
||||
import java.net.URL
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
|
||||
/** The server declined to store this run, per the operator's policy. Not a transport failure. */
|
||||
class UploadRefused(message: String) : Exception(message)
|
||||
|
||||
/**
|
||||
* The server refused this app's version. Distinct from a transport failure and from an auth
|
||||
* failure: nothing about the request was wrong, the two builds simply do not go together, and the
|
||||
* message says which versions do.
|
||||
*/
|
||||
class VersionRefused(message: String) : Exception(message)
|
||||
|
||||
/**
|
||||
* The control-plane client (probe-protocol.md §2): enrollment, profile, sessions — over
|
||||
* SPKI-pinned HTTPS. Uses HttpsURLConnection (available since Android API 1, unlike
|
||||
@@ -16,11 +26,14 @@ import javax.net.ssl.HttpsURLConnection
|
||||
*
|
||||
* @param controlUrl e.g. "https://fmr-1.echo-lot.app:8443"
|
||||
* @param pins the `pin-sha256` value(s) from the enrollment QR (base64, no prefix)
|
||||
* @param appVersion this build's SemVer, sent on every request so the server can refuse a build
|
||||
* it cannot serve *before* a measurement half-runs (BuildConfig.VERSION_NAME).
|
||||
*/
|
||||
/** The server declined to store this run, per the operator's policy. Not a transport failure. */
|
||||
class UploadRefused(message: String) : Exception(message)
|
||||
|
||||
class ControlClient(private val controlUrl: String, pins: Set<String>) {
|
||||
class ControlClient(
|
||||
private val controlUrl: String,
|
||||
pins: Set<String>,
|
||||
private val appVersion: String = "",
|
||||
) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val socketFactory = Pinning.sslContext(pins).socketFactory
|
||||
@@ -33,12 +46,39 @@ class ControlClient(private val controlUrl: String, pins: Set<String>) {
|
||||
conn.connectTimeout = 10_000
|
||||
conn.readTimeout = 10_000
|
||||
credential?.let { conn.setRequestProperty("Authorization", "Bearer $it") }
|
||||
if (appVersion.isNotBlank()) conn.setRequestProperty(Compat.APP_VERSION_HEADER, appVersion)
|
||||
return conn
|
||||
}
|
||||
|
||||
/**
|
||||
* 426 is the server saying "your version, not your request". Raised as a distinct exception
|
||||
* from every call site so callers never report it as a network error — the whole value of the
|
||||
* check is that the failure is legible.
|
||||
*/
|
||||
private fun checkVersion(conn: HttpsURLConnection, body: String) {
|
||||
if (conn.responseCode == 426) throw VersionRefused(extractError(body) ?: body.take(200))
|
||||
}
|
||||
|
||||
/** Pulls the "error" string out of a JSON body without pulling in a parser for one field. */
|
||||
private fun extractError(body: String): String? =
|
||||
Regex(""""error"\s*:\s*"((?:[^"\\]|\\.)*)"""").find(body)
|
||||
?.groupValues?.get(1)
|
||||
?.replace("\\\"", "\"")
|
||||
?.replace("\\n", "\n")
|
||||
?.replace("\\\\", "\\")
|
||||
|
||||
/**
|
||||
* Reads the response body, and turns a 426 into [VersionRefused] first.
|
||||
*
|
||||
* Every call goes through here, so the version check cannot be forgotten at a new call site —
|
||||
* the alternative (a check per method) is exactly the kind of thing that gets missed once and
|
||||
* then reports "upload failed: 426" to a user for a year.
|
||||
*/
|
||||
private fun body(conn: HttpsURLConnection): String {
|
||||
val stream = if (conn.responseCode in 200..299) conn.inputStream else conn.errorStream
|
||||
return stream?.bufferedReader()?.use { it.readText() } ?: ""
|
||||
val text = stream?.bufferedReader()?.use { it.readText() } ?: ""
|
||||
checkVersion(conn, text)
|
||||
return text
|
||||
}
|
||||
|
||||
private fun writeJson(conn: HttpsURLConnection, payload: String) {
|
||||
@@ -66,21 +106,24 @@ class ControlClient(private val controlUrl: String, pins: Set<String>) {
|
||||
val conn = open("/v1/enroll", "POST", null)
|
||||
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||
writeJson(conn, if (name != null) """{"name":${jstr(name)}}""" else "{}")
|
||||
check(conn.responseCode == 201) { "enroll failed: ${conn.responseCode} ${body(conn)}" }
|
||||
return json.decodeFromString(EnrollResponse.serializer(), body(conn))
|
||||
val text = body(conn) // reads and raises VersionRefused on 426
|
||||
check(conn.responseCode == 201) { "enroll failed: ${conn.responseCode} $text" }
|
||||
return json.decodeFromString(EnrollResponse.serializer(), text)
|
||||
}
|
||||
|
||||
fun profile(credential: String): Profile {
|
||||
val conn = open("/v1/profile", "GET", credential)
|
||||
check(conn.responseCode == 200) { "profile failed: ${conn.responseCode} ${body(conn)}" }
|
||||
return json.decodeFromString(Profile.serializer(), body(conn))
|
||||
val text = body(conn)
|
||||
check(conn.responseCode == 200) { "profile failed: ${conn.responseCode} $text" }
|
||||
return json.decodeFromString(Profile.serializer(), text)
|
||||
}
|
||||
|
||||
fun createSession(credential: String, target: String): SessionResponse {
|
||||
val conn = open("/v1/sessions", "POST", credential)
|
||||
writeJson(conn, """{"target":${jstr(target)}}""")
|
||||
check(conn.responseCode == 201) { "session failed: ${conn.responseCode} ${body(conn)}" }
|
||||
return json.decodeFromString(SessionResponse.serializer(), body(conn))
|
||||
val text = body(conn)
|
||||
check(conn.responseCode == 201) { "session failed: ${conn.responseCode} $text" }
|
||||
return json.decodeFromString(SessionResponse.serializer(), text)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +154,7 @@ class ControlClient(private val controlUrl: String, pins: Set<String>) {
|
||||
val body = body(conn)
|
||||
when (conn.responseCode) {
|
||||
in 200..299 -> return body
|
||||
403 -> throw UploadRefused(body)
|
||||
403 -> throw UploadRefused(extractError(body) ?: body.take(200))
|
||||
413 -> throw UploadRefused("run is larger than this server accepts: $body")
|
||||
else -> error("upload failed: ${conn.responseCode} $body")
|
||||
}
|
||||
@@ -120,14 +163,16 @@ class ControlClient(private val controlUrl: String, pins: Set<String>) {
|
||||
/** Lists this device's runs stored on the server. */
|
||||
fun listRuns(credential: String): String {
|
||||
val conn = open("/v1/runs", "GET", credential)
|
||||
check(conn.responseCode == 200) { "list runs failed: ${conn.responseCode}" }
|
||||
return body(conn)
|
||||
val text = body(conn)
|
||||
check(conn.responseCode == 200) { "list runs failed: ${conn.responseCode} $text" }
|
||||
return text
|
||||
}
|
||||
|
||||
fun getRun(credential: String, runId: String): String {
|
||||
val conn = open("/v1/runs/$runId", "GET", credential)
|
||||
check(conn.responseCode == 200) { "get run failed: ${conn.responseCode}" }
|
||||
return body(conn)
|
||||
val text = body(conn)
|
||||
check(conn.responseCode == 200) { "get run failed: ${conn.responseCode} $text" }
|
||||
return text
|
||||
}
|
||||
|
||||
fun deleteRun(credential: String, runId: String) {
|
||||
@@ -136,8 +181,9 @@ class ControlClient(private val controlUrl: String, pins: Set<String>) {
|
||||
|
||||
fun observations(credential: String, sessionId: String): String {
|
||||
val conn = open("/v1/sessions/$sessionId/observations", "GET", credential)
|
||||
check(conn.responseCode == 200) { "observations failed: ${conn.responseCode}" }
|
||||
return body(conn)
|
||||
val text = body(conn)
|
||||
check(conn.responseCode == 200) { "observations failed: ${conn.responseCode} $text" }
|
||||
return text
|
||||
}
|
||||
|
||||
fun deleteSession(credential: String, sessionId: String) {
|
||||
|
||||
@@ -56,6 +56,16 @@ data class UploadPolicy(
|
||||
}
|
||||
}
|
||||
|
||||
/** The server's declaration of what it speaks and which app versions it will serve. */
|
||||
@Serializable
|
||||
data class CompatInfo(
|
||||
@SerialName("protocol_version") val protocolVersion: String = "",
|
||||
@SerialName("schema_version") val schemaVersion: String = "",
|
||||
@SerialName("app_min") val appMin: String = "",
|
||||
/** Exclusive; empty means the server sets no upper bound. */
|
||||
@SerialName("app_max") val appMax: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Profile(
|
||||
@SerialName("profile_version") val profileVersion: Int = 0,
|
||||
@@ -67,6 +77,7 @@ data class Profile(
|
||||
@SerialName("server_selftest") val serverSelftest: SelfTest? = null,
|
||||
val pins: List<String> = emptyList(),
|
||||
val uploads: UploadPolicy = UploadPolicy(),
|
||||
val compat: CompatInfo = CompatInfo(),
|
||||
) {
|
||||
fun supports(capability: String) = capability in capabilities
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.protocol
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The client half of the compatibility rule. Deliberately mirrors `compat_test.go`: the two
|
||||
* implementations must agree on where the boundaries are, or one side refuses a peer the other
|
||||
* accepts and the disagreement surfaces as an inexplicable failure in the field.
|
||||
*/
|
||||
class CompatTest {
|
||||
|
||||
@Test
|
||||
fun parsesTheFormsThatActuallyReachUs() {
|
||||
assertEquals(SemVer(1, 2, 3), SemVer.parse("1.2.3"))
|
||||
assertEquals(SemVer(1, 2, 3), SemVer.parse("v1.2.3"))
|
||||
assertEquals(SemVer(0, 4, 2), SemVer.parse("server-v0.4.2"))
|
||||
assertEquals(SemVer(0, 2, 0), SemVer.parse(" 0.2.0 "))
|
||||
assertEquals(SemVer(1, 0, 0, "rc1"), SemVer.parse("1.0.0-rc1"))
|
||||
assertEquals(SemVer(1, 0, 0), SemVer.parse("1.0.0+build.7"))
|
||||
assertEquals(SemVer(1, 0, 0, "rc1"), SemVer.parse("1.0.0-rc1+meta"))
|
||||
// A pre-release identifier containing a "v" is not a tag prefix.
|
||||
assertEquals(SemVer(1, 2, 3, "rcv1"), SemVer.parse("1.2.3-rcv1"))
|
||||
|
||||
for (bad in listOf("", "dev", "1.2", "1.2.3.4", "x.y.z", "-1.0.0", "1.2.beta", null)) {
|
||||
assertNull(SemVer.parse(bad), "should not parse: $bad")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ordersPreReleasesBelowTheirRelease() {
|
||||
fun lt(a: String, b: String) {
|
||||
val x = assertNotNull(SemVer.parse(a))
|
||||
val y = assertNotNull(SemVer.parse(b))
|
||||
assertTrue(x < y, "$a should sort below $b")
|
||||
assertTrue(y > x)
|
||||
}
|
||||
lt("0.9.9", "1.0.0")
|
||||
lt("1.0.0", "1.0.1")
|
||||
lt("1.0.0", "1.1.0")
|
||||
lt("1.0.0-rc1", "1.0.0")
|
||||
lt("1.0.0-rc1", "1.0.0-rc2")
|
||||
assertEquals(0, SemVer.parse("1.2.3")!!.compareTo(SemVer.parse("v1.2.3")!!))
|
||||
}
|
||||
|
||||
// Below 1.0.0 the minor is the breaking axis. Must match Go's NextBreaking exactly.
|
||||
@Test
|
||||
fun nextBreakingUsesTheMinorBelowOne() {
|
||||
assertEquals("0.5.0", SemVer.parse("0.4.2")!!.nextBreaking().toString())
|
||||
assertEquals("0.1.0", SemVer.parse("0.0.9")!!.nextBreaking().toString())
|
||||
assertEquals("2.0.0", SemVer.parse("1.2.3")!!.nextBreaking().toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rangeIsMinInclusiveMaxExclusive() {
|
||||
val r = VersionRange.of("0.2.0", "1.0.0")
|
||||
for (s in listOf("0.2.0", "0.2.1", "0.9.9", "1.0.0-rc1")) {
|
||||
assertTrue(SemVer.parse(s)!! in r, "$s should be inside $r")
|
||||
}
|
||||
for (s in listOf("0.1.9", "1.0.0", "1.0.1", "2.0.0")) {
|
||||
assertTrue(SemVer.parse(s)!! !in r, "$s should be outside $r")
|
||||
}
|
||||
assertTrue(SemVer.parse("99.0.0")!! in VersionRange.of("0.2.0", null), "empty max is unbounded")
|
||||
}
|
||||
|
||||
private fun profile(serverVersion: String, appMin: String = "0.2.0", appMax: String = "1.0.0") =
|
||||
Profile(
|
||||
serverVersion = serverVersion,
|
||||
compat = CompatInfo(protocolVersion = "1.0.0", appMin = appMin, appMax = appMax),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun acceptsAServerInsideTheWindow() {
|
||||
val r = Compat.check(profile("0.4.2"), appVersion = "0.2.0")
|
||||
assertEquals(Compat.Verdict.OK, r.verdict)
|
||||
assertTrue(r.usable)
|
||||
assertNull(r.message)
|
||||
}
|
||||
|
||||
// 0.4.2 is the minimum for a concrete reason: older multi-homed servers mis-address granted
|
||||
// sends and the client reports 100% downstream loss that never happened.
|
||||
@Test
|
||||
fun refusesAServerBelowTheMinimum() {
|
||||
val r = Compat.check(profile("0.4.1"), appVersion = "0.2.0")
|
||||
assertEquals(Compat.Verdict.SERVER_TOO_OLD, r.verdict)
|
||||
assertTrue(!r.usable)
|
||||
assertTrue(r.message!!.contains("0.4.1") && r.message!!.contains("0.4.2"),
|
||||
"the message must name both versions: ${r.message}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refusesAServerFromANewerBreakingSeries() {
|
||||
val r = Compat.check(profile("1.0.0"), appVersion = "0.2.0")
|
||||
assertEquals(Compat.Verdict.SERVER_TOO_NEW, r.verdict)
|
||||
assertTrue(r.message!!.contains("Update the app"), "should tell the user what to do")
|
||||
}
|
||||
|
||||
// Learning that the server will refuse us only when a measurement fails halfway is a much
|
||||
// worse experience than being told before the run starts — so the check goes both ways.
|
||||
@Test
|
||||
fun detectsThatTheServerWouldRefuseThisApp() {
|
||||
val old = Compat.check(profile("0.4.2", appMin = "0.5.0"), appVersion = "0.2.0")
|
||||
assertEquals(Compat.Verdict.APP_REFUSED, old.verdict)
|
||||
assertTrue(old.message!!.contains("0.5.0"))
|
||||
|
||||
val tooNew = Compat.check(profile("0.4.2", appMax = "0.3.0"), appVersion = "0.4.0")
|
||||
assertEquals(Compat.Verdict.APP_REFUSED, tooNew.verdict)
|
||||
}
|
||||
|
||||
// A development build reports something unparseable. Locking a developer out of their own
|
||||
// server would be a poor trade for a check meant to make failures clearer.
|
||||
@Test
|
||||
fun unknownVersionsAreUsableWithAnExplanation() {
|
||||
val r = Compat.check(profile("dev"), appVersion = "0.2.0")
|
||||
assertEquals(Compat.Verdict.UNKNOWN, r.verdict)
|
||||
assertTrue(r.usable, "an unidentifiable server must not be treated as incompatible")
|
||||
assertNotNull(r.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aServerThatDeclaresNoWindowIsNotTreatedAsRefusingUs() {
|
||||
// An older server predating the compat block sends nothing; absence must not read as
|
||||
// a restriction.
|
||||
val r = Compat.check(Profile(serverVersion = "0.4.2"), appVersion = "0.2.0")
|
||||
assertEquals(Compat.Verdict.OK, r.verdict)
|
||||
}
|
||||
|
||||
// The protocol version decides whether the builds *can* talk; the release window is only
|
||||
// policy about whether they *may*. A protocol break must be reported as a protocol break,
|
||||
// even when both release versions sit comfortably inside their windows.
|
||||
@Test
|
||||
fun aProtocolBreakIsReportedAsOne() {
|
||||
val newer = Profile(
|
||||
serverVersion = "0.9.0",
|
||||
compat = CompatInfo(protocolVersion = "2.0.0", appMin = "0.2.0", appMax = "1.0.0"),
|
||||
)
|
||||
val r = Compat.check(newer, appVersion = "0.2.0")
|
||||
assertEquals(Compat.Verdict.PROTOCOL_MISMATCH, r.verdict)
|
||||
assertTrue(r.message!!.contains("2.0.0"))
|
||||
|
||||
// Same protocol series, different patch: fine. A protocol bugfix must not split a fleet.
|
||||
val samePatch = Profile(
|
||||
serverVersion = "0.9.0",
|
||||
compat = CompatInfo(protocolVersion = "1.0.4", appMin = "0.2.0", appMax = "1.0.0"),
|
||||
)
|
||||
assertEquals(Compat.Verdict.OK, Compat.check(samePatch, appVersion = "0.2.0").verdict)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thisBuildsOwnBoundsAreWellFormed() {
|
||||
val r = Compat.serverRange
|
||||
assertEquals(SemVer.parse(Compat.MIN_SERVER), r.min)
|
||||
assertEquals(SemVer.parse(Compat.MAX_SERVER), r.max)
|
||||
assertTrue(r.min < r.max!!, "the built-in window must be non-empty")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user