diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..2c52a7b --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,42 @@ +# Gradle +.gradle/ +build/ +**/build/ +!gradle/wrapper/gradle-wrapper.jar +!src/**/build/ + +# Gradle caches / config-cache +.gradle/configuration-cache/ + +# IDE — IntelliJ IDEA / Android Studio +.idea/ +*.iml +*.ipr +*.iws +captures/ +.navigation/ + +# Local machine config (never commit) +local.properties + +# Android build outputs (relevant once the SDK-gated modules are enabled) +*.apk +*.aab +*.ap_ +*.dex +release/ +proguard/ + +# Secrets — never commit +google-services.json +**/google-services.json +*.keystore +*.jks +*.p12 +service-account*.json + +# OS cruft +.DS_Store + +# Kover / test reports +**/kover/ diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..ff2c8b3 --- /dev/null +++ b/android/README.md @@ -0,0 +1,95 @@ +# WebTerm — Android client + +A native Android client for the WebTerm browser-terminal server, targeting functional +parity with the shipped **iOS** client. See the full design in +[`docs/ANDROID_CLIENT_PLAN.md`](../docs/ANDROID_CLIENT_PLAN.md) (stack §2, module +architecture §3, server contract §4, task waves §5). + +This directory is a **Gradle multi-module** project. The module set mirrors the iOS +SPM package set and inherits its rule: *dependencies only flow down; nothing points +upward* (ARCHITECTURE §1). + +## ⚠️ No-SDK constraint (why only 5 modules build here) + +The current build environment has **no Android SDK**. Everything that can be pure +**Kotlin/JVM** (`kotlin("jvm")`) is built and unit-tested now; anything that needs the +Android framework (`com.android.*` plugins) is **scaffolded but disabled**. + +- **Enabled now (pure Kotlin/JVM, `./gradlew test`-able):** + `:wire-protocol`, `:session-core`, `:api-client`, `:client-tls`, `:test-support`. +- **Scaffolded but COMMENTED OUT** in [`settings.gradle.kts`](settings.gradle.kts) + (dirs + a `build.gradle.kts` stub exist, marked `// TODO(android-sdk)`): + `:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`. + +To bring the Android modules online later: install an SDK, add +`local.properties` → `sdk.dir`, add the Android Gradle Plugin + `google()` to +`pluginManagement`, then uncomment the `include(...)` lines and the plugin blocks in +each stub. + +## Module map (mirror of the iOS SPM packages — plan §3) + +| iOS SPM package | Android module | Kind | Status | +|------------------------|------------------------|-------------------------------|--------| +| WireProtocol | `:wire-protocol` | pure Kotlin/JVM | ✅ built | +| SessionCore (reducers) | `:session-core` | pure Kotlin/JVM | ✅ built | +| APIClient | `:api-client` | pure Kotlin/JVM | ✅ built | +| ClientTLS (pure half) | `:client-tls` | pure Kotlin/JVM | ✅ built | +| TestSupport | `:test-support` | pure Kotlin/JVM (fakes) | ✅ built | +| ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ⏸ SDK-gated | +| HostRegistry | `:host-registry` | Android (DataStore) | ⏸ SDK-gated | +| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ⏸ SDK-gated | +| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ⏸ SDK-gated | + +> Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport` +> impls, JVM) is owned by task **A7** and will be added then. The iOS +> `URLSession*Transport`s consolidate into it (plan §3 framing note). + +### Dependency graph (arrows = "depends on") + +``` + :app (SDK-gated) + ┌───────────────┬───┴────┬──────────────┬───────────────┐ + ▼ ▼ ▼ ▼ ▼ + :terminal-view :session-core :api-client :host-registry :client-tls-android + (SDK-gated) │ │ (SDK-gated) │ + │ │ │ ▼ + │ │ │ :client-tls (pure) + └──────┬───────┴──────────┴──────────────┬────────────────┘ + ▼ ▼ + :wire-protocol ◀──────────── :transport-okhttp (A7, not yet) + ▲ + └──────── :test-support → test source sets only +``` + +`:wire-protocol` is the **frozen shared contract** (Android analogue of +`src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`, +`Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation), +and the `TermTransport` / `HttpTransport` / `PingableTermTransport` boundary +interfaces. New wire types are added **only** here (a coordination point). + +## Toolchain + +- **Gradle** 9.6.1 (via the committed wrapper — always use `./gradlew`). +- **Kotlin** 2.3.21 (matches the Kotlin embedded in Gradle 9.6.1). +- **JVM toolchain** 17 (`jvmToolchain(17)` in every module). +- Versions are pinned in the version catalog + [`gradle/libs.versions.toml`](gradle/libs.versions.toml): kotlinx-serialization-json, + kotlinx-coroutines-core/-test, JUnit5 (Jupiter), Turbine, MockK. + +Pure modules apply `kotlin("jvm")` + `kotlin("plugin.serialization")`, wire the +`libs.bundles.unit-test` bundle into `testImplementation`, and run tests on the +JUnit Platform (`tasks.test { useJUnitPlatform() }`). + +## Build & test + +```bash +# Use the committed wrapper for everything. +./gradlew help # sanity: the build configures +./gradlew projects # lists the 5 pure modules +./gradlew build # compile all pure modules +./gradlew test # run JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK) +``` + +> Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`, +> `:session-core`, `:api-client`, `:client-tls` pure half). TDD, immutable data, +> small focused files — same discipline as the rest of the repo. diff --git a/android/api-client/build.gradle.kts b/android/api-client/build.gradle.kts new file mode 100644 index 0000000..d0a3445 --- /dev/null +++ b/android/api-client/build.gradle.kts @@ -0,0 +1,27 @@ +// :api-client — pure REST client logic (12 routes, tolerant decode, Origin-iff- +// guarded, strict query encoding, prefs unknown-key preservation, pairing probe / +// PairingError / HostClassifier tiers). Consumes HttpTransport by interface only. +// Depends only on :wire-protocol. + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + api(project(":wire-protocol")) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + + testImplementation(project(":test-support")) + testImplementation(libs.bundles.unit.test) + testRuntimeOnly(libs.junit.platform.launcher) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/.gitkeep b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/HookDecision.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/HookDecision.kt new file mode 100644 index 0000000..dbaeadc --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/HookDecision.kt @@ -0,0 +1,10 @@ +package wang.yaojia.webterm.api.models + +/** + * The two verdicts `POST /hook/decision` accepts — anything else is a 400 server-side. The [wire] + * value is what goes into the request body (`{ sessionId, decision, token }`). + */ +public enum class HookDecision(public val wire: String) { + ALLOW("allow"), + DENY("deny"), +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/LiveSessionInfo.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/LiveSessionInfo.kt new file mode 100644 index 0000000..adebdcd --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/LiveSessionInfo.kt @@ -0,0 +1,41 @@ +package wang.yaojia.webterm.api.models + +import kotlinx.serialization.Serializable +import wang.yaojia.webterm.wire.ClaudeStatus +import wang.yaojia.webterm.wire.StatusTelemetry +import java.util.UUID + +/** + * One running (or just-exited) server session from `GET /live-sessions` (read-only discovery — NO + * Origin header). Mirrors `src/types.ts` `LiveSessionInfo` and iOS `APIClient.LiveSessionInfo` + * field-for-field. + * + * Tolerant decode at the untrusted boundary (plan §4): + * - identity/geometry ([id]/[createdAt]/[clientCount]/[exited]/[cols]/[rows]) are REQUIRED — an + * entry missing them is dropped by `LossyDecode.listOrNull`; + * - an unrecognized `status` string maps to [ClaudeStatus.UNKNOWN] (a future server status must + * not make a running session invisible); + * - absent `cwd`/`telemetry`/`lastOutputAt` degrade to `null`. + * + * NOTE: ms timestamps ([createdAt]/[lastOutputAt]) are `Long` — epoch-ms overflows a 32-bit `Int` + * (Swift's `Int` is 64-bit, so iOS used `Int`). + */ +@Serializable +public data class LiveSessionInfo( + @Serializable(with = UuidSerializer::class) val id: UUID, + /** Server `Date.now()` at spawn (ms since epoch). */ + val createdAt: Long, + /** Devices currently attached (JOIN/mirror semantics). */ + val clientCount: Int, + @Serializable(with = ClaudeStatusSerializer::class) val status: ClaudeStatus = ClaudeStatus.UNKNOWN, + val exited: Boolean, + val cwd: String? = null, + /** Current PTY size (latest-writer-wins on the server). */ + val cols: Int, + val rows: Int, + /** Latest statusLine telemetry, if any (B2). */ + val telemetry: StatusTelemetry? = null, + /** Server ms timestamp of the last PTY output (== createdAt until first output). Additive + * optional field — pre-P1 servers omit it; `null` means "no unread data source". */ + val lastOutputAt: Long? = null, +) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/ModelJson.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/ModelJson.kt new file mode 100644 index 0000000..da6b55d --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/ModelJson.kt @@ -0,0 +1,48 @@ +package wang.yaojia.webterm.api.models + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.decodeFromJsonElement + +/** + * Tolerant decode config for the UNTRUSTED server boundary (plan §4 / A8). The server is an + * untrusted input source here: unknown keys are ignored, malformed list elements are dropped one + * by one, and nothing crashes on bad input. The Android analogue of iOS's per-field `try?` + * tolerance in `APIClient/Models.swift`. + * + * ENCODE is deterministic (`ignoreUnknownKeys`/`isLenient` only affect parsing) so the same + * instance also encodes request bodies (`/hook/decision`, `/push/fcm-token`, `PUT /prefs`). + */ +internal val ModelJson: Json = Json { + ignoreUnknownKeys = true + isLenient = true +} + +/** + * Per-element / per-body tolerant decoders (mirror iOS `LiveSessionInfo.decodeList` / + * `LossyBox` / `LossyList`). + * + * - [listOrNull]: a non-array top level yields `null` (the caller raises + * `ApiClientError.InvalidResponseBody` — the pairing "port speaks HTTP but isn't web-terminal" + * signal); malformed elements are dropped, good ones kept. + * - [listOrEmpty]: a non-array top level yields `[]` (matches iOS `TimelineEvent.decodeList`, which + * the server itself returns when timeline capture is disabled). + * - [objectOrNull]: a single object that fails to decode yields `null` (caller raises + * `InvalidResponseBody`). + */ +internal object LossyDecode { + fun listOrNull(bytes: ByteArray, element: KSerializer): List? { + val array = parseArray(bytes) ?: return null + return array.mapNotNull { el -> runCatching { ModelJson.decodeFromJsonElement(element, el) }.getOrNull() } + } + + fun listOrEmpty(bytes: ByteArray, element: KSerializer): List = + listOrNull(bytes, element) ?: emptyList() + + fun objectOrNull(bytes: ByteArray, deserializer: KSerializer): T? = + runCatching { ModelJson.decodeFromString(deserializer, bytes.decodeToString()) }.getOrNull() + + private fun parseArray(bytes: ByteArray): JsonArray? = + runCatching { ModelJson.parseToJsonElement(bytes.decodeToString()) as? JsonArray }.getOrNull() +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Projects.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Projects.kt new file mode 100644 index 0000000..6595ea3 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Projects.kt @@ -0,0 +1,69 @@ +package wang.yaojia.webterm.api.models + +import kotlinx.serialization.Serializable +import wang.yaojia.webterm.wire.ClaudeStatus +import java.util.UUID + +/** + * One running session belonging to a project (`src/types.ts` `ProjectSessionRef`). The server + * mirrors [LiveSessionInfo] fields into this ref. Tolerant: unknown status → [ClaudeStatus.UNKNOWN], + * absent title → `null`; missing id/clientCount/createdAt/exited drops the ref (nested lossy list). + */ +@Serializable +public data class ProjectSessionRef( + @Serializable(with = UuidSerializer::class) val id: UUID, + val title: String? = null, + @Serializable(with = ClaudeStatusSerializer::class) val status: ClaudeStatus = ClaudeStatus.UNKNOWN, + val clientCount: Int, + val createdAt: Long, + val exited: Boolean, +) + +/** + * A discovered project (git repo or recently-used cwd) — `GET /projects` (`src/types.ts` + * `ProjectInfo`). There is NO `namespace` field on the wire; namespace grouping is a client concept + * surfaced only via `UiPrefs.collapsed` group-keys. + */ +@Serializable +public data class ProjectInfo( + val name: String, + val path: String, + val isGit: Boolean, + val branch: String? = null, + /** Uncommitted changes; only present when the server runs the dirty check. */ + val dirty: Boolean? = null, + /** Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. */ + val lastActiveMs: Long? = null, + @Serializable(with = ProjectSessionRefListSerializer::class) + val sessions: List = emptyList(), +) + +/** One entry from `git worktree list --porcelain` (`src/types.ts` `WorktreeInfo`). */ +@Serializable +public data class WorktreeInfo( + val path: String, + /** Branch name; `null` on detached HEAD. */ + val branch: String? = null, + val head: String? = null, + val isMain: Boolean = false, + val isCurrent: Boolean = false, + val locked: Boolean? = null, + val prunable: Boolean? = null, +) + +/** Detailed view of one project — `GET /projects/detail?path=` (`src/types.ts` `ProjectDetail`). */ +@Serializable +public data class ProjectDetail( + val name: String, + val path: String, + val isGit: Boolean, + val branch: String? = null, + val dirty: Boolean? = null, + @Serializable(with = WorktreeInfoListSerializer::class) + val worktrees: List = emptyList(), + @Serializable(with = ProjectSessionRefListSerializer::class) + val sessions: List = emptyList(), + val hasClaudeMd: Boolean = false, + /** CLAUDE.md content (server-truncated for display) when present. */ + val claudeMd: String? = null, +) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Serializers.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Serializers.kt new file mode 100644 index 0000000..2a6df4e --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Serializers.kt @@ -0,0 +1,59 @@ +package wang.yaojia.webterm.api.models + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.decodeFromJsonElement +import wang.yaojia.webterm.wire.ClaudeStatus +import java.util.UUID + +/** + * Decode a server session id as [UUID]. Server ids are lowercase `crypto.randomUUID()` strings; + * a non-UUID string throws, so `LossyDecode` drops that list element (matches iOS decoding + * `UUID.self` — a malformed id must not surface). Serializes back lowercase (`UUID.toString()`). + */ +internal object UuidSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString()) + override fun serialize(encoder: Encoder, value: UUID) = encoder.encodeString(value.toString()) +} + +/** + * Decode [ClaudeStatus] by its `wire` value; an unknown/future value maps to + * [ClaudeStatus.UNKNOWN] rather than dropping the entry — a new server status must never make a + * running session invisible (iOS `rawStatus.flatMap(...) ?? .unknown`). + */ +internal object ClaudeStatusSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ClaudeStatus", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): ClaudeStatus = + ClaudeStatus.fromWire(decoder.decodeString()) ?: ClaudeStatus.UNKNOWN + override fun serialize(encoder: Encoder, value: ClaudeStatus) = encoder.encodeString(value.wire) +} + +/** + * A `List` serializer that drops malformed elements and degrades a non-array to `[]` — the + * Android analogue of iOS's `LossyList.decode` for NESTED arrays (`ProjectInfo.sessions`, + * `ProjectDetail.worktrees`). A bad nested element must not fail the whole parent object. + */ +internal class LossyListSerializer(private val element: KSerializer) : KSerializer> { + private val delegate = ListSerializer(element) + override val descriptor: SerialDescriptor = delegate.descriptor + override fun serialize(encoder: Encoder, value: List) = delegate.serialize(encoder, value) + override fun deserialize(decoder: Decoder): List { + val json = decoder as? JsonDecoder ?: return delegate.deserialize(decoder) + val array = json.decodeJsonElement() as? JsonArray ?: return emptyList() + return array.mapNotNull { runCatching { json.json.decodeFromJsonElement(element, it) }.getOrNull() } + } +} + +internal object ProjectSessionRefListSerializer : + KSerializer> by LossyListSerializer(ProjectSessionRef.serializer()) + +internal object WorktreeInfoListSerializer : + KSerializer> by LossyListSerializer(WorktreeInfo.serializer()) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/SessionPreview.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/SessionPreview.kt new file mode 100644 index 0000000..6fd0e69 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/SessionPreview.kt @@ -0,0 +1,17 @@ +package wang.yaojia.webterm.api.models + +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * `GET /live-sessions/:id/preview` response: the tail of the session's ring buffer for a + * read-only thumbnail (no attach, no client registered). [data] is opaque ANSI/UTF-8 — feed it to + * a terminal, never parse it. All fields required (a malformed body → `InvalidResponseBody`). + */ +@Serializable +public data class SessionPreview( + @Serializable(with = UuidSerializer::class) val id: UUID, + val cols: Int, + val rows: Int, + val data: String, +) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/UiConfig.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/UiConfig.kt new file mode 100644 index 0000000..3092614 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/UiConfig.kt @@ -0,0 +1,12 @@ +package wang.yaojia.webterm.api.models + +import kotlinx.serialization.Serializable + +/** + * `GET /config/ui` response (`{ allowAutoMode }`). Reserved for a future permission-mode switcher + * (filters the high-risk raw `auto` mode); the plan-gate three-way UI does not consume it. + */ +@Serializable +public data class UiConfig( + val allowAutoMode: Boolean, +) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/UiPrefs.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/UiPrefs.kt new file mode 100644 index 0000000..87c3510 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/UiPrefs.kt @@ -0,0 +1,106 @@ +package wang.yaojia.webterm.api.models + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull + +/** + * The cross-device Projects-UI prefs blob (`GET /prefs` RO · `PUT /prefs` G) — an + * OPAQUE-BUT-VALIDATED JSON object round-trip. Known keys mirror the web client + * (`favourites: string[]`, `collapsed: { group-key: true }`); ALL OTHER top-level keys are + * preserved verbatim across decode → mutate → encode, so an Android `PUT` can never clobber prefs + * written by the web/iOS client or a future server (`PUT` replaces the WHOLE blob server-side, so + * key preservation is correctness, not politeness). + * + * Immutable snapshot: [withFavourites]/[withCollapsed] return NEW copies that replace exactly one + * known key and carry every other key through untouched. Numbers round-trip verbatim because a + * parsed [JsonPrimitive] keeps its source token (`42` never re-encodes as `42.0`). + */ +public class UiPrefs private constructor(private val storage: JsonObject) { + + /** + * Favourited project paths (★): non-empty JSON strings, de-duplicated, original order kept. + * Wrong-typed entries are dropped, not fatal (mirrors `public/prefs.ts sanitizePrefs`). + */ + public val favourites: List + get() { + val array = storage[KEY_FAVOURITES] as? JsonArray ?: return emptyList() + val out = LinkedHashSet() + for (element in array) { + val primitive = element as? JsonPrimitive ?: continue + if (!primitive.isString) continue + val path = primitive.content + if (path.isNotEmpty()) out.add(path) + } + return out.toList() + } + + /** + * Namespace group-key → collapsed. Only literal `true` values count (expanded is the default; + * both web and server sanitizers agree). A JSON string `"true"` does NOT count. + */ + public val collapsed: Map + get() { + val obj = storage[KEY_COLLAPSED] as? JsonObject ?: return emptyMap() + val out = LinkedHashMap() + for ((key, value) in obj) { + if (key.isEmpty()) continue + if (value is JsonPrimitive && !value.isString && value.booleanOrNull == true) { + out[key] = true + } + } + return out + } + + /** New snapshot with `favourites` replaced; every other key untouched (position preserved). */ + public fun withFavourites(favourites: List): UiPrefs { + val next = LinkedHashMap(storage) + next[KEY_FAVOURITES] = JsonArray(favourites.map { JsonPrimitive(it) }) + return UiPrefs(JsonObject(next)) + } + + /** New snapshot with `collapsed` replaced; every other key untouched (position preserved). */ + public fun withCollapsed(collapsed: Map): UiPrefs { + val next = LinkedHashMap(storage) + next[KEY_COLLAPSED] = JsonObject(collapsed.mapValues { JsonPrimitive(it.value) }) + return UiPrefs(JsonObject(next)) + } + + /** Encode the FULL blob (known + unknown keys) for `PUT /prefs`. */ + public fun encodeBody(): ByteArray = + ModelJson.encodeToString(JsonObject.serializer(), storage).encodeToByteArray() + + override fun equals(other: Any?): Boolean = other is UiPrefs && other.storage == storage + override fun hashCode(): Int = storage.hashCode() + override fun toString(): String = "UiPrefs(storage=$storage)" + + public companion object { + private const val KEY_FAVOURITES = "favourites" + private const val KEY_COLLAPSED = "collapsed" + + /** Fresh prefs (e.g. first write from a device that never fetched). */ + public fun create( + favourites: List = emptyList(), + collapsed: Map = emptyMap(), + ): UiPrefs = UiPrefs( + JsonObject( + mapOf( + KEY_FAVOURITES to JsonArray(favourites.map { JsonPrimitive(it) }), + KEY_COLLAPSED to JsonObject(collapsed.mapValues { JsonPrimitive(it.value) }), + ), + ), + ) + + /** + * Decode a `/prefs` body. `null` = top level is not a JSON object — callers surface + * `InvalidResponseBody` LOUDLY instead of degrading to empty prefs (an empty-based `PUT` + * would wipe the server blob). + */ + public fun decode(bytes: ByteArray): UiPrefs? { + val element = runCatching { ModelJson.parseToJsonElement(bytes.decodeToString()) }.getOrNull() + return (element as? JsonObject)?.let { UiPrefs(it) } + } + } +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingError.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingError.kt new file mode 100644 index 0000000..5fdb730 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingError.kt @@ -0,0 +1,131 @@ +package wang.yaojia.webterm.api.pairing + +import wang.yaojia.webterm.wire.HostClassifier +import wang.yaojia.webterm.wire.HostEndpoint +import java.io.InterruptedIOException +import java.net.ConnectException +import java.net.NoRouteToHostException +import java.net.PortUnreachableException +import java.net.SocketException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.net.UnknownServiceException +import java.security.cert.CertPathBuilderException +import java.security.cert.CertPathValidatorException +import java.security.cert.CertificateException +import javax.net.ssl.SSLException + +/** + * Error taxonomy for the pairing probe (Android port of iOS `APIClient.PairingError`). Each case + * maps one probe failure mode to actionable UI copy (`PairingViewModel`, A19, renders these). + * + * DEVIATION from iOS (plan R12): the iOS `localNetworkDenied` case is DROPPED — Android has no + * iOS-style Local-Network permission prompt (a plain WS to a LAN IP needs no runtime permission), + * so the ENETDOWN→localNetworkDenied diagnosis is dead weight here. iOS `atsBlocked` is kept as + * [CleartextBlocked]: the genuine Android analogue is a `network_security_config` cleartext block + * surfaced as [java.net.UnknownServiceException] (plan §6.9/§8 cleartext posture). + */ +public sealed interface PairingError { + /** Nothing answered — connection refused / no route / DNS failure (`ConnectException`, `UnknownHostException`, …). */ + public data class HostUnreachable(val underlying: String) : PairingError + + /** + * The port speaks HTTP but `GET /live-sessions` did not return the web-terminal shape (a JSON + * array) — "端口对吗?". Also used when probe ② connects but the socket never speaks our protocol. + */ + public data object HttpOkButNotWebTerminal : PairingError + + /** + * The WS upgrade (or the guarded kill round-trip) was rejected — the host's Origin whitelist + * does not contain our dialed origin. [hint] carries the exact `ALLOWED_ORIGINS=` line, + * always derived from [HostEndpoint.originHeader] (never hand-assembled; default ports omitted). + */ + public data class OriginRejected(val hint: String) : PairingError + + /** + * Cleartext (`ws://`/`http://`) to a host outside the app's `network_security_config` allowlist + * was blocked by the platform ([java.net.UnknownServiceException]). Android analogue of iOS + * `atsBlocked`. [host] is the dialed host for the actionable copy. + */ + public data class CleartextBlocked(val host: String) : PairingError + + /** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */ + public data object TlsFailure : PairingError + + /** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */ + public data object Timeout : PairingError + + public companion object { + /** + * Actionable copy for [OriginRejected] — always derived from the SINGLE origin source + * ([HostEndpoint.originHeader]), never hand-assembled. Ported verbatim from iOS. + */ + public fun originRejectedHint(endpoint: HostEndpoint): String = + "服务器拒绝了这个来源。请在主机上设置 ALLOWED_ORIGINS=${endpoint.originHeader}" + + "(与 App 连接的 URL 完全一致)后重启 web-terminal,再重试配对。" + + /** + * Classify a transport-level [Throwable] thrown by [wang.yaojia.webterm.wire.HttpTransport] + * or [wang.yaojia.webterm.wire.TermTransport] into the probe taxonomy. Walks the bounded + * `cause` chain (OkHttp wraps causes; never trust an error graph not to cycle) so wrapped + * roots (e.g. an `IOException` wrapping a `ConnectException`) are seen. + * + * @param unrecognizedFallback used by probe step ② — after step ① proved the host reachable + * AND web-terminal-shaped, an upgrade failure with no recognizable network cause is, by + * elimination, the server's Origin 401 (its only upgrade-reject path). + */ + public fun classify( + error: Throwable, + endpoint: HostEndpoint, + unrecognizedFallback: PairingError? = null, + ): PairingError { + val chain = causeChain(error) + if (chain.any { it is UnknownServiceException }) { + return CleartextBlocked(host = HostClassifier.hostOf(endpoint)) + } + if (chain.any { isTimeout(it) }) return Timeout + if (chain.any { isTls(it) }) return TlsFailure + if (chain.any { isConnectivity(it) }) { + return HostUnreachable(underlying = describe(error)) + } + return unrecognizedFallback ?: HostUnreachable(underlying = describe(error)) + } + + private const val MAX_CAUSE_DEPTH = 8 + + /** Bounded walk of [Throwable.cause] with an identity cycle-guard (defensive). */ + private fun causeChain(error: Throwable): List { + val chain = mutableListOf() + var current: Throwable? = error + while (current != null && chain.size < MAX_CAUSE_DEPTH) { + if (chain.any { it === current }) break + chain.add(current) + current = current.cause + } + return chain + } + + // `SocketTimeoutException` extends `InterruptedIOException`; OkHttp's overall call-timeout + // also surfaces as a bare `InterruptedIOException` — both mean "timed out". + private fun isTimeout(e: Throwable): Boolean = e is InterruptedIOException + + private fun isTls(e: Throwable): Boolean = + e is SSLException || + e is CertificateException || + e is CertPathValidatorException || + e is CertPathBuilderException + + // `ConnectException` / `NoRouteToHostException` / `PortUnreachableException` all extend + // `SocketException`; listed explicitly for readability. `UnknownHostException` is a DNS + // failure (extends `IOException`, not `SocketException`). + private fun isConnectivity(e: Throwable): Boolean = + e is ConnectException || + e is NoRouteToHostException || + e is PortUnreachableException || + e is SocketException || + e is UnknownHostException + + private fun describe(error: Throwable): String = + error.message ?: error::class.simpleName ?: "unknown" + } +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingProbe.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingProbe.kt new file mode 100644 index 0000000..a04320f --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingProbe.kt @@ -0,0 +1,227 @@ +package wang.yaojia.webterm.api.pairing + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest +import wang.yaojia.webterm.wire.HttpTransport +import wang.yaojia.webterm.wire.MessageCodec +import wang.yaojia.webterm.wire.ServerMessage +import wang.yaojia.webterm.wire.TermTransport +import wang.yaojia.webterm.wire.TransportConnection +import wang.yaojia.webterm.wire.Tunables +import java.net.URI +import kotlin.time.Duration + +/** + * Result of a pairing probe — the validated [HostEndpoint] on success, a [PairingError] on failure. + * Android analogue of iOS `Result`. The pairing UI (A19) constructs the + * persisted `Host{id,name}` from the returned endpoint (id/name are not the probe's to know). + */ +public sealed interface PairingProbeResult { + public data class Success(val endpoint: HostEndpoint) : PairingProbeResult + + public data class Failure(val error: PairingError) : PairingProbeResult +} + +/** + * Public probe entry (Android port of iOS `runPairingProbe`). Two-step probe: + * 1. `GET /live-sessions` (NO Origin) — reachability + web-terminal shape. + * 2. WS `attach(null)` → adopt the server-issued `attached` id → close → **immediately + * `DELETE /live-sessions/:id` WITH Origin** (verifies the Origin guard on both the upgrade and + * the guarded-HTTP side, and never leaks the probe's orphan session). + * + * **Confirm-before-network contract:** callers MUST run this only after the user confirmed a + * scanned/typed host (A19). Step ① already talks to the network and step ② spawns a PTY on the + * target machine — nothing here is speculative, so the UI gate is the caller's responsibility. + * + * The wall-clock deadline is [Tunables.PAIRING_PROBE_TIMEOUT]; tests drive [runPairingProbeCore] + * with an explicit (or `null`) timeout for deterministic virtual-time coverage. + */ +public suspend fun runPairingProbe( + endpoint: HostEndpoint, + http: HttpTransport, + ws: TermTransport, +): PairingProbeResult = + runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT) + +/** + * Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts + * still apply — fast, race-free tests); otherwise the whole probe is cancelled past the deadline and + * resolves [PairingError.Timeout]. Cancellation is the coroutine analogue of iOS's `group.cancelAll`. + */ +internal suspend fun runPairingProbeCore( + endpoint: HostEndpoint, + http: HttpTransport, + ws: TermTransport, + timeout: Duration?, +): PairingProbeResult { + if (timeout == null) return performProbe(endpoint, http, ws) + return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws) } + ?: PairingProbeResult.Failure(PairingError.Timeout) +} + +// ── Probe body ──────────────────────────────────────────────────────────────────────────────── + +private suspend fun performProbe( + endpoint: HostEndpoint, + http: HttpTransport, + ws: TermTransport, +): PairingProbeResult { + // ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape + // means "some other service" → httpOkButNotWebTerminal ("端口对吗?"). + probeReachability(endpoint, http)?.let { return it } + + // ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an + // unrecognizable connect failure is classified as originRejected. + val connection: TransportConnection = try { + ws.connect(endpoint) + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Throwable) { + return PairingProbeResult.Failure( + PairingError.classify( + error, + endpoint, + unrecognizedFallback = PairingError.OriginRejected( + PairingError.originRejectedHint(endpoint), + ), + ), + ) + } + + // Once connected, the connection MUST be closed on every exit path — including the timeout/cancel + // path, where withTimeoutOrNull cancels us mid-`firstOrNull`. Without this finally, a cancel skips + // close() and leaks the WS + its orphan PTY session on the host. NonCancellable so the close still + // runs while we are already being cancelled. + return try { + when (val adoption = adoptAttachedSession(connection)) { + is Adoption.Failure -> PairingProbeResult.Failure(adoption.error) + is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http) + } + } finally { + withContext(NonCancellable) { runCatching { connection.close() } } + } +} + +/** + * Probe step ①. Returns a [PairingProbeResult.Failure] to short-circuit, or `null` to proceed. + * Reachable + web-terminal-shaped = HTTP 200 with a JSON-array body (an HTML admin page, a 404, or + * a non-array body are all "not web-terminal"). + */ +private suspend fun probeReachability( + endpoint: HostEndpoint, + http: HttpTransport, +): PairingProbeResult.Failure? { + val response = try { + http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint))) + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Throwable) { + return PairingProbeResult.Failure(PairingError.classify(error, endpoint)) + } + if (response.status != HTTP_OK || !isJsonArray(response.body)) { + return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) + } + return null +} + +/** + * Send `attach(null)` (explicit JSON `"sessionId":null` via [MessageCodec]) and wait for the + * server-issued `attached` id, skipping any other or undecodable frame (untrusted server; only + * `attached` matters here). A stream that ends or errors before speaking our protocol is NOT an + * Origin problem — the upgrade already succeeded — so it maps to [PairingError.HttpOkButNotWebTerminal]. + */ +private suspend fun adoptAttachedSession(connection: TransportConnection): Adoption = + try { + connection.send(MessageCodec.encode(ClientMessage.Attach(sessionId = null))) + val sessionId = connection.frames + .mapNotNull { frame -> (MessageCodec.decodeServer(frame) as? ServerMessage.Attached)?.sessionId } + .firstOrNull() + if (sessionId != null) Adoption.Success(sessionId) else Adoption.Failure(PairingError.HttpOkButNotWebTerminal) + } catch (cancel: CancellationException) { + throw cancel + } catch (_: Throwable) { + Adoption.Failure(PairingError.HttpOkButNotWebTerminal) + } + +/** + * The guarded kill round-trip is part of pairing verification itself (`DELETE` exercises the + * HTTP-side Origin guard the later `hookDecision` will need) AND guarantees the probe leaves no + * orphan session. 204 = killed, 404 = already gone (both success), 403 = Origin guard rejected us. + */ +private suspend fun killProbeSession( + sessionId: String, + endpoint: HostEndpoint, + http: HttpTransport, +): PairingProbeResult { + val response = try { + http.send( + HttpRequest( + method = HttpMethod.DELETE, + url = killUrl(endpoint, sessionId), + headers = mapOf(ORIGIN_HEADER to endpoint.originHeader), + ), + ) + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Throwable) { + return PairingProbeResult.Failure(PairingError.classify(error, endpoint)) + } + return when (response.status) { + HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint) + HTTP_FORBIDDEN -> PairingProbeResult.Failure( + PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)), + ) + else -> PairingProbeResult.Failure( + PairingError.HostUnreachable(underlying = "HTTP ${response.status}"), + ) + } +} + +private sealed interface Adoption { + data class Success(val sessionId: String) : Adoption + + data class Failure(val error: PairingError) : Adoption +} + +// ── URL derivation + shape check ──────────────────────────────────────────────────────────────── + +private val PROBE_JSON = Json { ignoreUnknownKeys = true; isLenient = true } + +/** Web-terminal shape = the body parses as a JSON array (the server replies `[]` when idle). */ +private fun isJsonArray(body: ByteArray): Boolean = + runCatching { PROBE_JSON.parseToJsonElement(body.decodeToString()) is JsonArray }.getOrDefault(false) + +private fun liveSessionsUrl(endpoint: HostEndpoint): String = httpBaseUrl(endpoint) + LIVE_SESSIONS_PATH + +private fun killUrl(endpoint: HostEndpoint, sessionId: String): String = + httpBaseUrl(endpoint) + LIVE_SESSIONS_PATH + "/" + sessionId + +/** + * `scheme://host[:port]` from the endpoint's dialed URL — path/query/fragment/credentials dropped + * (same derivation philosophy as [HostEndpoint.wsUrl]). The dialed port is kept verbatim; the host + * is left as [java.net.URI] returns it (IPv6 literals are already bracketed). + */ +private fun httpBaseUrl(endpoint: HostEndpoint): String { + val uri = URI(endpoint.baseUrl) + val scheme = (uri.scheme ?: "http").lowercase() + val host = uri.host ?: "" + val portPart = if (uri.port != -1) ":${uri.port}" else "" + return "$scheme://$host$portPart" +} + +private const val LIVE_SESSIONS_PATH = "/live-sessions" +private const val ORIGIN_HEADER = "Origin" +private const val HTTP_OK = 200 +private const val HTTP_NO_CONTENT = 204 +private const val HTTP_FORBIDDEN = 403 +private const val HTTP_NOT_FOUND = 404 diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt new file mode 100644 index 0000000..0bed89b --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt @@ -0,0 +1,173 @@ +package wang.yaojia.webterm.api.routes + +import wang.yaojia.webterm.api.models.HookDecision +import wang.yaojia.webterm.api.models.LiveSessionInfo +import wang.yaojia.webterm.api.models.LossyDecode +import wang.yaojia.webterm.api.models.ProjectDetail +import wang.yaojia.webterm.api.models.ProjectInfo +import wang.yaojia.webterm.api.models.SessionPreview +import wang.yaojia.webterm.api.models.UiConfig +import wang.yaojia.webterm.api.models.UiPrefs +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpResponse +import wang.yaojia.webterm.wire.HttpTransport +import wang.yaojia.webterm.wire.TimelineEvent +import java.util.UUID + +/** + * Typed client for the server's HTTP surface (12 frozen routes). Pure — consumes [HttpTransport] + * by interface (no OkHttp/Android); `:transport-okhttp` (A7) provides the real impl, the + * `:test-support` fake queues canned responses. + * + * **Origin 铁律 (CSWSH, plan §4.3):** only the guarded (state-changing) routes stamp + * `Origin: endpoint.originHeader`; the read-only GETs never do. Stamping lives in ONE place + * ([ApiRoute.toHttpRequest]) and the value is single-point-derived by [HostEndpoint]. + * + * The server is UNTRUSTED at this boundary: bodies decode tolerantly (malformed entries dropped), + * statuses map to explicit [ApiClientError]s, and nothing here crashes on bad input. + */ +public class ApiClient( + public val endpoint: HostEndpoint, + private val http: HttpTransport, +) { + // ── RO (read-only — NO Origin header) ────────────────────────────────────────────────── + + /** `GET /live-sessions` — the discovery list every device polls. */ + public suspend fun liveSessions(): List { + val response = perform(Endpoints.liveSessions()) + if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status) + return LossyDecode.listOrNull(response.body, LiveSessionInfo.serializer()) + ?: throw ApiClientError.InvalidResponseBody + } + + /** `GET /live-sessions/:id/preview` — ring-buffer tail for a read-only thumbnail (no attach). */ + public suspend fun preview(id: UUID): SessionPreview { + val response = perform(Endpoints.preview(id)) + requireOk(response) + return LossyDecode.objectOrNull(response.body, SessionPreview.serializer()) + ?: throw ApiClientError.InvalidResponseBody + } + + /** `GET /live-sessions/:id/events` — the activity timeline. A non-array body (timeline capture + * disabled) → `[]`; unknown-class entries survive shape-decode and are filtered downstream. */ + public suspend fun events(id: UUID): List { + val response = perform(Endpoints.events(id)) + requireOk(response) + return LossyDecode.listOrEmpty(response.body, TimelineEvent.serializer()) + } + + /** `GET /config/ui` — `{ allowAutoMode }`. */ + public suspend fun uiConfig(): UiConfig { + val response = perform(Endpoints.uiConfig()) + requireOk(response) + return LossyDecode.objectOrNull(response.body, UiConfig.serializer()) + ?: throw ApiClientError.InvalidResponseBody + } + + /** `GET /projects` — discovered projects with their running sessions merged in. */ + public suspend fun projects(): List { + val response = perform(Endpoints.projects()) + if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status) + return LossyDecode.listOrNull(response.body, ProjectInfo.serializer()) + ?: throw ApiClientError.InvalidResponseBody + } + + /** `GET /projects/detail?path=` — branch/worktrees/CLAUDE.md for one project. An empty path is + * rejected client-side (mirror of the server's 400) before any network I/O. */ + public suspend fun projectDetail(path: String): ProjectDetail { + if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid + val response = perform(Endpoints.projectDetail(path)) + return when (response.status) { + HttpStatus.OK -> LossyDecode.objectOrNull(response.body, ProjectDetail.serializer()) + ?: throw ApiClientError.InvalidResponseBody + HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid + HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound + HttpStatus.INTERNAL_SERVER_ERROR -> throw ApiClientError.ProjectDetailUnavailable + else -> throw ApiClientError.UnexpectedStatus(response.status) + } + } + + /** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws + * `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */ + public suspend fun prefs(): UiPrefs { + val response = perform(Endpoints.getPrefs()) + if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status) + return UiPrefs.decode(response.body) ?: throw ApiClientError.InvalidResponseBody + } + + // ── G (state-changing — Origin required, byte-equal) ─────────────────────────────────── + + /** `DELETE /live-sessions/:id`. 204 = success; 404 = already gone (also success on the server, + * but iOS surfaces it as `SessionNotFound` — matched here). */ + public suspend fun killSession(id: UUID) { + val response = perform(Endpoints.killSession(id)) + when (response.status) { + HttpStatus.NO_CONTENT -> Unit + HttpStatus.NOT_FOUND -> throw ApiClientError.SessionNotFound + HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden + else -> throw ApiClientError.UnexpectedStatus(response.status) + } + } + + /** `POST /hook/decision` — resolve a held remote approval with a single-use `token` (push + * payload only; NEVER persist it). 403 → stale/mismatched token; 429 → rate-limited. */ + public suspend fun hookDecision(sessionId: UUID, decision: HookDecision, token: String) { + val response = perform(Endpoints.hookDecision(sessionId, decision, token)) + when (response.status) { + HttpStatus.NO_CONTENT -> Unit + HttpStatus.FORBIDDEN -> throw ApiClientError.DecisionRejected + HttpStatus.TOO_MANY_REQUESTS -> throw ApiClientError.RateLimited + else -> throw ApiClientError.UnexpectedStatus(response.status) + } + } + + /** `PUT /prefs` — replace the whole blob. Returns the server's sanitized echo — treat IT as the + * new source of truth, not the input. 403 = Origin guard. */ + public suspend fun putPrefs(prefs: UiPrefs): UiPrefs { + val response = perform(Endpoints.putPrefs(prefs)) + return when (response.status) { + HttpStatus.OK -> UiPrefs.decode(response.body) ?: throw ApiClientError.InvalidResponseBody + HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden + else -> throw ApiClientError.UnexpectedStatus(response.status) + } + } + + /** `POST /push/fcm-token` — register this device's FCM token (idempotent upsert → 204). Invalid + * tokens are rejected client-side (`InvalidFcmToken`) before any network I/O. */ + public suspend fun registerFcmToken(token: String) { + sendFcmToken(token, Endpoints::registerFcmToken) + } + + /** `DELETE /push/fcm-token` — unregister (idempotent → 204 even for an unknown token). */ + public suspend fun unregisterFcmToken(token: String) { + sendFcmToken(token, Endpoints::unregisterFcmToken) + } + + private suspend fun sendFcmToken(token: String, build: (String) -> ApiRoute) { + val normalized = FcmTokenRule.normalize(token) ?: throw ApiClientError.InvalidFcmToken + val response = perform(build(normalized)) + when (response.status) { + HttpStatus.NO_CONTENT -> Unit + HttpStatus.BAD_REQUEST -> throw ApiClientError.InvalidFcmToken + HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden + HttpStatus.TOO_MANY_REQUESTS -> throw ApiClientError.RateLimited + else -> throw ApiClientError.UnexpectedStatus(response.status) + } + } + + // ── Internals ────────────────────────────────────────────────────────────────────────── + + private suspend fun perform(route: ApiRoute): HttpResponse { + val request = route.toHttpRequest(endpoint) ?: throw ApiClientError.InvalidRequest + return http.send(request) + } + + /** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */ + private fun requireOk(response: HttpResponse) { + when (response.status) { + HttpStatus.OK -> Unit + HttpStatus.NOT_FOUND -> throw ApiClientError.SessionNotFound + else -> throw ApiClientError.UnexpectedStatus(response.status) + } + } +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt new file mode 100644 index 0000000..3d20486 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt @@ -0,0 +1,49 @@ +package wang.yaojia.webterm.api.routes + +/** + * Typed failures for [ApiClient] calls (explicit error handling, plan §4). Transport-level errors + * thrown by `HttpTransport.send` propagate UNWRAPPED (the pairing classifier reads their shape). + * + * [userMessage] is UI-ready copy (matches the iOS `APIClientError.message` 话术). The no-arg cases + * are singletons (`object`) so tests can assert by identity/equality; [UnexpectedStatus] carries the + * offending code. + */ +public sealed class ApiClientError(public val userMessage: String) : Exception(userMessage) { + /** The request could not be built from the endpoint (malformed base URL — should not happen for + * a validated `HostEndpoint`; surfaced instead of crashing). */ + public data object InvalidRequest : ApiClientError("无法构造请求(主机地址异常)。") + + /** A 2xx arrived but the body is not the endpoint's shape. For `/live-sessions` this is the + * pairing probe's "the port speaks HTTP but is not web-terminal" signal. */ + public data object InvalidResponseBody : ApiClientError("服务器响应不是预期格式——端口对吗?") + + /** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */ + public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。") + + /** 403 from a G route's Origin guard (CSWSH defence). */ + public data object Forbidden : ApiClientError("服务器拒绝了此来源(Origin 校验未通过)。") + + /** 403 from `POST /hook/decision`: the capability token is missing / mismatched / STALE — + * tokens are single-use and expiring by design. */ + public data object DecisionRejected : ApiClientError("审批令牌已过期或已被处理——请回到终端里直接批准/拒绝。") + + /** 429: the endpoint is rate-limited per IP by fixed server policy. */ + public data object RateLimited : ApiClientError("操作过于频繁,服务器已限流,请稍后再试。") + + /** The FCM registration token failed the client-side charset/length check, or the server echoed + * a 400. */ + public data object InvalidFcmToken : ApiClientError("推送注册令牌格式异常,请重启 App 重新注册推送。") + + /** 400 from `GET /projects/detail` — the `path` query parameter is missing/empty. Also raised + * client-side for an empty path, before any network I/O. */ + public data object ProjectPathInvalid : ApiClientError("项目路径为空或不合法。") + + /** 404 from `GET /projects/detail` — no project at that path (moved/deleted/not a directory). */ + public data object ProjectNotFound : ApiClientError("项目不存在(路径可能已移动或删除)。") + + /** 500 from `GET /projects/detail` — the server failed reading the repo. */ + public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。") + + /** Any other non-success status code. */ + public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status。") +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt new file mode 100644 index 0000000..f3f599b --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt @@ -0,0 +1,91 @@ +package wang.yaojia.webterm.api.routes + +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest +import java.net.URI + +/** Named HTTP status codes used by the client (no magic numbers, plan §4). */ +internal object HttpStatus { + const val OK = 200 + const val NO_CONTENT = 204 + const val BAD_REQUEST = 400 + const val FORBIDDEN = 403 + const val NOT_FOUND = 404 + const val TOO_MANY_REQUESTS = 429 + const val INTERNAL_SERVER_ERROR = 500 +} + +/** Header / content-type names (no magic strings inline). */ +internal object HeaderName { + const val ORIGIN = "Origin" + const val CONTENT_TYPE = "Content-Type" +} + +internal object ContentType { + const val JSON = "application/json" +} + +/** + * Whether a route mutates server state — THE security split (plan §4.3): `Origin` is stamped + * **iff** [GUARDED]. If the server ever reclassifies a RO route as guarded, tests go red instead of + * passing by coincidence. + */ +internal enum class OriginPolicy { + /** Read-only GET — MUST NOT carry Origin. */ + READ_ONLY, + + /** State-changing — MUST carry `Origin: endpoint.originHeader`, byte-equal; server 403s a + * missing/foreign Origin (CSWSH defence). */ + GUARDED, +} + +/** + * One buildable API route — an immutable snapshot; building never mutates. The Android analogue of + * iOS `APIRoute`. [percentEncodedQuery] is pre-encoded ONCE by the route builder (never at call + * sites). Origin stamping happens HERE and only here (single point). + */ +internal class ApiRoute( + val method: HttpMethod, + val path: String, + val originPolicy: OriginPolicy, + val body: ByteArray? = null, + val percentEncodedQuery: String? = null, +) { + /** + * Build the [HttpRequest] against [endpoint]'s scheme/host/port: the path is REPLACED, the + * query is REPLACED by [percentEncodedQuery] (dropped when null), fragment/credentials are + * dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base + * URL cannot be parsed (surfaced by the client as `InvalidRequest`). + */ + fun toHttpRequest(endpoint: HostEndpoint): HttpRequest? { + val url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null + val headers = LinkedHashMap() + if (originPolicy == OriginPolicy.GUARDED) { + headers[HeaderName.ORIGIN] = endpoint.originHeader + } + if (body != null) { + headers[HeaderName.CONTENT_TYPE] = ContentType.JSON + } + return HttpRequest(method = method, url = url, headers = headers, body = body) + } + + private companion object { + /** + * Rebuild `://[:][?]` from the dialed base URL, keeping the + * dialed port verbatim (like `wsUrl`, unlike the default-port-dropping Origin). The path and + * pre-encoded query are appended verbatim; any path/query/fragment/credentials the base URL + * carried are dropped. + */ + fun buildUrl(baseUrl: String, path: String, query: String?): String? { + val uri = runCatching { URI(baseUrl.trim()) }.getOrNull() ?: return null + val scheme = uri.scheme?.lowercase() ?: return null + val host = uri.host ?: return null + if (host.isEmpty()) return null + val serializedHost = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host + val portPart = if (uri.port != -1) ":${uri.port}" else "" + val queryPart = if (query != null) "?$query" else "" + return "$scheme://$serializedHost$portPart$path$queryPart" + } + } +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt new file mode 100644 index 0000000..b37274f --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt @@ -0,0 +1,127 @@ +package wang.yaojia.webterm.api.routes + +import kotlinx.serialization.Serializable +import wang.yaojia.webterm.api.models.HookDecision +import wang.yaojia.webterm.api.models.ModelJson +import wang.yaojia.webterm.api.models.UiPrefs +import wang.yaojia.webterm.wire.HttpMethod +import java.util.UUID + +/** + * Builders for the frozen endpoints (12 routes, verified against `src/http/…` + iOS `Endpoints` / + * `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token** + * pair (this client uses FCM, not APNs/VAPID). + * + * RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `GET /config/ui` + * · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs` + * G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST /hook/decision` · `PUT /prefs` + * · `POST|DELETE /push/fcm-token` + * + * KNOWN WIRE-PARITY GAP (intentional, not drift): the `POST|DELETE /push/fcm-token` pair is AHEAD + * of the server. Its server route is delivered by plan task **A33** (`src/push/fcm.ts` + + * `/push/fcm-token`), which is still PENDING — so FCM push is non-functional against the current + * server until A33 lands. The client builders exist now so the token lifecycle is ready the moment + * the server route ships; do not "fix" this as a mismatch. + */ +internal object Endpoints { + // ── RO ─────────────────────────────────────────────────────────────────────────────── + + fun liveSessions(): ApiRoute = + ApiRoute(HttpMethod.GET, "/live-sessions", OriginPolicy.READ_ONLY) + + fun preview(id: UUID): ApiRoute = + ApiRoute(HttpMethod.GET, "/live-sessions/${pathId(id)}/preview", OriginPolicy.READ_ONLY) + + fun events(id: UUID): ApiRoute = + ApiRoute(HttpMethod.GET, "/live-sessions/${pathId(id)}/events", OriginPolicy.READ_ONLY) + + fun uiConfig(): ApiRoute = + ApiRoute(HttpMethod.GET, "/config/ui", OriginPolicy.READ_ONLY) + + fun projects(): ApiRoute = + ApiRoute(HttpMethod.GET, "/projects", OriginPolicy.READ_ONLY) + + /** + * `GET /projects/detail?path=` — RO. The ONE place `path` gets percent-encoded, with a strict + * RFC 3986 unreserved-only set (deliberately stricter than URL-query-allowed: a bare `+` decodes + * to a SPACE in Express's qs parser, and `&`/`=` would split the parameter). + */ + fun projectDetail(path: String): ApiRoute = + ApiRoute( + HttpMethod.GET, + "/projects/detail", + OriginPolicy.READ_ONLY, + percentEncodedQuery = "path=${percentEncode(path)}", + ) + + fun getPrefs(): ApiRoute = + ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY) + + // ── G ──────────────────────────────────────────────────────────────────────────────── + + fun killSession(id: UUID): ApiRoute = + ApiRoute(HttpMethod.DELETE, "/live-sessions/${pathId(id)}", OriginPolicy.GUARDED) + + /** Body is exactly `{ sessionId, decision, token }`. `token` is single-use (push payload only) — + * callers must never persist/log it. */ + fun hookDecision(sessionId: UUID, decision: HookDecision, token: String): ApiRoute { + val body = ModelJson.encodeToString( + HookDecisionBody.serializer(), + HookDecisionBody(pathId(sessionId), decision.wire, token), + ).encodeToByteArray() + return ApiRoute(HttpMethod.POST, "/hook/decision", OriginPolicy.GUARDED, body = body) + } + + /** `PUT /prefs` — G. Replaces the whole blob (server echoes a sanitized 200). */ + fun putPrefs(prefs: UiPrefs): ApiRoute = + ApiRoute(HttpMethod.PUT, "/prefs", OriginPolicy.GUARDED, body = prefs.encodeBody()) + + fun registerFcmToken(normalized: String): ApiRoute = + fcmTokenRoute(HttpMethod.POST, normalized) + + fun unregisterFcmToken(normalized: String): ApiRoute = + fcmTokenRoute(HttpMethod.DELETE, normalized) + + private fun fcmTokenRoute(method: HttpMethod, normalized: String): ApiRoute { + val body = ModelJson.encodeToString( + FcmTokenBody.serializer(), + FcmTokenBody(normalized), + ).encodeToByteArray() + return ApiRoute(method, FCM_TOKEN_PATH, OriginPolicy.GUARDED, body = body) + } + + private const val FCM_TOKEN_PATH = "/push/fcm-token" + + /** + * Server session ids are lowercase `crypto.randomUUID()` strings and `:id` route params are + * matched as EXACT strings — always serialize lowercase. `UUID.toString()` is already lowercase + * on the JVM (unlike Swift's uppercase `uuidString`). + */ + private fun pathId(id: UUID): String = id.toString() + + /** Strict RFC 3986 unreserved set — everything else is percent-encoded over UTF-8 bytes. */ + private val UNRESERVED: Set = + ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~").toSet() + + private fun percentEncode(value: String): String { + val sb = StringBuilder() + for (byte in value.encodeToByteArray()) { + val code = byte.toInt() and 0xFF + val ch = code.toChar() + if (ch in UNRESERVED) { + sb.append(ch) + } else { + sb.append('%').append(HEX[code ushr 4]).append(HEX[code and 0x0F]) + } + } + return sb.toString() + } + + private val HEX = "0123456789ABCDEF".toCharArray() + + @Serializable + private data class HookDecisionBody(val sessionId: String, val decision: String, val token: String) + + @Serializable + private data class FcmTokenBody(val token: String) +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/FcmTokenRule.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/FcmTokenRule.kt new file mode 100644 index 0000000..d6410fa --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/FcmTokenRule.kt @@ -0,0 +1,24 @@ +package wang.yaojia.webterm.api.routes + +/** + * Client-side FCM registration-token validator (validate at the boundary, plan §4). Deliberately + * LOOSE, per plan §4.5: non-empty, bounded length, `base64url` charset plus `:` — the FCM token + * format/length is undocumented and changes, so a strict length regex risks rejecting valid tokens. + * + * Unlike iOS's APNs hex rule, FCM tokens are case-sensitive → NOT lowercased. [normalize] returns + * the token unchanged when valid, or `null` (→ `ApiClientError.InvalidFcmToken` before any I/O). + */ +internal object FcmTokenRule { + /** Generous headroom bound; real tokens are ~150–250 chars but the ceiling is undocumented. */ + private const val MAX_LENGTH = 4096 + + /** base64url alphabet (`A–Z a–z 0–9 - _`) plus the `:` that appears in FCM tokens. */ + private val ALLOWED: Set = + (('A'..'Z') + ('a'..'z') + ('0'..'9') + listOf('-', '_', ':')).toSet() + + fun normalize(raw: String): String? { + if (raw.isEmpty() || raw.length > MAX_LENGTH) return null + if (!raw.all { it in ALLOWED }) return null + return raw + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/.gitkeep b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/UiPrefsTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/UiPrefsTest.kt new file mode 100644 index 0000000..b8ab6d4 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/UiPrefsTest.kt @@ -0,0 +1,72 @@ +package wang.yaojia.webterm.api.models + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * The prefs round-trip correctness trap: `PUT /prefs` replaces the WHOLE blob, so decode → mutate + * one known key → encode MUST carry every unknown top-level key through verbatim (including exact + * integer formatting), or an Android write clobbers web/iOS/future-server prefs. + */ +class UiPrefsTest { + @Test + fun sanitizesFavouritesAndCollapsedLikeTheWebClient() { + val prefs = UiPrefs.decode( + """ + {"favourites":["/a","/b","/a","",123],"collapsed":{"g1":true,"g2":false,"g3":"true","":true}} + """.trimIndent().toByteArray(), + )!! + + // favourites: non-empty strings only, de-duplicated, order preserved; the number 123 dropped. + assertEquals(listOf("/a", "/b"), prefs.favourites) + // collapsed: only literal `true`; false, string "true", and the empty key are all dropped. + assertEquals(mapOf("g1" to true), prefs.collapsed) + } + + @Test + fun mutatingOneKeyPreservesUnknownKeysAndIntegerFormatting() { + val original = UiPrefs.decode( + """ + {"favourites":["/old"],"collapsed":{"g":true},"schemaVersion":7,"vendor":{"nested":42,"ratio":1.5}} + """.trimIndent().toByteArray(), + )!! + + val mutated = original.withFavourites(listOf("/new")) + val encoded = mutated.encodeBody().decodeToString() + + // Known key was replaced... + assertEquals(listOf("/new"), UiPrefs.decode(encoded.toByteArray())!!.favourites) + // ...collapsed (untouched known key) survived... + assertEquals(mapOf("g" to true), UiPrefs.decode(encoded.toByteArray())!!.collapsed) + // ...and every unknown key survived verbatim, integers still integers (42, not 42.0). + assertTrue(encoded.contains("\"schemaVersion\":7"), "unknown scalar key must round-trip: $encoded") + assertTrue(encoded.contains("\"nested\":42"), "nested integer must not become 42.0: $encoded") + assertTrue(encoded.contains("\"ratio\":1.5"), "nested double must round-trip: $encoded") + } + + @Test + fun withCollapsedReplacesOnlyThatKey() { + val original = UiPrefs.decode("""{"favourites":["/keep"],"extra":"x"}""".toByteArray())!! + val encoded = original.withCollapsed(mapOf("ns" to true)).encodeBody().decodeToString() + + assertEquals(listOf("/keep"), UiPrefs.decode(encoded.toByteArray())!!.favourites) + assertEquals(mapOf("ns" to true), UiPrefs.decode(encoded.toByteArray())!!.collapsed) + assertTrue(encoded.contains("\"extra\":\"x\"")) + } + + @Test + fun createBuildsAFreshBlobWithBothKnownKeys() { + val encoded = UiPrefs.create(favourites = listOf("/a"), collapsed = mapOf("g" to true)) + .encodeBody().decodeToString() + assertEquals("""{"favourites":["/a"],"collapsed":{"g":true}}""", encoded) + } + + @Test + fun nonObjectBodyDecodesToNull() { + assertNull(UiPrefs.decode("[]".toByteArray())) + assertNull(UiPrefs.decode("\"hi\"".toByteArray())) + assertNull(UiPrefs.decode("not json".toByteArray())) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/HostClassifierTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/HostClassifierTest.kt new file mode 100644 index 0000000..6d08c49 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/HostClassifierTest.kt @@ -0,0 +1,65 @@ +package wang.yaojia.webterm.api.pairing + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.wire.HostClassifier +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HostNetworkTier + +/** Ports the tier table of iOS `HostClassificationTests` (plan §5.4, fail-safe unknown→public). */ +class HostClassifierTest { + @Test + fun loopbackHostsClassifyAsLoopback() { + val hosts = listOf("localhost", "LOCALHOST", "127.0.0.1", "127.5.9.200", "::1", "[::1]") + hosts.forEach { assertEquals(HostNetworkTier.LOOPBACK, HostClassifier.classify(it), it) } + } + + @Test + fun rfc1918AndLinkLocalAndMdnsClassifyAsPrivateLan() { + val hosts = listOf( + "10.0.0.5", "10.255.255.255", + "172.16.0.1", "172.20.10.1", "172.31.255.255", + "192.168.0.9", "192.168.1.1", + "169.254.1.1", + "mac-mini.local", "MAC-MINI.LOCAL", "printer.local", + ) + hosts.forEach { assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify(it), it) } + } + + @Test + fun cgnatAndMagicDnsClassifyAsTailscale() { + val hosts = listOf( + "100.64.0.1", "100.100.1.1", "100.127.255.255", + "mac.tailnet.ts.net", "MAC.TAILNET.TS.NET", "host.ts.net", + ) + hosts.forEach { assertEquals(HostNetworkTier.TAILSCALE, HostClassifier.classify(it), it) } + } + + @Test + fun everythingElseFailsSafeToPublic() { + val hosts = listOf( + // routable public IPs + "8.8.8.8", "203.0.113.7", + // hostnames + "example.com", "claude.ai", + // boundary-miss IPv4 (just outside the private/tailscale ranges) + "172.15.0.1", "172.32.0.1", "100.63.0.1", "100.128.0.1", + // malformed / out-of-range / wrong arity → fail-safe public + "256.1.1.1", "1.2.3", "999.999.999.999", "not a host", "", + // non-loopback IPv6 (ULA / documentation) → fail-safe public (iOS v1 scope) + "fd00::1", "2001:db8::1", + ) + hosts.forEach { assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify(it), it) } + } + + @Test + fun classifyEndpointDelegatesToHost() { + val lan = requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000")) + val tailscale = requireNotNull(HostEndpoint.fromBaseUrl("https://mac.tailnet.ts.net")) + val public = requireNotNull(HostEndpoint.fromBaseUrl("https://example.com")) + + assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify(lan)) + assertEquals(HostNetworkTier.TAILSCALE, HostClassifier.classify(tailscale)) + assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify(public)) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/PairingErrorTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/PairingErrorTest.kt new file mode 100644 index 0000000..4fd1a87 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/PairingErrorTest.kt @@ -0,0 +1,110 @@ +package wang.yaojia.webterm.api.pairing + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.wire.HostEndpoint +import java.io.IOException +import java.io.InterruptedIOException +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.net.UnknownServiceException +import java.security.cert.CertificateException +import javax.net.ssl.SSLException +import javax.net.ssl.SSLHandshakeException + +/** Re-derives iOS `PairingError.classify` for JVM exceptions (plan R12: drop localNetworkDenied). */ +class PairingErrorTest { + private val lan = requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000")) + + @Test + fun connectionRefusedMapsToHostUnreachable() { + val result = PairingError.classify(ConnectException("Connection refused"), lan) + assertInstanceOf(PairingError.HostUnreachable::class.java, result) + } + + @Test + fun dnsFailureMapsToHostUnreachable() { + val result = PairingError.classify(UnknownHostException("no such host"), lan) + assertInstanceOf(PairingError.HostUnreachable::class.java, result) + } + + @Test + fun socketTimeoutMapsToTimeout() { + assertEquals(PairingError.Timeout, PairingError.classify(SocketTimeoutException("read timed out"), lan)) + } + + @Test + fun interruptedIoMapsToTimeout() { + // OkHttp's overall call-timeout surfaces as a bare InterruptedIOException. + assertEquals(PairingError.Timeout, PairingError.classify(InterruptedIOException("timeout"), lan)) + } + + @Test + fun sslHandshakeFailureMapsToTlsFailure() { + assertEquals(PairingError.TlsFailure, PairingError.classify(SSLHandshakeException("handshake_failure"), lan)) + } + + @Test + fun genericSslAndCertFailuresMapToTlsFailure() { + assertEquals(PairingError.TlsFailure, PairingError.classify(SSLException("tls"), lan)) + assertEquals(PairingError.TlsFailure, PairingError.classify(CertificateException("bad cert"), lan)) + } + + @Test + fun cleartextBlockMapsToCleartextBlockedWithHost() { + val err = UnknownServiceException("CLEARTEXT communication to 192.168.1.5 not permitted") + val result = PairingError.classify(err, lan) + assertInstanceOf(PairingError.CleartextBlocked::class.java, result) + assertEquals("192.168.1.5", (result as PairingError.CleartextBlocked).host) + } + + @Test + fun wrappedCauseIsWalked() { + // OkHttp frequently wraps the real cause; the chain walk must see it. + val wrappedConnect = IOException("unexpected end of stream", ConnectException("refused")) + assertInstanceOf(PairingError.HostUnreachable::class.java, PairingError.classify(wrappedConnect, lan)) + + val wrappedTls = IOException("io", SSLHandshakeException("handshake")) + assertEquals(PairingError.TlsFailure, PairingError.classify(wrappedTls, lan)) + } + + @Test + fun unrecognizedErrorUsesFallbackWhenProvided() { + // Probe step ②: after ① passed, an unclassifiable connect failure is the Origin 401. + val fallback = PairingError.OriginRejected("hint") + assertEquals(fallback, PairingError.classify(IllegalStateException("weird"), lan, unrecognizedFallback = fallback)) + } + + @Test + fun unrecognizedErrorWithoutFallbackIsHostUnreachable() { + assertInstanceOf( + PairingError.HostUnreachable::class.java, + PairingError.classify(IllegalStateException("weird"), lan), + ) + } + + @Test + fun causeCycleTerminates() { + // A→B→A cycle must not loop forever; B is a ConnectException so it classifies as reachable-failure. + val a = IOException("a") + val b = ConnectException("b") + a.initCause(b) + b.initCause(a) + assertInstanceOf(PairingError.HostUnreachable::class.java, PairingError.classify(a, lan)) + } + + @Test + fun originRejectedHintDerivesFromOriginHeaderWithoutDefaultPort() { + val lanHint = PairingError.originRejectedHint(lan) + assertTrue(lanHint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000")) + + val tls = requireNotNull(HostEndpoint.fromBaseUrl("https://mac.tailnet.ts.net")) + val tlsHint = PairingError.originRejectedHint(tls) + assertTrue(tlsHint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net")) + assertFalse(tlsHint.contains(":443"), "default https port must be omitted (no :443 迷信)") + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/PairingProbeTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/PairingProbeTest.kt new file mode 100644 index 0000000..2dae516 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/PairingProbeTest.kt @@ -0,0 +1,284 @@ +package wang.yaojia.webterm.api.pairing + +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.testsupport.FakeTermTransport +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.net.UnknownServiceException +import javax.net.ssl.SSLHandshakeException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Ports iOS `PairingProbeTests`: the two-step probe (① GET reachability/shape, ② WS attach→adopt→ + * kill-with-Origin), each failure mode mapped to a [PairingError], full-success leaves no orphan + * session, and the injected-deadline timeout — all under `runTest` virtual time (zero real waits). + */ +class PairingProbeTest { + private data class Fixture( + val endpoint: HostEndpoint, + val http: FakeHttpTransport, + val ws: FakeTermTransport, + val liveSessionsUrl: String, + val killUrl: String, + ) + + private fun fixture(base: String = BASE): Fixture { + val endpoint = requireNotNull(HostEndpoint.fromBaseUrl(base)) + return Fixture( + endpoint = endpoint, + http = FakeHttpTransport(), + ws = FakeTermTransport(), + liveSessionsUrl = "$base/live-sessions", + killUrl = "$base/live-sessions/$SESSION_ID", + ) + } + + /** Non-timeout cases use `timeout = null` so the probe never enters the race — deterministic. */ + private suspend fun runProbe(f: Fixture, timeout: Duration? = null): PairingProbeResult = + runPairingProbeCore(f.endpoint, f.http, f.ws, timeout) + + private fun failureError(result: PairingProbeResult): PairingError { + assertInstanceOf(PairingProbeResult.Failure::class.java, result) + return (result as PairingProbeResult.Failure).error + } + + // ── Probe ① failure branches ──────────────────────────────────────────────────────────────── + + @Test + fun stepOneConnectionRefusedMapsToHostUnreachableAndNeverTouchesWs() = runTest { + val f = fixture() + f.http.queueFailure(url = f.liveSessionsUrl, error = ConnectException("refused")) + + val result = runProbe(f) + + assertInstanceOf(PairingError.HostUnreachable::class.java, failureError(result)) + assertTrue(f.ws.connectAttempts.isEmpty(), "probe must not touch WS when ① fails") + } + + @Test + fun stepOneHtmlBodyMapsToNotWebTerminal() = runTest { + val f = fixture() + f.http.queueSuccess(url = f.liveSessionsUrl, body = "router admin".toByteArray()) + + assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f))) + } + + @Test + fun stepOne404MapsToNotWebTerminal() = runTest { + val f = fixture() + f.http.queueSuccess(url = f.liveSessionsUrl, status = 404) + + assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f))) + } + + @Test + fun stepOneNonArrayJsonMapsToNotWebTerminal() = runTest { + val f = fixture() + f.http.queueSuccess(url = f.liveSessionsUrl, body = "{\"ok\":true}".toByteArray()) + + assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f))) + } + + @Test + fun stepOneTlsFailureMapsToTlsFailure() = runTest { + val f = fixture() + f.http.queueFailure(url = f.liveSessionsUrl, error = SSLHandshakeException("handshake_failure")) + + assertEquals(PairingError.TlsFailure, failureError(runProbe(f))) + } + + @Test + fun stepOneTransportTimeoutMapsToTimeout() = runTest { + val f = fixture() + f.http.queueFailure(url = f.liveSessionsUrl, error = SocketTimeoutException("read timed out")) + + assertEquals(PairingError.Timeout, failureError(runProbe(f))) + } + + @Test + fun stepOneCleartextBlockMapsToCleartextBlockedWithHost() = runTest { + val f = fixture() + f.http.queueFailure( + url = f.liveSessionsUrl, + error = UnknownServiceException("CLEARTEXT communication to 192.168.1.5 not permitted"), + ) + + val error = failureError(runProbe(f)) + assertInstanceOf(PairingError.CleartextBlocked::class.java, error) + assertEquals("192.168.1.5", (error as PairingError.CleartextBlocked).host) + } + + @Test + fun stepOneDnsFailureMapsToHostUnreachable() = runTest { + val f = fixture() + f.http.queueFailure(url = f.liveSessionsUrl, error = UnknownHostException("nxdomain")) + + assertInstanceOf(PairingError.HostUnreachable::class.java, failureError(runProbe(f))) + } + + // ── Probe ② failure branches ──────────────────────────────────────────────────────────────── + + @Test + fun stepTwoUpgradeRejectionMapsToOriginRejectedWithActionableHint() = runTest { + // ① passes; ② upgrade fails (a 401 is a shapeless connect error at the transport layer). + val f = fixture() + f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray()) + f.ws.scriptConnectFailure() + + val error = failureError(runProbe(f)) + assertInstanceOf(PairingError.OriginRejected::class.java, error) + val hint = (error as PairingError.OriginRejected).hint + assertTrue(hint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000"), hint) + assertFalse(hint.contains(":443"), hint) + } + + @Test + fun originRejectedHintOmitsDefaultPortForHttps() = runTest { + val f = fixture(base = "https://mac.tailnet.ts.net") + f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray()) + f.ws.scriptConnectFailure() + + val error = failureError(runProbe(f)) + assertInstanceOf(PairingError.OriginRejected::class.java, error) + val hint = (error as PairingError.OriginRejected).hint + assertTrue(hint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net"), hint) + assertFalse(hint.contains(":443"), hint) + } + + @Test + fun stepTwoStreamEndingBeforeAttachedMapsToNotWebTerminal() = runTest { + // A queued finish is flushed into the connection at connect → stream closes before `attached`. + val f = fixture() + f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray()) + f.ws.finishFrames() + + assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f))) + } + + // ── Full pass + kill round-trip ────────────────────────────────────────────────────────────── + + @Test + fun fullProbeSuccessAttachesWithNullSessionIdThenKillsWithOrigin() = runTest { + val f = fixture() + f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray()) + f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204) + f.ws.emit(ATTACHED_FRAME) + + val result = runProbe(f) + + // Success payload is the probed endpoint. + assertEquals(PairingProbeResult.Success(f.endpoint), result) + + // attach(null) is the only WS frame, with an explicit JSON null sessionId key. + assertEquals(1, f.ws.sentFrames.size) + val attach = Json.parseToJsonElement(f.ws.sentFrames.single()).jsonObject + assertEquals("attach", attach["type"]?.jsonPrimitive?.content) + assertTrue(attach["sessionId"] is JsonNull, "sessionId key must be present and JSON null") + + // Exactly two HTTP requests: RO GET (NO Origin) then guarded DELETE (byte-equal Origin). + val requests = f.http.recordedRequests + assertEquals(2, requests.size) + assertFalse(requests.first().headers.containsKey("Origin"), "RO GET must not carry Origin") + val kill = requests.last() + assertEquals(HttpMethod.DELETE, kill.method) + assertEquals(f.killUrl, kill.url) + assertEquals(f.endpoint.originHeader, kill.headers["Origin"]) + + // The probe never holds the connection. + assertEquals(1, f.ws.closeCallCount) + } + + @Test + fun publicWrapperHappyPathReturnsEndpoint() = runTest { + // The public entry uses the default Tunables deadline; immediate fakes never trip it. + val f = fixture() + f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray()) + f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204) + f.ws.emit(ATTACHED_FRAME) + + val result = runPairingProbe(f.endpoint, f.http, f.ws) + + assertEquals(PairingProbeResult.Success(f.endpoint), result) + assertEquals(1, f.ws.closeCallCount) + } + + @Test + fun outputFrameBeforeAttachedIsSkippedAndProbeSucceeds() = runTest { + val f = fixture() + f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray()) + f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204) + f.ws.emit("""{"type":"output","data":"replay"}""") + f.ws.emit(ATTACHED_FRAME) + + assertEquals(PairingProbeResult.Success(f.endpoint), runProbe(f)) + } + + @Test + fun killRejectedByOriginGuardMapsToOriginRejected() = runTest { + val f = fixture() + f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray()) + f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 403) + f.ws.emit(ATTACHED_FRAME) + + assertInstanceOf(PairingError.OriginRejected::class.java, failureError(runProbe(f))) + } + + @Test + fun killReturning404StillCountsAsSuccess() = runTest { + // Session exited between attach and kill — the goal state (no orphan) is already reached. + val f = fixture() + f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray()) + f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 404) + f.ws.emit(ATTACHED_FRAME) + + assertEquals(PairingProbeResult.Success(f.endpoint), runProbe(f)) + } + + // ── Timeout (virtual time, zero real waits) ────────────────────────────────────────────────── + + @Test + fun probeTimesOutWhenServerNeverSendsAttached() = runTest { + // ① passes; ② connects but the server never replies `attached` → the probe hangs on the + // frame stream until the injected deadline cancels it. + val f = fixture() + f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray()) + + val result = runProbe(f, timeout = 10.seconds) + + assertEquals(PairingProbeResult.Failure(PairingError.Timeout), result) + } + + @Test + fun timeoutPathAlwaysClosesTheConnectionExactlyOnce() = runTest { + // ① passes; ② connects but the server never sends `attached` → the probe hangs on the frame + // stream until the injected deadline cancels it MID-adopt. The connection must still close + // (try/finally + NonCancellable), or the probe leaks the WS + its orphan session. + val f = fixture() + f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray()) + + val result = runProbe(f, timeout = 10.seconds) + + assertEquals(PairingProbeResult.Failure(PairingError.Timeout), result) + assertEquals(1, f.ws.closeCallCount, "the probe must close the WS even on the timeout path") + } + + private companion object { + const val BASE = "http://192.168.1.5:3000" + const val SESSION_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + const val ATTACHED_FRAME = """{"type":"attached","sessionId":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"}""" + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/ApiClientErrorMappingTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/ApiClientErrorMappingTest.kt new file mode 100644 index 0000000..088fafb --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/ApiClientErrorMappingTest.kt @@ -0,0 +1,86 @@ +package wang.yaojia.webterm.api.routes + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.models.HookDecision +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import java.io.IOException +import java.util.UUID + +/** Status-code → typed [ApiClientError] mapping per route, plus transport errors propagating raw. */ +class ApiClientErrorMappingTest { + private companion object { + const val BASE = "http://h:3000" + val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555") + const val ID_STR = "11111111-2222-4333-8444-555555555555" + } + + private val transport = FakeHttpTransport() + private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport) + + private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull() + + @Test + fun killSessionMapsNotFoundAndForbidden() = runTest { + transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 404) + assertEquals(ApiClientError.SessionNotFound, errorOf { client.killSession(ID) }) + + transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 403) + assertEquals(ApiClientError.Forbidden, errorOf { client.killSession(ID) }) + } + + @Test + fun hookDecisionMapsForbiddenToRejectedAnd429ToRateLimited() = runTest { + transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 403) + assertEquals(ApiClientError.DecisionRejected, errorOf { client.hookDecision(ID, HookDecision.DENY, "t") }) + + transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 429) + assertEquals(ApiClientError.RateLimited, errorOf { client.hookDecision(ID, HookDecision.DENY, "t") }) + } + + @Test + fun projectDetailMapsEmptyPathAndAllServerErrorStatuses() = runTest { + // Empty path is rejected BEFORE any I/O. + assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectDetail("") }) + assertTrue(transport.recordedRequests.isEmpty(), "empty path must not hit the network") + + val url = "$BASE/projects/detail?path=%2Fp" + transport.queueSuccess(url = url, status = 400) + assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectDetail("/p") }) + transport.queueSuccess(url = url, status = 404) + assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectDetail("/p") }) + transport.queueSuccess(url = url, status = 500) + assertEquals(ApiClientError.ProjectDetailUnavailable, errorOf { client.projectDetail("/p") }) + } + + @Test + fun putPrefsMapsForbiddenAndUnexpected() = runTest { + transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", status = 403) + assertEquals(ApiClientError.Forbidden, errorOf { client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()) }) + + transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", status = 418) + assertEquals(ApiClientError.UnexpectedStatus(418), errorOf { client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()) }) + } + + @Test + fun readOnlyRoutesMapNonOkToUnexpectedOrSessionNotFound() = runTest { + transport.queueSuccess(url = "$BASE/live-sessions", status = 500, body = "boom".toByteArray()) + assertEquals(ApiClientError.UnexpectedStatus(500), errorOf { client.liveSessions() }) + + // preview/events/uiConfig go through requireOk: 404 → SessionNotFound. + transport.queueSuccess(url = "$BASE/live-sessions/$ID_STR/preview", status = 404) + assertEquals(ApiClientError.SessionNotFound, errorOf { client.preview(ID) }) + } + + @Test + fun transportLevelErrorsPropagateUnwrapped() = runTest { + transport.queueFailure(url = "$BASE/live-sessions", error = IOException("connection refused")) + val error = errorOf { client.liveSessions() } + assertTrue(error is IOException) + assertEquals("connection refused", error?.message) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/ApiRouteShapeTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/ApiRouteShapeTest.kt new file mode 100644 index 0000000..395ef0d --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/ApiRouteShapeTest.kt @@ -0,0 +1,167 @@ +package wang.yaojia.webterm.api.routes + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.models.HookDecision +import wang.yaojia.webterm.api.models.UiPrefs +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest +import java.util.UUID + +/** + * Request-shape + Origin-iff-guarded (plan §4.3 铁律) across all 12 routes. Verifies method, URL, + * the presence/absence of the `Origin` header, and the exact JSON body for the mutating routes. + */ +class ApiRouteShapeTest { + private companion object { + const val BASE = "http://192.168.1.5:3000" + const val ORIGIN = "http://192.168.1.5:3000" + val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555") + const val ID_STR = "11111111-2222-4333-8444-555555555555" + } + + private val transport = FakeHttpTransport() + private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport) + + private fun lastRequest(): HttpRequest = transport.recordedRequests.last() + + private fun assertGuarded(request: HttpRequest) = + assertEquals(ORIGIN, request.headers[HeaderName.ORIGIN], "guarded route must stamp byte-equal Origin") + + private fun assertReadOnly(request: HttpRequest) = + assertFalse(request.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin") + + // ── read-only routes: correct verb+url, NO Origin ─────────────────────────────────────── + + @Test + fun liveSessionsIsPlainReadOnlyGet() = runTest { + transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray()) + client.liveSessions() + + val request = lastRequest() + assertEquals(HttpMethod.GET, request.method) + assertEquals("$BASE/live-sessions", request.url) + assertReadOnly(request) + assertNull(request.body) + } + + @Test + fun previewEventsUiConfigProjectsPrefsAreReadOnlyGets() = runTest { + transport.queueSuccess( + url = "$BASE/live-sessions/$ID_STR/preview", + body = """{"id":"$ID_STR","cols":80,"rows":24,"data":""}""".toByteArray(), + ) + transport.queueSuccess(url = "$BASE/live-sessions/$ID_STR/events", body = "[]".toByteArray()) + transport.queueSuccess(url = "$BASE/config/ui", body = """{"allowAutoMode":true}""".toByteArray()) + transport.queueSuccess(url = "$BASE/projects", body = "[]".toByteArray()) + transport.queueSuccess(url = "$BASE/prefs", body = "{}".toByteArray()) + + client.preview(ID) + client.events(ID) + client.uiConfig() + client.projects() + client.prefs() + + val urls = transport.recordedRequests.map { it.url } + assertEquals( + listOf( + "$BASE/live-sessions/$ID_STR/preview", + "$BASE/live-sessions/$ID_STR/events", + "$BASE/config/ui", + "$BASE/projects", + "$BASE/prefs", + ), + urls, + ) + transport.recordedRequests.forEach { + assertEquals(HttpMethod.GET, it.method) + assertReadOnly(it) + } + } + + @Test + fun projectDetailStrictlyPercentEncodesPathInTheQuery() = runTest { + val path = "/home/me/my repo/a+b&c" + transport.queueSuccess( + url = "$BASE/projects/detail?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c", + body = """{"name":"repo","path":"$path","isGit":true}""".toByteArray(), + ) + + client.projectDetail(path) + + val request = lastRequest() + assertEquals(HttpMethod.GET, request.method) + assertEquals("$BASE/projects/detail?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c", request.url) + assertReadOnly(request) + } + + // ── guarded routes: correct verb+url, Origin stamped, exact body ──────────────────────── + + @Test + fun killSessionIsGuardedDeleteWithNoBody() = runTest { + transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 204) + client.killSession(ID) + + val request = lastRequest() + assertEquals(HttpMethod.DELETE, request.method) + assertEquals("$BASE/live-sessions/$ID_STR", request.url) + assertGuarded(request) + assertNull(request.body) + } + + @Test + fun hookDecisionIsGuardedPostWithExactBodyAndJsonContentType() = runTest { + transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 204) + client.hookDecision(ID, HookDecision.ALLOW, "cap-tok-123") + + val request = lastRequest() + assertEquals(HttpMethod.POST, request.method) + assertEquals("$BASE/hook/decision", request.url) + assertGuarded(request) + assertEquals(ContentType.JSON, request.headers[HeaderName.CONTENT_TYPE]) + assertEquals( + """{"sessionId":"$ID_STR","decision":"allow","token":"cap-tok-123"}""", + request.body?.decodeToString(), + ) + } + + @Test + fun putPrefsIsGuardedPutEchoingTheFullBlob() = runTest { + val body = """{"favourites":["/a"],"collapsed":{}}""" + transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", body = body.toByteArray()) + + client.putPrefs(UiPrefs.create(favourites = listOf("/a"))) + + val request = lastRequest() + assertEquals(HttpMethod.PUT, request.method) + assertEquals("$BASE/prefs", request.url) + assertGuarded(request) + assertEquals(body, request.body?.decodeToString()) + } + + @Test + fun fcmTokenRegisterAndUnregisterAreGuardedWithTokenBody() = runTest { + val token = "fVoT9x-abc_DEF:api-901" + transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/push/fcm-token", status = 204) + transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/push/fcm-token", status = 204) + + client.registerFcmToken(token) + client.unregisterFcmToken(token) + + val post = transport.recordedRequests[0] + val delete = transport.recordedRequests[1] + assertEquals(HttpMethod.POST, post.method) + assertEquals(HttpMethod.DELETE, delete.method) + assertTrue(post.url.endsWith("/push/fcm-token")) + assertGuarded(post) + assertGuarded(delete) + assertEquals("""{"token":"$token"}""", post.body?.decodeToString()) + assertEquals("""{"token":"$token"}""", delete.body?.decodeToString()) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/FcmTokenTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/FcmTokenTest.kt new file mode 100644 index 0000000..1f3aa96 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/FcmTokenTest.kt @@ -0,0 +1,68 @@ +package wang.yaojia.webterm.api.routes + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod + +/** The intentionally-LOOSE FCM token validator (plan §4.5) + register/unregister error mapping. */ +class FcmTokenTest { + private companion object { + const val BASE = "http://h:3000" + const val URL = "$BASE/push/fcm-token" + } + + private val transport = FakeHttpTransport() + private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport) + + @Test + fun acceptsBase64UrlPlusColonAndIsCaseSensitive() { + // base64url chars + ':' , mixed case preserved (FCM tokens are case-sensitive → not lowered). + val token = "cRZ7-x_Y9:APA91bH-Ab_CdEf" + assertEquals(token, FcmTokenRule.normalize(token)) + } + + @Test + fun rejectsEmptyOverlongAndOutOfCharsetTokens() { + assertNull(FcmTokenRule.normalize("")) + assertNull(FcmTokenRule.normalize("a".repeat(4097))) + assertNull(FcmTokenRule.normalize("has space")) + assertNull(FcmTokenRule.normalize("has/slash")) + assertNull(FcmTokenRule.normalize("emoji😀")) + assertNotNull(FcmTokenRule.normalize("a".repeat(4096))) // exactly at the bound is fine + } + + @Test + fun invalidTokenIsRejectedBeforeAnyNetworkIO() = runTest { + val error = runCatching { client.registerFcmToken("bad token") }.exceptionOrNull() + assertEquals(ApiClientError.InvalidFcmToken, error) + assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an invalid token") + } + + @Test + fun registerMaps400To403To429() = runTest { + transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 400) + assertEquals(ApiClientError.InvalidFcmToken, runCatching { client.registerFcmToken("okTok") }.exceptionOrNull()) + + transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 403) + assertEquals(ApiClientError.Forbidden, runCatching { client.registerFcmToken("okTok") }.exceptionOrNull()) + + transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 429) + assertEquals(ApiClientError.RateLimited, runCatching { client.registerFcmToken("okTok") }.exceptionOrNull()) + } + + @Test + fun registerAndUnregisterSucceedOn204() = runTest { + transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 204) + transport.queueSuccess(method = HttpMethod.DELETE, url = URL, status = 204) + // No exception = success. + client.registerFcmToken("okTok") + client.unregisterFcmToken("okTok") + assertEquals(2, transport.recordedRequests.size) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/HappyPathDecodeTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/HappyPathDecodeTest.kt new file mode 100644 index 0000000..adaad61 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/HappyPathDecodeTest.kt @@ -0,0 +1,102 @@ +package wang.yaojia.webterm.api.routes + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import java.util.UUID + +/** End-to-end decode of each model from a well-formed body (exercises the custom serializers). */ +class HappyPathDecodeTest { + private companion object { + const val BASE = "http://h:3000" + val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555") + const val ID_STR = "11111111-2222-4333-8444-555555555555" + } + + private val transport = FakeHttpTransport() + private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport) + + @Test + fun previewDecodesGeometryAndOpaqueData() = runTest { + transport.queueSuccess( + url = "$BASE/live-sessions/$ID_STR/preview", + body = """{"id":"$ID_STR","cols":100,"rows":30,"data":"hello"}""".toByteArray(), + ) + val preview = client.preview(ID) + assertEquals(ID, preview.id) + assertEquals(100, preview.cols) + assertEquals("hello", preview.data) + } + + @Test + fun uiConfigDecodesAllowAutoMode() = runTest { + transport.queueSuccess(url = "$BASE/config/ui", body = """{"allowAutoMode":true}""".toByteArray()) + assertTrue(client.uiConfig().allowAutoMode) + } + + @Test + fun liveSessionsDecodesTelemetry() = runTest { + val body = """ + [{"id":"$ID_STR","createdAt":10,"clientCount":1,"status":"working","exited":false,"cols":80,"rows":24, + "telemetry":{"at":99,"model":"opus","costUsd":0.42,"linesAdded":10,"pr":{"number":7,"url":"http://x"}}}] + """.trimIndent() + transport.queueSuccess(url = "$BASE/live-sessions", body = body.toByteArray()) + + val session = client.liveSessions().single() + assertEquals("opus", session.telemetry?.model) + assertEquals(0.42, session.telemetry?.costUsd) + assertEquals(7, session.telemetry?.pr?.number) + } + + @Test + fun projectDetailDecodesWorktreesSessionsAndClaudeMd() = runTest { + val body = """ + {"name":"repo","path":"/r","isGit":true,"branch":"main","dirty":true, + "worktrees":[{"path":"/r","branch":"main","isMain":true,"isCurrent":true}, + {"path":"/r-wt","head":"abc123","isMain":false,"isCurrent":false}], + "sessions":[{"id":"$ID_STR","title":"claude","status":"waiting","clientCount":1,"createdAt":5,"exited":false}], + "hasClaudeMd":true,"claudeMd":"# Repo"} + """.trimIndent() + transport.queueSuccess(url = "$BASE/projects/detail?path=%2Fr", body = body.toByteArray()) + + val detail = client.projectDetail("/r") + assertEquals("main", detail.branch) + assertEquals(2, detail.worktrees.size) + assertTrue(detail.worktrees[0].isMain) + assertEquals("abc123", detail.worktrees[1].head) + assertEquals("claude", detail.sessions.single().title) + assertEquals("# Repo", detail.claudeMd) + } + + @Test + fun prefsRoundTripsThroughGetAndPutEcho() = runTest { + transport.queueSuccess( + url = "$BASE/prefs", + body = """{"favourites":["/a"],"collapsed":{"g":true},"vendor":1}""".toByteArray(), + ) + val prefs = client.prefs() + assertEquals(listOf("/a"), prefs.favourites) + + // PUT returns the server's sanitized echo — treat IT as truth (server dropped "vendor"). + transport.queueSuccess( + method = HttpMethod.PUT, + url = "$BASE/prefs", + body = """{"favourites":["/a","/b"],"collapsed":{}}""".toByteArray(), + ) + val echoed = client.putPrefs(prefs.withFavourites(listOf("/a", "/b"))) + assertEquals(listOf("/a", "/b"), echoed.favourites) + } + + @Test + fun prefsNonObjectBodyThrowsInvalidResponseBody() = runTest { + transport.queueSuccess(url = "$BASE/prefs", body = "[]".toByteArray()) + assertEquals( + ApiClientError.InvalidResponseBody, + runCatching { client.prefs() }.exceptionOrNull(), + ) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/TolerantDecodeTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/TolerantDecodeTest.kt new file mode 100644 index 0000000..9a893d7 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/TolerantDecodeTest.kt @@ -0,0 +1,101 @@ +package wang.yaojia.webterm.api.routes + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.ClaudeStatus +import wang.yaojia.webterm.wire.HostEndpoint +import java.util.UUID + +/** Tolerant decode at the UNTRUSTED server boundary (plan §4): drop bad elements, never crash. */ +class TolerantDecodeTest { + private companion object { + const val BASE = "http://h:3000" + val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555") + const val ID_STR = "11111111-2222-4333-8444-555555555555" + } + + private val transport = FakeHttpTransport() + private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport) + + @Test + fun liveSessionsDropsMalformedEntriesAndMapsUnknownStatusToUnknown() = runTest { + // Entry 1: valid, unknown status string. Entry 2: missing required clientCount → dropped. + // Entry 3: non-UUID id → dropped. Entry 4: valid, big-ms createdAt (Long), no telemetry. + val body = """ + [ + {"id":"$ID_STR","createdAt":1700000000000,"clientCount":2,"status":"reticulating","exited":false,"cols":80,"rows":24}, + {"id":"22222222-2222-4222-8222-222222222222","createdAt":1,"status":"idle","exited":false,"cols":80,"rows":24}, + {"id":"not-a-uuid","createdAt":1,"clientCount":1,"status":"idle","exited":false,"cols":80,"rows":24}, + {"id":"33333333-3333-4333-8333-333333333333","createdAt":1700000000001,"clientCount":0,"status":"working","exited":true,"cwd":"/w","cols":120,"rows":40,"lastOutputAt":1700000000005} + ] + """.trimIndent() + transport.queueSuccess(url = "$BASE/live-sessions", body = body.toByteArray()) + + val sessions = client.liveSessions() + + assertEquals(2, sessions.size) + assertEquals(ID, sessions[0].id) + assertEquals(ClaudeStatus.UNKNOWN, sessions[0].status) // unknown wire value → UNKNOWN, not dropped + assertEquals(1_700_000_000_000L, sessions[0].createdAt) // ms fits Long, would overflow Int + assertEquals(ClaudeStatus.WORKING, sessions[1].status) + assertEquals(1_700_000_000_005L, sessions[1].lastOutputAt) + } + + @Test + fun liveSessionsNonArrayBodyThrowsInvalidResponseBody() = runTest { + transport.queueSuccess(url = "$BASE/live-sessions", body = "{}".toByteArray()) + assertEquals( + ApiClientError.InvalidResponseBody, + runCatching { client.liveSessions() }.exceptionOrNull(), + ) + } + + @Test + fun projectsKeepsProjectButDropsOnlyItsMalformedNestedSession() = runTest { + val body = """ + [ + {"name":"repo","path":"/r","isGit":true,"branch":"main","sessions":[ + {"id":"$ID_STR","status":"working","clientCount":1,"createdAt":1,"exited":false}, + {"id":"bad","status":"working","clientCount":1,"createdAt":1,"exited":false} + ]} + ] + """.trimIndent() + transport.queueSuccess(url = "$BASE/projects", body = body.toByteArray()) + + val projects = client.projects() + + assertEquals(1, projects.size) // project survives its bad nested session + assertEquals(1, projects[0].sessions.size) + assertEquals(ID, projects[0].sessions[0].id) + } + + @Test + fun eventsNonArrayBodyDegradesToEmptyAndUnknownClassSurvives() = runTest { + // A non-array body (timeline capture disabled) → [] rather than an error. + transport.queueSuccess(url = "$BASE/live-sessions/$ID_STR/events", body = "{}".toByteArray()) + assertTrue(client.events(ID).isEmpty()) + + // Unknown class decodes fine (shape ok); hasKnownClass=false for downstream filtering. + transport.queueSuccess( + url = "$BASE/live-sessions/$ID_STR/events", + body = """[{"at":5,"class":"weird","label":"did a thing"}]""".toByteArray(), + ) + val events = client.events(ID) + assertEquals(1, events.size) + assertFalse(events[0].hasKnownClass) + assertEquals("did a thing", events[0].label) + } + + @Test + fun singleObjectRoutesThrowInvalidResponseBodyOnGarbage() = runTest { + transport.queueSuccess(url = "$BASE/config/ui", body = "not json".toByteArray()) + assertEquals( + ApiClientError.InvalidResponseBody, + runCatching { client.uiConfig() }.exceptionOrNull(), + ) + } +} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..05e7fb8 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,48 @@ +// ───────────────────────────────────────────────────────────────────────────── +// :app — the Android application (Compose UI, ViewModels, Hilt DI, FCM push, +// DeepLinkRouter, DesignSystem, EventBus). Mirrors iOS App/WebTerm. +// +// SCAFFOLD STUB ONLY. This module is COMMENTED OUT in settings.gradle.kts because +// this build environment has NO Android SDK. The block below is the intended shape; +// it applies the Android Gradle Plugin, which cannot resolve without an SDK. +// +// TODO(android-sdk): uncomment the include in settings.gradle.kts AND this block, +// add AGP + google() to pluginManagement, and provide local.properties -> sdk.dir. +// ───────────────────────────────────────────────────────────────────────────── + +/* +plugins { + id("com.android.application") + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.serialization) + id("com.google.dagger.hilt.android") + id("com.google.gms.google-services") +} + +android { + namespace = "wang.yaojia.webterm" + compileSdk = 35 + + defaultConfig { + applicationId = "wang.yaojia.webterm" + minSdk = 29 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + } + + buildFeatures { compose = true } +} + +kotlin { jvmToolchain(17) } + +dependencies { + implementation(project(":wire-protocol")) + implementation(project(":session-core")) + implementation(project(":api-client")) + implementation(project(":client-tls")) + implementation(project(":client-tls-android")) + implementation(project(":host-registry")) + implementation(project(":terminal-view")) +} +*/ diff --git a/android/app/src/main/kotlin/wang/yaojia/webterm/app/.gitkeep b/android/app/src/main/kotlin/wang/yaojia/webterm/app/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..21db267 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,8 @@ +// Root build script. Registers the Kotlin plugins (versions from the catalog) but +// applies none at the root — each module opts in via its own `plugins { }` block. +// Kept intentionally thin (KISS); there is no allprojects/subprojects magic. + +plugins { + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.serialization) apply false +} diff --git a/android/client-tls-android/build.gradle.kts b/android/client-tls-android/build.gradle.kts new file mode 100644 index 0000000..1db4556 --- /dev/null +++ b/android/client-tls-android/build.gradle.kts @@ -0,0 +1,30 @@ +// ───────────────────────────────────────────────────────────────────────────── +// :client-tls-android — the FRAMEWORK half of ClientTLS: AndroidKeyStore import +// (device-bound, non-exportable key), Tink AEAD cert-chain-at-rest storage, and +// connectionPool.evictAll() on rotation. Tested instrumented on a real device. +// Depends on the pure :client-tls module for the parse/selection logic. +// +// SCAFFOLD STUB ONLY — COMMENTED OUT in settings.gradle.kts (no Android SDK here). +// TODO(android-sdk): enable when an Android SDK is available. +// ───────────────────────────────────────────────────────────────────────────── + +/* +plugins { + id("com.android.library") + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "wang.yaojia.webterm.tlsandroid" + compileSdk = 35 + defaultConfig { minSdk = 29 } +} + +kotlin { jvmToolchain(17) } + +dependencies { + api(project(":client-tls")) + implementation(project(":wire-protocol")) + // implementation("com.google.crypto.tink:tink-android:1.15.0") +} +*/ diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/.gitkeep b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/android/client-tls/build.gradle.kts b/android/client-tls/build.gradle.kts new file mode 100644 index 0000000..9d2e452 --- /dev/null +++ b/android/client-tls/build.gradle.kts @@ -0,0 +1,26 @@ +// :client-tls (PURE half) — PKCS#12 structural parse (KeyStore("PKCS12"), +// import-parse only), X509KeyManager alias/getPrivateKey selection logic, +// CertificateSummary parsing, PairingError/HostClassifier warning-tier mapping. +// JVM-speed tested and IN the 80% Kover gate. The AndroidKeyStore/Tink framework +// half lives in the (SDK-gated) :client-tls-android module. + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + api(project(":wire-protocol")) + implementation(libs.kotlinx.coroutines.core) + + testImplementation(libs.bundles.unit.test) + testRuntimeOnly(libs.junit.platform.launcher) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/android/client-tls/src/main/kotlin/wang/yaojia/webterm/clienttls/CertificateSummary.kt b/android/client-tls/src/main/kotlin/wang/yaojia/webterm/clienttls/CertificateSummary.kt new file mode 100644 index 0000000..d5829c3 --- /dev/null +++ b/android/client-tls/src/main/kotlin/wang/yaojia/webterm/clienttls/CertificateSummary.kt @@ -0,0 +1,68 @@ +package wang.yaojia.webterm.clienttls + +import java.security.cert.X509Certificate +import java.time.Instant +import javax.naming.ldap.LdapName +import javax.security.auth.x500.X500Principal + +/** + * Display summary of a device certificate (C-iOS-3), shown on the install / rotation screen so the + * user can confirm *which* cert is active and *when* it expires before relying on it. Faithful port + * of iOS `ClientCertificateSummary`. + * + * @property subjectCommonName subject CN — the device/leaf CN (e.g. `t1-android`); null if absent. + * @property issuerCommonName issuer CN — the device-CA CN (e.g. `webterm-device-ca`); null if absent. + * @property notAfter not-after instant; null if it could not be parsed. + */ +public data class CertificateSummary( + val subjectCommonName: String?, + val issuerCommonName: String?, + val notAfter: Instant?, +) { + /** + * Expired relative to [now] (defaults to the current instant). Unknown expiry is treated as NOT + * expired (fail-open for display only — the TLS stack, not this label, is the real gate), + * matching iOS `isExpired`. + */ + public fun isExpired(now: Instant = Instant.now()): Boolean { + val end = notAfter ?: return false + return end.isBefore(now) + } +} + +/** + * Reads the display fields off an [X509Certificate]. + * + * Unlike iOS (which hand-walks the DER because `SecCertificate` lacks the accessor on iOS), the JVM + * `X509Certificate` already exposes subject/issuer principals and `notAfter`, so this is a thin + * extraction. CN is pulled from the RFC2253 DN via `javax.naming.ldap.LdapName` (JVM stdlib), which + * handles DN escaping/ordering correctly. Any parse miss degrades to `null` fields (the summary is + * display-only; the TLS stack is the gate). + */ +public object CertificateSummaryReader { + private const val COMMON_NAME_TYPE = "CN" + + public fun summarize(certificate: X509Certificate): CertificateSummary = + CertificateSummary( + subjectCommonName = commonName(certificate.subjectX500Principal), + issuerCommonName = commonName(certificate.issuerX500Principal), + notAfter = notAfterInstant(certificate), + ) + + private fun commonName(principal: X500Principal): String? = + try { + LdapName(principal.getName(X500Principal.RFC2253)).rdns + .firstOrNull { it.type.equals(COMMON_NAME_TYPE, ignoreCase = true) } + ?.value + ?.toString() + } catch (_: Exception) { + null + } + + private fun notAfterInstant(certificate: X509Certificate): Instant? = + try { + certificate.notAfter?.toInstant() + } catch (_: Exception) { + null + } +} diff --git a/android/client-tls/src/main/kotlin/wang/yaojia/webterm/clienttls/ClientKeyManagerLogic.kt b/android/client-tls/src/main/kotlin/wang/yaojia/webterm/clienttls/ClientKeyManagerLogic.kt new file mode 100644 index 0000000..108dabd --- /dev/null +++ b/android/client-tls/src/main/kotlin/wang/yaojia/webterm/clienttls/ClientKeyManagerLogic.kt @@ -0,0 +1,71 @@ +package wang.yaojia.webterm.clienttls + +/** + * The pinned device identity as seen by the key-manager selection logic: just enough to *decide* + * whether to present a client certificate. The key material (private key + chain) lives in the + * framework half (A11, AndroidKeyStore-backed) — the pure logic only needs the alias and the key + * algorithm to make the selection. + * + * @property alias the single device-identity alias this app presents. + * @property keyAlgorithm the leaf key's algorithm (`RSA` / `EC`), matched against the server's + * requested client key types. + */ +public data class KeyManagerIdentity( + val alias: String, + val keyAlgorithm: String, +) + +/** + * The pure, synchronous alias-selection heart of a client `X509KeyManager` (the Android analogue of + * iOS `MutualTLSChallengeResponder`). Deliberately free of any `SSLEngine` / `Socket` / OkHttp + * state so the full truth table is unit-testable without a live handshake; the framework half (A11) + * is a thin `X509KeyManager` that delegates alias decisions here and reads the actual + * `PrivateKey`/chain from AndroidKeyStore for the [KeyManagerIdentity.alias]. + * + * Truth table (mirrors iOS C-iOS-1): + * a. identity present + server accepts our key type → present our [KeyManagerIdentity.alias]. + * b. identity present + key-type mismatch → present nothing (null) — a clean, classifiable + * handshake failure rather than a wrong-type cert. + * c. no identity installed → present nothing (null) — never a silent + * wrong-cert; the TLS handshake fails and the failure surfaces (iOS `.cancel`). + * + * @property identity the installed device identity, or null when none is installed. + */ +public class ClientKeyManagerLogic( + private val identity: KeyManagerIdentity?, +) { + /** + * The alias to present for a client-certificate request whose acceptable public-key types are + * [keyTypes] (the `keyType` argument of `X509KeyManager.chooseClientAlias`). Returns the pinned + * alias iff an identity is installed AND its key algorithm is acceptable; null otherwise. A null + * or empty [keyTypes] (server expressed no constraint) is treated as "accept" (fail-open — keep + * the handshake working when there is exactly one pinned identity). + */ + public fun chooseClientAlias(keyTypes: Array?): String? { + val id = identity ?: return null + return if (accepts(keyTypes)) id.alias else null + } + + /** + * All aliases eligible for [keyType] (the `X509KeyManager.getClientAliases` shape): a + * single-element array of the pinned alias when eligible, else null. + */ + public fun clientAliases(keyType: String?): Array? { + val id = identity ?: return null + return if (accepts(if (keyType == null) null else arrayOf(keyType))) arrayOf(id.alias) else null + } + + /** + * True iff [candidate] is the pinned alias of an installed identity. The framework + * `getPrivateKey(alias)` / `getCertificateChain(alias)` MUST gate on this before returning key + * material, so a foreign alias never leaks our identity. + */ + public fun ownsAlias(candidate: String?): Boolean = + identity != null && candidate != null && candidate == identity.alias + + private fun accepts(keyTypes: Array?): Boolean { + val id = identity ?: return false + if (keyTypes == null || keyTypes.isEmpty()) return true + return keyTypes.any { it.equals(id.keyAlgorithm, ignoreCase = true) } + } +} diff --git a/android/client-tls/src/main/kotlin/wang/yaojia/webterm/clienttls/Pkcs12Parse.kt b/android/client-tls/src/main/kotlin/wang/yaojia/webterm/clienttls/Pkcs12Parse.kt new file mode 100644 index 0000000..c365bc8 --- /dev/null +++ b/android/client-tls/src/main/kotlin/wang/yaojia/webterm/clienttls/Pkcs12Parse.kt @@ -0,0 +1,163 @@ +package wang.yaojia.webterm.clienttls + +import java.io.ByteArrayInputStream +import java.io.IOException +import java.security.GeneralSecurityException +import java.security.KeyStore +import java.security.PrivateKey +import java.security.UnrecoverableKeyException +import java.security.cert.X509Certificate +import javax.crypto.BadPaddingException + +/** + * The structural result of parsing a `.p12` import file — the private key, its leaf certificate, + * and the issuer chain that accompanies it in the TLS handshake. Android analogue of iOS + * `ClientIdentity`, but holding only the *pure JVM* material. + * + * @property alias the PKCS#12 key-entry alias the identity was found under. + * @property privateKey the leaf private key (device authentication key). + * @property keyAlgorithm the private key's algorithm (e.g. `RSA`, `EC`) — drives + * [ClientKeyManagerLogic] client-alias selection. + * @property leafCertificate the leaf certificate (chain[0]); the device's own cert. + * @property issuerCertificates the issuer chain (the CA chain, leaf excluded) — may be empty when + * the server already pins the trust anchor. Mirrors iOS `issuerChain` (do not repeat the leaf). + */ +public data class ParsedClientIdentity( + val alias: String, + val privateKey: PrivateKey, + val keyAlgorithm: String, + val leafCertificate: X509Certificate, + val issuerCertificates: List, +) { + /** The full chain with the leaf at index 0, as presented to the server. */ + public val fullChain: List + get() = listOf(leafCertificate) + issuerCertificates + + /** Display summary of the leaf certificate for the install/rotation UI. */ + public fun summary(): CertificateSummary = CertificateSummaryReader.summarize(leafCertificate) +} + +/** + * Decoded, but carried no importable private-key entry (e.g. a certs-only / truststore `.p12`). + * The iOS `PKCS12ImportError.noIdentity` analogue. + */ +public class NoClientIdentityException(message: String) : GeneralSecurityException(message) + +/** + * Not a decodable PKCS#12 blob (truncated / not a `.p12` / unsupported algorithms). The iOS + * `PKCS12ImportError.corruptFile` / `.unsupported` analogue; retains the underlying cause. + */ +public class Pkcs12DecodeException(message: String, cause: Throwable? = null) : + GeneralSecurityException(message, cause) + +/** + * Structural PKCS#12 parse via `KeyStore("PKCS12")` — JVM stdlib, needs NO Android SDK. This is the + * pure half of the iOS `PKCS12Importer`: it validates the passphrase and extracts the identity, but + * (unlike the framework half A11) never touches AndroidKeyStore/Tink and never persists anything. + * + * Error surface (matches the A10 verify contract): + * - wrong passphrase → [UnrecoverableKeyException] (the JVM idiom; the underlying cause is + * unwrapped so callers see the specific security exception, not a generic [IOException]). + * - corrupt / not-a-p12 / unsupported → [Pkcs12DecodeException]. + * - decoded but no key entry → [NoClientIdentityException]. + */ +public object Pkcs12Parse { + private const val PKCS12 = "PKCS12" + private const val MAX_CAUSE_DEPTH = 8 + + /** Case-insensitive message fragments that a JCE provider uses for a rejected PKCS#12 passphrase. */ + private val WRONG_PASSPHRASE_HINTS = listOf("password", "mac", "integrity") + + /** + * Parse [bytes] with [passphrase] and return the first key-entry identity found. The alias is + * selected by preferring the first entry that is a *key* entry (a `.p12` may also carry + * trusted-cert entries, which are skipped — they hold no private key). + */ + public fun parse(bytes: ByteArray, passphrase: String): ParsedClientIdentity { + val password = passphrase.toCharArray() + val keyStore = loadKeyStore(bytes, password) + val alias = firstKeyAlias(keyStore) + ?: throw NoClientIdentityException("PKCS#12 contained no private-key entry") + return buildIdentity(keyStore, alias, password) + } + + private fun loadKeyStore(bytes: ByteArray, password: CharArray): KeyStore { + val keyStore = KeyStore.getInstance(PKCS12) + try { + ByteArrayInputStream(bytes).use { keyStore.load(it, password) } + } catch (e: IOException) { + throw classifyLoadFailure(e) + } + return keyStore + } + + /** + * Map a `KeyStore.load` [IOException] to the A10 error surface. A wrong passphrase surfaces + * DIFFERENTLY per JCE provider: SunJSSE throws an IOException caused by [UnrecoverableKeyException] + * (MAC/integrity check), while Android's BouncyCastle throws one caused by a + * [BadPaddingException] (or carries a "password"/"mac"/"integrity" message). Treat ALL of those + * as the wrong-passphrase signal so a bad password is never misreported on-device as a corrupt + * file; only a genuinely undecodable blob stays a [Pkcs12DecodeException]. Never echoes the + * passphrase. `internal` so the classification is unit-testable without a provider-specific blob. + */ + internal fun classifyLoadFailure(error: IOException): GeneralSecurityException = + if (isWrongPassphrase(error)) { + wrongPassphrase(error) + } else { + Pkcs12DecodeException("PKCS#12 blob could not be decoded", error) + } + + private fun isWrongPassphrase(error: Throwable): Boolean = + causeChain(error).any { cause -> + cause is UnrecoverableKeyException || + cause is BadPaddingException || + messageHintsWrongPassphrase(cause.message) + } + + private fun messageHintsWrongPassphrase(message: String?): Boolean { + val lowered = message?.lowercase() ?: return false + return WRONG_PASSPHRASE_HINTS.any { it in lowered } + } + + /** Prefer a provider-native [UnrecoverableKeyException]; otherwise wrap (never echo the passphrase). */ + private fun wrongPassphrase(error: Throwable): UnrecoverableKeyException = + causeChain(error).filterIsInstance().firstOrNull() + ?: UnrecoverableKeyException("PKCS#12 passphrase was rejected (MAC/integrity check failed)") + + /** Bounded cause-chain walk with an identity cycle-guard (never trust an error graph not to cycle). */ + private fun causeChain(error: Throwable): List { + val chain = mutableListOf() + var current: Throwable? = error + while (current != null && chain.size < MAX_CAUSE_DEPTH) { + if (chain.any { it === current }) break + chain.add(current) + current = current.cause + } + return chain + } + + /** First alias that holds a private key; trusted-cert-only entries are skipped. */ + private fun firstKeyAlias(keyStore: KeyStore): String? = + keyStore.aliases().toList().firstOrNull { keyStore.isKeyEntry(it) } + + private fun buildIdentity( + keyStore: KeyStore, + alias: String, + password: CharArray, + ): ParsedClientIdentity { + // getKey with a wrong key-password throws UnrecoverableKeyException directly (propagated). + val key = keyStore.getKey(alias, password) as? PrivateKey + ?: throw NoClientIdentityException("Entry '$alias' held no private key") + val chain = (keyStore.getCertificateChain(alias) ?: emptyArray()) + .filterIsInstance() + val leaf = chain.firstOrNull() + ?: throw NoClientIdentityException("Entry '$alias' had no certificate chain") + return ParsedClientIdentity( + alias = alias, + privateKey = key, + keyAlgorithm = key.algorithm, + leafCertificate = leaf, + issuerCertificates = chain.drop(1), + ) + } +} diff --git a/android/client-tls/src/main/kotlin/wang/yaojia/webterm/tls/.gitkeep b/android/client-tls/src/main/kotlin/wang/yaojia/webterm/tls/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/android/client-tls/src/test/kotlin/wang/yaojia/webterm/clienttls/CertificateSummaryTest.kt b/android/client-tls/src/test/kotlin/wang/yaojia/webterm/clienttls/CertificateSummaryTest.kt new file mode 100644 index 0000000..62d4582 --- /dev/null +++ b/android/client-tls/src/test/kotlin/wang/yaojia/webterm/clienttls/CertificateSummaryTest.kt @@ -0,0 +1,64 @@ +package wang.yaojia.webterm.clienttls + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.Instant +import kotlin.time.Duration.Companion.days + +/** Ports iOS `CertificateSummary` tests — field extraction off a real cert + the isExpired rule. */ +class CertificateSummaryTest { + private fun leafSummary(): CertificateSummary = + Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.PASSPHRASE).summary() + + @Test + fun extractsSubjectAndIssuerCommonNamesFromRealCertificate() { + val summary = leafSummary() + assertEquals(Fixtures.LEAF_SUBJECT_CN, summary.subjectCommonName) + assertEquals(Fixtures.LEAF_ISSUER_CN, summary.issuerCommonName) + } + + @Test + fun extractsNotAfterFromRealCertificate() { + val summary = leafSummary() + assertNotNull(summary.notAfter, "notAfter should parse off the fixture cert") + // Fixture was minted with a ~100-year validity, so it is not expired now. + assertFalse(summary.isExpired(Instant.now())) + } + + @Test + fun isExpiredIsTrueStrictlyAfterNotAfter() { + val notAfter = Instant.parse("2030-01-01T00:00:00Z") + val summary = CertificateSummary(subjectCommonName = "x", issuerCommonName = "y", notAfter = notAfter) + + assertFalse(summary.isExpired(notAfter.minusMillis(1)), "just before expiry") + assertFalse(summary.isExpired(notAfter), "exactly at notAfter is not yet expired") + assertTrue(summary.isExpired(notAfter.plusMillis(1)), "just after expiry") + assertTrue(summary.isExpired(notAfter.plusSeconds(365.days.inWholeSeconds)), "well after expiry") + } + + @Test + fun unknownExpiryFailsOpenAsNotExpired() { + val summary = CertificateSummary(subjectCommonName = null, issuerCommonName = null, notAfter = null) + assertFalse(summary.isExpired(Instant.now()), "null notAfter is display-only fail-open → not expired") + } + + @Test + fun realCertificateNotAfterMatchesLeafExpiry() { + // Derive isExpired boundaries from the parsed notAfter so the test stays deterministic + // regardless of the fixture generation date. + val summary = leafSummary() + val notAfter = requireNotNull(summary.notAfter) + assertFalse(summary.isExpired(notAfter.minusSeconds(1))) + assertTrue(summary.isExpired(notAfter.plusSeconds(1))) + } + + @Test + fun summaryFromCertificateReaderMatchesParsedLeaf() { + val identity = Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.PASSPHRASE) + val direct = CertificateSummaryReader.summarize(identity.leafCertificate) + assertEquals(identity.summary(), direct) + } +} diff --git a/android/client-tls/src/test/kotlin/wang/yaojia/webterm/clienttls/ClientKeyManagerLogicTest.kt b/android/client-tls/src/test/kotlin/wang/yaojia/webterm/clienttls/ClientKeyManagerLogicTest.kt new file mode 100644 index 0000000..8fa4cc6 --- /dev/null +++ b/android/client-tls/src/test/kotlin/wang/yaojia/webterm/clienttls/ClientKeyManagerLogicTest.kt @@ -0,0 +1,59 @@ +package wang.yaojia.webterm.clienttls + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Truth table for the pure client X509KeyManager alias selection (iOS `MutualTLSChallengeResponder` + * analogue): present iff an identity is installed AND its key type is acceptable. + */ +class ClientKeyManagerLogicTest { + private val alias = "device" + private val installed = ClientKeyManagerLogic(KeyManagerIdentity(alias = alias, keyAlgorithm = "RSA")) + private val empty = ClientKeyManagerLogic(identity = null) + + @Test + fun presentsAliasWhenServerAcceptsOurKeyType() { + assertEquals(alias, installed.chooseClientAlias(arrayOf("RSA"))) + assertEquals(alias, installed.chooseClientAlias(arrayOf("EC", "RSA"))) + assertEquals(alias, installed.chooseClientAlias(arrayOf("rsa")), "match is case-insensitive") + } + + @Test + fun failsOpenWhenServerExpressesNoKeyTypeConstraint() { + assertEquals(alias, installed.chooseClientAlias(null)) + assertEquals(alias, installed.chooseClientAlias(emptyArray())) + } + + @Test + fun presentsNothingOnKeyTypeMismatch() { + assertNull(installed.chooseClientAlias(arrayOf("EC"))) + assertNull(installed.chooseClientAlias(arrayOf("DSA", "EC"))) + } + + @Test + fun presentsNothingWhenNoIdentityInstalled() { + assertNull(empty.chooseClientAlias(arrayOf("RSA"))) + assertNull(empty.chooseClientAlias(null)) + assertNull(empty.clientAliases("RSA")) + assertFalse(empty.ownsAlias(alias)) + } + + @Test + fun clientAliasesReturnsSingletonWhenEligible() { + assertArrayEquals(arrayOf(alias), installed.clientAliases("RSA")) + assertArrayEquals(arrayOf(alias), installed.clientAliases(null)) + assertNull(installed.clientAliases("EC")) + } + + @Test + fun ownsAliasGatesKeyMaterialToThePinnedAlias() { + assertTrue(installed.ownsAlias(alias)) + assertFalse(installed.ownsAlias("someone-else")) + assertFalse(installed.ownsAlias(null)) + } +} diff --git a/android/client-tls/src/test/kotlin/wang/yaojia/webterm/clienttls/HostClassifierTest.kt b/android/client-tls/src/test/kotlin/wang/yaojia/webterm/clienttls/HostClassifierTest.kt new file mode 100644 index 0000000..8bfa8a4 --- /dev/null +++ b/android/client-tls/src/test/kotlin/wang/yaojia/webterm/clienttls/HostClassifierTest.kt @@ -0,0 +1,80 @@ +package wang.yaojia.webterm.clienttls + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.wire.HostClassifier +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HostNetworkTier + +/** Ports iOS `HostClassificationTests` — the four §5.4 warning tiers + the fail-safe default. */ +class HostClassifierTest { + @Test + fun loopbackTierMatchesLocalTargets() { + val hosts = listOf("localhost", "LOCALHOST", "127.0.0.1", "127.8.8.8", "::1", "[::1]") + for (host in hosts) { + assertEquals(HostNetworkTier.LOOPBACK, HostClassifier.classify(host), "$host should be loopback") + } + } + + @Test + fun privateLanTierMatchesRfc1918AndLinkLocal() { + val hosts = listOf( + "10.0.0.5", "192.168.0.9", "172.16.0.1", "172.31.255.255", + "169.254.1.1", "mac-mini.local", "Mac-Mini.LOCAL", + ) + for (host in hosts) { + assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify(host), "$host should be privateLAN") + } + } + + @Test + fun tailscaleTierMatchesCgnatAndMagicDns() { + val hosts = listOf( + "100.64.0.1", "100.100.1.1", "100.127.255.255", + "mac.tailnet.ts.net", "foo.TS.NET", + ) + for (host in hosts) { + assertEquals(HostNetworkTier.TAILSCALE, HostClassifier.classify(host), "$host should be tailscale") + } + } + + @Test + fun publicTierIsTheFailSafeDefault() { + // Out of range: 172.32 exceeds 172.16/12; 100.128 & 100.63 exceed 100.64/10; malformed IPv4. + val hosts = listOf( + "8.8.8.8", "203.0.113.7", "example.com", "tsnet.example.com", + "172.32.0.1", "100.128.0.1", "100.63.255.255", "256.1.1.1", "1.2.3", "", + ) + for (host in hosts) { + assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify(host), "$host should be public") + } + } + + @Test + fun classifyEndpointMatchesHostStringVersion() { + val vectors = listOf( + "http://127.0.0.1:3000" to HostNetworkTier.LOOPBACK, + "http://192.168.1.5:3000" to HostNetworkTier.PRIVATE_LAN, + "http://100.100.1.1:3000" to HostNetworkTier.TAILSCALE, + "https://mac.tailnet.ts.net" to HostNetworkTier.TAILSCALE, + "https://example.com" to HostNetworkTier.PUBLIC, + ) + for ((url, tier) in vectors) { + val endpoint = requireNotNull(HostEndpoint.fromBaseUrl(url)) { "endpoint for $url" } + assertEquals(tier, HostClassifier.classify(endpoint), url) + } + } + + @Test + fun ipv4OctetsStrictParseRejectsNonDottedQuads() { + assertNull(HostClassifier.ipv4Octets("")) + assertNull(HostClassifier.ipv4Octets("1.2.3")) + assertNull(HostClassifier.ipv4Octets("1.2.3.4.5")) + assertNull(HostClassifier.ipv4Octets("256.1.1.1")) + assertNull(HostClassifier.ipv4Octets("-1.2.3.4")) + assertNull(HostClassifier.ipv4Octets("a.b.c.d")) + assertNull(HostClassifier.ipv4Octets(".1.2.3")) + assertEquals(listOf(10, 0, 0, 255), HostClassifier.ipv4Octets("10.0.0.255")?.toList()) + } +} diff --git a/android/client-tls/src/test/kotlin/wang/yaojia/webterm/clienttls/Pkcs12ParseTest.kt b/android/client-tls/src/test/kotlin/wang/yaojia/webterm/clienttls/Pkcs12ParseTest.kt new file mode 100644 index 0000000..55e5f0d --- /dev/null +++ b/android/client-tls/src/test/kotlin/wang/yaojia/webterm/clienttls/Pkcs12ParseTest.kt @@ -0,0 +1,89 @@ +package wang.yaojia.webterm.clienttls + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.io.IOException +import java.security.UnrecoverableKeyException +import javax.crypto.BadPaddingException + +/** Ports iOS `PKCS12Importer` tests against a real keytool-minted `.p12` fixture (pure JVM). */ +class Pkcs12ParseTest { + @Test + fun parsesLeafIdentityWithChainAndKey() { + val identity = Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.PASSPHRASE) + + assertEquals("device", identity.alias, "must select the key entry, not the trusted-cert entry") + assertEquals("RSA", identity.keyAlgorithm) + assertEquals(Fixtures.LEAF_SUBJECT_CN, identity.summary().subjectCommonName) + } + + @Test + fun issuerChainExcludesTheLeafAndKeepsTheCa() { + val identity = Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.PASSPHRASE) + + // Full chain is leaf → ca; issuerCertificates drops the leaf (mirrors iOS issuerChain). + assertEquals(2, identity.fullChain.size) + assertEquals(1, identity.issuerCertificates.size) + assertEquals(identity.leafCertificate, identity.fullChain.first()) + val caSummary = CertificateSummaryReader.summarize(identity.issuerCertificates.first()) + assertEquals(Fixtures.LEAF_ISSUER_CN, caSummary.subjectCommonName, "issuer cert is the device CA") + } + + @Test + fun wrongPassphraseThrowsUnrecoverableKeyException() { + assertThrows { + Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.WRONG_PASSPHRASE) + } + } + + @Test + fun corruptBlobThrowsDecodeException() { + val garbage = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8) + assertThrows { + Pkcs12Parse.parse(garbage, Fixtures.PASSPHRASE) + } + } + + @Test + fun bouncyCastleBadPaddingIsClassifiedAsWrongPassphraseNotCorrupt() { + // On-device (BouncyCastle) a wrong passphrase surfaces as an IOException caused by + // BadPaddingException, NOT UnrecoverableKeyException — it must still map to wrong-passphrase. + val ioe = IOException("exception unwrapping private key", BadPaddingException("pad block corrupted")) + + assertInstanceOf(UnrecoverableKeyException::class.java, Pkcs12Parse.classifyLoadFailure(ioe)) + } + + @Test + fun wrongPasswordMessageIsClassifiedAsWrongPassphrase() { + // Some providers only report the failure in the message (no typed cause). + assertInstanceOf( + UnrecoverableKeyException::class.java, + Pkcs12Parse.classifyLoadFailure(IOException("keystore password was incorrect")), + ) + } + + @Test + fun genuinelyUndecodableBlobStaysDecodeException() { + // A structural DER failure carries no wrong-passphrase signal → stays a decode error. + assertInstanceOf( + Pkcs12DecodeException::class.java, + Pkcs12Parse.classifyLoadFailure(IOException("DerInputStream.getLength(): lengthTag=127, too big")), + ) + } + + @Test + fun certsOnlyStoreThrowsNoClientIdentity() { + assertThrows { + Pkcs12Parse.parse(Fixtures.trustP12(), Fixtures.PASSPHRASE) + } + } + + @Test + fun privateKeyIsRecoverableWithCorrectPassphrase() { + val identity = Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.PASSPHRASE) + assertTrue(identity.privateKey.encoded.isNotEmpty(), "private key material is present") + } +} diff --git a/android/client-tls/src/test/kotlin/wang/yaojia/webterm/clienttls/TestFixtures.kt b/android/client-tls/src/test/kotlin/wang/yaojia/webterm/clienttls/TestFixtures.kt new file mode 100644 index 0000000..c060ef2 --- /dev/null +++ b/android/client-tls/src/test/kotlin/wang/yaojia/webterm/clienttls/TestFixtures.kt @@ -0,0 +1,31 @@ +package wang.yaojia.webterm.clienttls + +import java.util.Base64 + +/** + * Real, deterministic PKCS#12 fixtures generated once with `keytool` (JDK 23), embedded base64 so + * the pure tests need no external tooling / files. Regeneration recipe is in the A10 log entry. + * + * - [LEAF_P12]: keystore with a `device` PrivateKeyEntry (CN=`t1-android`, RSA, chain + * `device → ca`) AND a `ca` trustedCertEntry — exercises key-alias selection over a mixed store. + * Issuer CN = `webterm-device-ca`, notAfter far in the future. + * - [TRUST_P12]: keystore with only the `ca` trustedCertEntry (no key) — the noIdentity case. + * + * Passphrase for both is [PASSPHRASE]. + */ +internal object Fixtures { + const val PASSPHRASE: String = "test-pass" + const val WRONG_PASSPHRASE: String = "wrong-pass" + + const val LEAF_SUBJECT_CN: String = "t1-android" + const val LEAF_ISSUER_CN: String = "webterm-device-ca" + + fun leafP12(): ByteArray = Base64.getDecoder().decode(LEAF_P12) + fun trustP12(): ByteArray = Base64.getDecoder().decode(TRUST_P12) + + private const val LEAF_P12: String = + "MIIQ1AIBAzCCEH4GCSqGSIb3DQEHAaCCEG8EghBrMIIQZzCCBa4GCSqGSIb3DQEHAaCCBZ8EggWbMIIFlzCCBZMGCyqGSIb3DQEMCgECoIIFQDCCBTwwZgYJKoZIhvcNAQUNMFkwOAYJKoZIhvcNAQUMMCsEFPpaTqIhQl8k+0dEeRcsfbjIn4dZAgInEAIBIDAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQaG2cWtSTczZKePLT0eg4RwSCBNDYTsTE5J2fGdHdBTcU/JDiIw9MY2THYSoHqrm/kb5uisEu8YFL8d+owv0oLzf88dSxp0vHGc9js/+Zx8e1POYNskINrOHhyh9tzRD8ecZHc2D/lUX5XG0eGSTFiCQ8LejczN8zRZNBn+RyZ7HmKCYxljF0UJhpOVE7fK0T3QAmTLG7TlknGBoEcQRSE1EaljYwTztBJTwCljQBCCKssL5C1j+28Z5mIJ+yALzTgKWbBoJak5mahF0Ti9FcwMBT7MKTlGlxvfgP2uRt/IRFXDHXJWH9xje/bx4hzegVrd29I/4lBQifN6p6gREHvrb0DvhigmrVtRIRXGjtxgYow5fGyrZsLV2LhzHsrTeo8V3wYjgFGtRa7/cpv/trOtQVz9zIVjk1ahTjNvEYuZKHwfVdSXz3P31BhYJzwWFh0AJNNltowB/rvECXKw8lxuTuy5xoBN9QrImqVV/IeW5sFVkm7NAO58Wy5naQwqbYLm+xV7Pur2xb/Ew4CnnjMzfvmvtl9BE60faK7gnBL/jceSa2HVgYtR7nxZFaYeUzUAq91yaqwZZeJCFMWklXdKxfdBEml1krRDKbbxI3u+JaTXAPdyAATKgg8paY+TiiB+gLAo0qfgaK8azAk6foOabZ9DS1a3KZi0aAmKssNUg9XwdG82I5udsJ4ExR3LPF5rJiU8w85CEMYjoNFFl/H0BCGRh4DLsjK87AaR/5bsm/9RCL5ULBDmXmD43VyP0/9LT+vNRGOzZo9DNvj2srXUX0J3SOer9D5hqYVYPs9kk3obAQfw5IVqnuiPrKf4aoHwZGpMhrBkRxg7Ut2PEeHbFbpVDC95yg1eApiAzxGI2051nWkBvE0JfRNh8jiv+RcezaRMePCNlhCB3yHU6BFwnvUC212S5W40xrpaV4BiMIqc59rvNl1ZeauKU4oWCH58vF/js6q96KavjQ5X+0OCEK6xKK/lT02N0iw5jwhXku8gQig8+hTE5s+zn2PHSW6sJTQiDJUTXMsPNCUP7zl6XQrJp+5j9RzAjqqlQ+Ttc4kJOcWC7bDqv1L0YGr3nsAMM9kLr5eXAESGD/JVVdvHTT9iuFxg7DxSkfZji15Sbji101xN8LjWuu2i1bxszqO46evBfI8f8HRaTYyhoSR02J3lsJ/DEJ398FJF/L2jm3Uf1eoPy0X9c5oP1OYXqb8FdwoL+Jch36gbmcIjl3JPdvelf7OasQeDXu5N/zn+Phi3RoFIb8yFQ4jrj/3bb6w7KOrYUOjVrph5bSHPPPcMbB3ZVXX6/MejXHtSMJOWHkuNLLi6VaZQU3n0rIgRdV+R0lOg8tieR63Jml3yF9udw+Vk9LebPWgko0lYrPVVRj3+XRkJD+1pvXUma6hxDDg2pAwucmkOP8XLU3Ax8uDhHDNxU2MKhSoehZZT8mm7ey6V7Z9FFaU2nozldT6KSJMolAYsIWh0U/38Rs6avnZE+KJHAbJOR/rxxdrZbF4XSQyBlEfEEGmHfxGwKYx9bq88ePM/i3FNN7rhTXj0GOiF/d9/8Rp7V2OR3FC+57eVnHD6FAa4XmszFKsECdq6meNzp7MUsq70/EBTklzJiVG+BSg+mVO2T48RjYty2j1Bev4cvnPzs02HM8WePQ8KlV2YMgsDFAMBsGCSqGSIb3DQEJFDEOHgwAZABlAHYAaQBjAGUwIQYJKoZIhvcNAQkVMRQEElRpbWUgMTc4MzUwNzk1MTAwNjCCCrEGCSqGSIb3DQEHBqCCCqIwggqeAgEAMIIKlwYJKoZIhvcNAQcBMGYGCSqGSIb3DQEFDTBZMDgGCSqGSIb3DQEFDDArBBSVIVu0GwH+Y2n0kk+lsYlHoWvQgAICJxACASAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEEObgqLbs3jUaA3uhbKvA4yAggog8fzgoVKaV7bvhWpbBxWjR5a2Wob6RZ4/itO440YsraKbCu676Ic+IDg4X388OX5d0dip/Y/O6imGiiy/KTWeKbeO82wIQcCnIbo/egCQ3xqECGcR/9OptLKSzrexSQ+CcJNNfs9bT6bkeNtcfUc0QxTuW6U7l9znMSx8qMYYxrK8dzeMqKJt8zNRYPc8CVrSiL8wA//BK5RBfN+fAh5GnJcsvU0LQRka4guGTovv3vwze2nvOPPIcYOdHlflMoKrC07+dFGCpJcWaMjRbnqo/GTcZX1CwTArbbmmv8j78kit01o4AaAyWAUP8NOYGEnC544Fw4ZZwtfG6ZlVqRVB6rWoMrwmzg72HqJuiZxw3u2TvUQTUjgcWnTtvclyPq8sqsVZLnJUWRrGHqHyi3ApNsWU29CjxlyLIgShGPU02AuWps6YAHaXdcV5k5rQPYMgKZOuYrOaXj5lLrmQgf3/B9jN2t95umAFsTK2wLNsbLmTACYsdMSVFiW26xYqRKIu4ZDzZX6OMHN9Ucjix6V8Tkobf0VbXFP4Yctzf6jd8bTUEFvGLWAWm5w/Ui9c6CyS0sch3AzitMUNUbYLPyW1ISsn2ZqlASE1+8vlALKwqvQdoieqbXktaEwSUNwmCD8hvTeABOKGPpWAnIsM8TZGVlVXc2BtkLZH6FFu1QV/8hJLvHv2pvld44ovUxYQ8d1kC9Wosqx+V8oGM3bGDYl0twBVRGoE/St1175AQ4XFFANP5WWutpjn/VjQ/Rf9bbSUm0FJ0WQv9NmwekVDP6XScyfSIV4lb86J7hxo0xq17tqvqTyjc0l/8nq3FaGp3jqu4efPJ14VO1sat1wZvjGSkQ2brg6v2NNYyQXKE54UlAg3e4Ur1exqs6xBrCGjL6t6IMxwGrodx6aeHNf820+NBzmya7Gs2tBunWKD2pnX49Y1ydCgkJs76o16zebD7As+gyzwWlk1zT+a3jblSCmzT/P2NhLhbfrHyNEvFnRoa0owzL8ahuDixaCunoq+YHci0m2JPDZBD9Xms8M9YtE2UHYjWc44ufhkG8QALrvBGS5mU7DMfjxWNzhenN75xvM6xIDl58UE/XYb0hr+yG/9Ho6foZDm251tX8u0lw7Ou/0Jfi0X58tKi52x+oCd3n+lwfhOakKoStPalhbZqwF3qqsf1W3xPRk9puEDo68QPrL7Cbk40oC5InXVml5m1HEUJ0gnblM+XYcplsBqKmAqt2Yk74VU+bmY5B3WJvwIpXS4hxhxm5Bjq27iOT+ooRbu3DvbBLLsjuFh5Fw2APWiLs9tw8fxwl9nrpGUsO7A2dGSAqu6fMb8Q/mKO641yJWkoOAdFf3/NeD7gURHMxTx4PRoFekXWEEFU65djWQ0nnCYfxbXIMgCoVQhQnPCzq7Z0UZni6ve0y160vmyl+CTc8qweDqt5IQoPp2ELrpbkBJa/2/13v2qWQWIScxdLIJ6NYjS7CbvMXpwyj/MDOZ4MyqYItv+XfmrX/h8SWspi5SsHAdm3/kxijAxzulXRkz6K1UUDEu6nzIybVvaOJSq0OuJ6ZAI9II4Spfq0U9aFACWE9Wh2VeIQwdZ8ynjGipIub6sOQiTm7+EWgWkUu/5lNlk2Y1mg/Qn7JxdILLBLLY2Af77RTLqTXp4BIFurhzqeFH7DOiwPfHZ176XIYFF080nyM7FG8xI92XK1gyMgmVlqYDB24jI58wj4QAUaGturyyrNdLDrBKos2oOcS06WDmUJzLJkcmgCKyFwMcwbIEANsngVlTEfBgSoiaszWgNuXeqazIHrpNEWUAY4RvYX+y3uLFFDG4MlxGdv5ftePpdvZZa2MA098o3mgmUnXTejQs3jLTILGLQ03sKIGOLVPvMIyplUyo+KfBVKx9etU2LwvB2bsvUDGwHE2hxbuGkU4Z6lCNqWpV+HHmmjw8uUzXbuaYDxVhtCPb5ZMaEtTBG8lECYP24JXYCQCKyGmqHOkJVDyx7zLKIE4sImM25OozS7ytsSW//fdV0W06Okdf4SqmAzfqUtTGWBq2OftX7PHgEdoGP3K2nVKwUl9jp4j2RPvl3J9MKOr52BdzBSXrc/8e3VG8IfnMNgPtAG8c8W0AYnUNBY1kNn2L3BR9ffh2vEw0PA0Ij7Rsy6WGpLUYM2rGhYypIRUbNJjwBjiW70gdf7NBwC9YCkVO98HBxs0S1P8ZZrv7w4/7VpGCegsspqfsjeXmtSOcFaJShhYN46BxDU4cvEriZY88B1d5lwwokXGEIPheal0PtSt+wBe7OkdJSEEqj1R2HSILyYuGeVgmBzzN+JBLh7lXr7y3GAHspZtW+enGGeUJ6LKxIS92PYAy23jNRUGAPPzCGWWuRu/LHMG7CLi4rdwDkKVI840v79gjwXTq/SlK3yqcDAVhL/Dk5oA7hUIXMviQ0185zPcPj8cZMDIoi/QVaG+l+iFgv15iiurZbB9q0KgQj0VZW4HHA5ESCJrX/8SItRrbiTnkY2JlWKURcDN/NusRVRaNDaXiNVgCaXlAcfmH8V6GSki6XaiSKVX3tZ/+wGMRUVacXyZljrroA0ozFyLoSEj0R/wo9sTc88Edut7UVEq92VViAzyB9Scrvelee895TqPBhCChWG0vWC9Twb7t1yMr4PDnqdcMjUNsCr+Euruwo0nteCmPBkVqhIBfbqjhNDg32zAi7AuZlFFL+H48dtRcVfvu+vg9EN9TgeupbsZW9vm95Y2hWBhyvNDE7/+tNx6NNEzf/fYZTloHaFmdWJiE51RbQn8hDYXyV8x1jvxXxEO2qTUTny1O1YWdIYMm1SGtflN831hoeBr57bt3O2QF0dUeU524nVBA13Rb94MdMdVMmvTjPdathvnErNhONadaq9BuYmp4+PcSPrnXKQz/sUfXrbRkBroPc61sWIIVFCSLPBnwdHqbu5tRsGsZYXofj/K0eUvvI5LKHnKNrUcmnckhGpDFC+N2cSVPKsrt0TND4bRD+PKGLxgb4Qshl/eHRdLd7qlr1Rgvm3hFuE2ajgrUWypgQwGRh0ZWoRqqIsUpRVEfwazBPfmeYupKK9UXlCsj/IYoLo3MohDikpgQJpAEVeAgQy/svYHRpSPt6ankfg0jOS+0s2qQy373xnlm9juraIpCUh8dJa1gkeWiM571c+0cl4gHF6zIfX5uLbBHwxw0K+GtAAYm9Wv53FW0qKyR6mIaJH6wx1M/WpHFd9asEDrSeR3WpSwGvEcah5qQ91LYIWURRDDwpcmyup7Ru5c+v7P4CrE7GUg6W84EUV9c3slgxQJW5SQInaDwSgnbuZhAC0c2kQ1aVoUepgf6BQhE7vQDhrh0kvsxNahMFeQVZa2614m+hL/FSWOU5aRsvMgUw50ldnGrR2dghqXKExTnjrCRVRVZNf9isZQe1+6QaSS2dDnFRq1afZO3knFTk3kZzjJuI7ir06J72ME0wMTANBglghkgBZQMEAgEFAAQgVqKg5coUVzlTAr5tT3DmW4kb3k9o6QdVs6cQQfK+jPQEFAWLju6rft4pPbB1rtQH8nOEVgBFAgInEA==" + + private const val TRUST_P12: String = + "MIIEYgIBAzCCBAwGCSqGSIb3DQEHAaCCA/0EggP5MIID9TCCA/EGCSqGSIb3DQEHBqCCA+IwggPeAgEAMIID1wYJKoZIhvcNAQcBMGYGCSqGSIb3DQEFDTBZMDgGCSqGSIb3DQEFDDArBBSrewHUfPbr+otdl4tmwoDvEJINhgICJxACASAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEENk23MvOQSnK5Pgy9odZiGGAggNg8I3/LcDIrF3uMG9grSSd1zKNVz+MVstaDZiREnwFHI0rUiKwiMUDpjGGSAUVglBCWglNOqWwbmNF8HiWgLcvJhH39mCzGI9n2nos3NceWVDVSh739ZdvY1wQS0jYyuyjfj3ECTuAIytkIMFn75Ar2gUURKjRRxgiZAPXUAm6WohBKYyHuqpSAcPSByvIOoP2IeBNl9GVCnVDGCQ4Q/9a+CjF0tN0lV/ajkn1jFJHbJPDojB5Wp3dyO5elqUEHk+d3zyb0uYGny/SrsH8bzCN7HjklxFVpQ1g3bfkA9RAuvXdORcLgQ0hy+sgLKjtqQ7ecJI/LRm8Kah04KYfdPTbgFYIfLWJbywm3m0BH8iDmxyDlceiWrrUvm1NAn2gHorU7JLAEe+EOvTicB/6ex4awhNnnaC5Jm6i4YZ5f+nmuIqx6KufaYY7m0rQf33+anTu4BRvRolIi08VdJdTQRV7z09ZCHVg0fF6TcX72Vg+QrPFb4J8OtEwonyIP7qaw028jmdLDphZ5xNfmjbviWYYtl8A/PXie7ll0fTV6hjRPEAVUUjuK2zI+536irJfePQFrPpI7OoyiajIuozib9Mkb0fXAG/zCFIV/C5p3IqI+JBZwvwQnXhuoqZcc7bQ+DsT1duwpqxAwT4qt+Cfde1AhLKlxPyx6nfmqJU2esJzY/ytllNTOw9Jh/BOFR4RhxBFKKx5OQSwg/QgDNISW9lXEb9PYH9eZ/OcVmm/3SijFFXYwF3V/vU+gsukX6oceiz3/L1qHRcPzToq494kZ4c9AeaeJqr+qvyX5QX4GC7DVGY6220gIV1mVQxZ9UGcScabZisHjIlr9NlUaZAkvD+yP9M0cQTiPL0F9QHkS9ZX50xiyBIKOS17KmP6uvkX/hpN6THNE5b54pSCe25NlejAn/rWj8CnGkuuqoqoqjSP6g9jGsywExrApsyV31+P6MYfPBv5CBRlV2/WC86bPi9OgPKlBvtoA2ASVHX/iFDjijnyiLBHOjFgHgm2zJBfxrH6m6gZbxUsEnv1QZm8dZ+3BmJZR3qPlv5+mX9wpBnbzCcmNRK+BSidkWzjBvgjN+1VJJba1TBmSMOdsISm9JzNxHnsfJTEQXCMb1VHZuvEZGV5jn8GOySd8yjqnsq8aqRJME0wMTANBglghkgBZQMEAgEFAAQgKozJ1u63f6k1oW1kqQIJZTPJvIjrnRgZjBDXMsa2bAQEFDb/2+4tiu9XeQhYImYtohonZpVwAgInEA==" +} diff --git a/android/client-tls/src/test/kotlin/wang/yaojia/webterm/tls/.gitkeep b/android/client-tls/src/test/kotlin/wang/yaojia/webterm/tls/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fa50ef9 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,10 @@ +# JVM args for the Gradle daemon. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 + +# Opt into stable Gradle features used by the build. +org.gradle.caching=true +org.gradle.parallel=true +org.gradle.configuration-cache=true + +# Kotlin: use the K2 compiler defaults; official code style. +kotlin.code.style=official diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 0000000..ea5dfe1 --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,37 @@ +## Version catalog — single source of truth for plugin & dependency versions. +## Pure JVM modules only (this env has NO Android SDK). See android/README.md. +## Kotlin pinned to 2.3.21 to match the Kotlin embedded in Gradle 9.6.1 (best +## compatibility with the Kotlin Gradle Plugin on Gradle 9.6). + +[versions] +kotlin = "2.3.21" +kotlinxSerialization = "1.9.0" +kotlinxCoroutines = "1.10.2" +junit = "5.13.4" +junitPlatform = "1.13.4" +turbine = "1.2.1" +mockk = "1.14.2" + +[libraries] +# Serialization +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } + +# Coroutines +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } + +# Test — JUnit5 (Jupiter) +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatform" } + +# Test — Flow assertions + mocking +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } + +[bundles] +# Convenience bundle wired into every pure module's testImplementation. +unit-test = ["junit-jupiter", "kotlinx-coroutines-test", "turbine", "mockk"] + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a9db115 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..8508ef6 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/android/host-registry/build.gradle.kts b/android/host-registry/build.gradle.kts new file mode 100644 index 0000000..83a9351 --- /dev/null +++ b/android/host-registry/build.gradle.kts @@ -0,0 +1,33 @@ +// ───────────────────────────────────────────────────────────────────────────── +// :host-registry — Host / HostStore + DataStore-backed storage, LastSessionStore. +// Mirrors the iOS HostRegistry package (storage behind an interface). +// +// NOTE: the plan (§3) treats the storage half as Android-framework-bound (DataStore), +// so it is scaffolded here as an SDK-gated module. Its pure logic could later be +// split into a JVM module if the Kover gate needs it; for now it is COMMENTED OUT +// in settings.gradle.kts (no Android SDK here). +// +// SCAFFOLD STUB ONLY. TODO(android-sdk): enable when an Android SDK is available. +// ───────────────────────────────────────────────────────────────────────────── + +/* +plugins { + id("com.android.library") + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "wang.yaojia.webterm.hostregistry" + compileSdk = 35 + defaultConfig { minSdk = 29 } +} + +kotlin { jvmToolchain(17) } + +dependencies { + implementation(project(":wire-protocol")) + // implementation("androidx.datastore:datastore-preferences:1.1.1") + // implementation("androidx.datastore:datastore:1.1.1") +} +*/ diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/.gitkeep b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/android/session-core/build.gradle.kts b/android/session-core/build.gradle.kts new file mode 100644 index 0000000..05fd26e --- /dev/null +++ b/android/session-core/build.gradle.kts @@ -0,0 +1,28 @@ +// :session-core — pure reducers/state machines (SessionEngine, ReconnectMachine, +// PingScheduler, GateTracker, AwayDigest, UnreadLedger, KeyByteMap, TitleSanitizer, +// SessionEvent). Consumes TermTransport by interface only; runs entirely under +// runTest virtual time. Depends only on :wire-protocol. + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + api(project(":wire-protocol")) + implementation(libs.kotlinx.coroutines.core) + + // Fakes (FakeConnectionPinger, ...) + TestScope/virtual-time, exposed as `api` by + // :test-support, so reducers can be driven under runTest without inventing a clock seam. + testImplementation(project(":test-support")) + testImplementation(libs.bundles.unit.test) + testRuntimeOnly(libs.junit.platform.launcher) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/.gitkeep b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/AwayDigest.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/AwayDigest.kt new file mode 100644 index 0000000..0315e36 --- /dev/null +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/AwayDigest.kt @@ -0,0 +1,74 @@ +package wang.yaojia.webterm.session + +import wang.yaojia.webterm.wire.TimelineEvent + +/** + * The server's `TimelineClass` vocabulary (src/types.ts:429, produced by + * src/session/timeline.ts CLASS_MAP). Kept in lockstep with + * [TimelineEvent.KNOWN_CLASSES] (a sync test asserts equality). Internal — the UI + * sees counts, not class strings. + */ +private object TimelineClassName { + const val TOOL = "tool" + const val WAITING = "waiting" + const val DONE = "done" + const val STUCK = "stuck" +} + +/** + * "What happened while I was away" summary (A6; ports iOS `SessionCore.AwayDigest`, + * plan §3.2 — frozen shape). Reduced ONCE after a reconnect from the session's + * activity timeline (`GET /live-sessions/:id/events`, src/types.ts:434-439) and + * rendered above the terminal (A21/A22). + */ +public data class AwayDigest( + /** Events with class `tool` at/after `since` (PreToolUse + PostToolUse). */ + val toolRuns: Int, + /** Events with class `waiting` (approval requests / notifications). */ + val waitingCount: Int, + /** Any `done` event seen (Stop / SessionEnd). */ + val sawDone: Boolean, + /** Any `stuck` event seen (A5 silent-too-long derivation). */ + val sawStuck: Boolean, + /** The newest events (post-`since`), oldest→newest, truncated to `limit`. */ + val recent: List, +) { + /** True when there is nothing to show (the UI's "don't render" signal). */ + public val isEmpty: Boolean get() = this == EMPTY + + public companion object { + /** The all-zero digest: nothing happened → UI skips rendering entirely. */ + public val EMPTY: AwayDigest = AwayDigest( + toolRuns = 0, + waitingCount = 0, + sawDone = false, + sawStuck = false, + recent = emptyList(), + ) + + /** + * Folds a timeline into a digest. PURE function — no I/O, no clock. + * + * - [events]: `/events` entries (untrusted server input; order not assumed + * — entries are stably re-ordered by `at` before truncation). + * - [sinceMs]: the moment the user left (ms since epoch); strictly-earlier + * events are excluded, an event AT the instant still counts. + * - [limit]: max [recent] entries kept (newest win); `<= 0` keeps none. + */ + public fun reduce(events: List, sinceMs: Long, limit: Int): AwayDigest { + val away = events + .filter { it.at >= sinceMs } + .sortedBy { it.at } + if (away.isEmpty()) return EMPTY + + val keep = if (limit <= 0) 0 else limit + return AwayDigest( + toolRuns = away.count { it.eventClass == TimelineClassName.TOOL }, + waitingCount = away.count { it.eventClass == TimelineClassName.WAITING }, + sawDone = away.any { it.eventClass == TimelineClassName.DONE }, + sawStuck = away.any { it.eventClass == TimelineClassName.STUCK }, + recent = away.takeLast(keep), + ) + } + } +} diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateState.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateState.kt new file mode 100644 index 0000000..bab53df --- /dev/null +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateState.kt @@ -0,0 +1,63 @@ +package wang.yaojia.webterm.session + +import wang.yaojia.webterm.wire.ApproveMode +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.GateKind + +/** + * One held permission gate as the UI should render it (A6; ports iOS + * `SessionCore.GateState`, plan §3.2 — frozen shape). Produced by [GateTracker] + * from `status` frames; a `null` gate means "gate lifted". + * + * [epoch] is the stale-decision guard (mirrors the web client's + * `pendingEpochValue`, public/terminal-session.ts:98-102): it increments only on + * a pending false→true rising edge, and every approve/reject carries the epoch it + * was tapped against — a stale epoch is dropped so a slow tap can never approve + * the NEXT gate (防误批新 gate). + */ +public data class GateState( + /** `plan` → three-way affordances; `tool` → two-way (public/tabs.ts:334-350). */ + val kind: GateKind, + /** Server-supplied context (tool name for tool gates); untrusted display text. */ + val detail: String?, + /** Rising-edge counter; see type doc. */ + val epoch: Int, +) { + /** The action set for this gate: plan → three-way, tool → two-way. */ + public val affordances: List + get() = when (kind) { + GateKind.PLAN -> listOf(Affordance.APPROVE_AUTO, Affordance.APPROVE_REVIEW, Affordance.KEEP_PLANNING) + GateKind.TOOL -> listOf(Affordance.APPROVE, Affordance.REJECT) + } + + /** + * One user-facing gate action. Pure affordance DATA — display strings live in + * the App layer; the wire mapping mirrors public/tabs.ts:334-350. + */ + public enum class Affordance { + /** Plan gate: "Approve + Auto" → `approve(mode = ACCEPT_EDITS)`. */ + APPROVE_AUTO, + /** Plan gate: "Approve + Review" → `approve(mode = DEFAULT)`. */ + APPROVE_REVIEW, + /** Plan gate: "Keep Planning" → `reject` (stays in plan mode). */ + KEEP_PLANNING, + /** Tool gate: "Approve" → `approve(mode = null)`. */ + APPROVE, + /** Tool gate: "Reject" → `reject`. */ + REJECT; + + /** + * The exact wire message the web client sends for this affordance + * (public/tabs.ts:345-347: the plan three-way only ever sends + * acceptEdits / default / reject — never raw `auto`, plan §3.1 note). + */ + public val clientMessage: ClientMessage + get() = when (this) { + APPROVE_AUTO -> ClientMessage.Approve(ApproveMode.ACCEPT_EDITS) + APPROVE_REVIEW -> ClientMessage.Approve(ApproveMode.DEFAULT) + KEEP_PLANNING -> ClientMessage.Reject + APPROVE -> ClientMessage.Approve(null) + REJECT -> ClientMessage.Reject + } + } +} diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateTracker.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateTracker.kt new file mode 100644 index 0000000..277ba59 --- /dev/null +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/GateTracker.kt @@ -0,0 +1,74 @@ +package wang.yaojia.webterm.session + +import wang.yaojia.webterm.wire.GateKind + +/** + * Pure reducer that folds `status` frames' `(pending, gate, detail)` into the + * current [GateState]? (A6; ports iOS `SessionCore.GateTracker`). Mirrors + * public/terminal-session.ts:306-311: + * + * - pending false→true rising edge → epoch +1, new gate held; + * - sustained pending → SAME epoch, kind/detail refresh from the latest frame + * (the server is the source of truth; reattach re-sync stays correct); + * - pending true→false falling edge → gate cleared, epoch counter retained; + * - `gate == null` while pending → treated as a tool gate, because an + * unrecognized gate value decodes as null ([wang.yaojia.webterm.wire.ServerMessage]) + * and the pending signal must never be lost (web: `gate ?? null` → tool buttons). + * + * PURE state machine: [reduce] never mutates the receiver — it returns a new + * immutable snapshot. No I/O, no clock; the caller (SessionEngine, A14) feeds + * frames in and emits a gate change event. + */ +public class GateTracker private constructor( + /** The gate currently held server-side, or `null` when none. */ + public val current: GateState?, + /** + * Highest epoch ever issued; NOT reset on falling edges so decisions against a + * resolved gate can never match a later one. + */ + private val lastEpoch: Int, +) { + /** Feeds one `status` frame's gate-relevant fields; returns the next snapshot. */ + public fun reduce(pending: Boolean, gate: GateKind?, detail: String?): GateTracker { + if (!pending) { + // Falling edge (or still idle): gate lifted, epoch counter retained. + return GateTracker(current = null, lastEpoch = lastEpoch) + } + val kind = gate ?: GateKind.TOOL + val held = current + if (held != null) { + // Sustained pending: same epoch, refresh kind/detail from the frame. + val refreshed = GateState(kind = kind, detail = detail, epoch = held.epoch) + return GateTracker(current = refreshed, lastEpoch = lastEpoch) + } + // Rising edge: mint the next epoch (the ONLY place it increments). + val nextEpoch = lastEpoch + 1 + val risen = GateState(kind = kind, detail = detail, epoch = nextEpoch) + return GateTracker(current = risen, lastEpoch = nextEpoch) + } + + /** + * True iff a decision tapped against [epoch] may be sent NOW: a gate must be + * held and its epoch must match. Stale epoch or no gate → drop the decision + * (防误批新 gate — the two-line stale-guard). + */ + public fun canDecide(epoch: Int): Boolean { + val held = current ?: return false + return held.epoch == epoch + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is GateTracker) return false + return current == other.current && lastEpoch == other.lastEpoch + } + + override fun hashCode(): Int = 31 * (current?.hashCode() ?: 0) + lastEpoch + + override fun toString(): String = "GateTracker(current=$current, lastEpoch=$lastEpoch)" + + public companion object { + /** The starting tracker: no gate held, epoch counter at 0. */ + public val INITIAL: GateTracker = GateTracker(current = null, lastEpoch = 0) + } +} diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/KeyByteMap.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/KeyByteMap.kt new file mode 100644 index 0000000..5bc3f70 --- /dev/null +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/KeyByteMap.kt @@ -0,0 +1,74 @@ +package wang.yaojia.webterm.session + +/** + * Key-name → terminal byte-string table (A6; ports iOS `SessionCore.KeyByteMap`; + * pure data, no I/O). + * + * Byte-for-byte mirror of the web client's `public/keybar.ts` `KEY_MAP` — the + * SINGLE source of truth for every label→bytes lookup: the KeyBar buttons AND the + * hardware-key mapping both resolve through here (plan A17/A25). Hand-writing an + * escape sequence anywhere in the App layer is a review finding. + * + * Control bytes are built from explicit hex code points (`Char(0x1B)` etc.) that + * map 1:1 to the web `\xNN` escapes — never a raw control byte in source. Gotcha + * carried over from the web client: Enter is `\r` (0x0D, carriage return), NOT + * `\n` — synthesized input with a linefeed breaks TUIs (CLAUDE.md gotcha). + */ +public object KeyByteMap { + private const val ESC_CODE = 0x1B + + /** + * One key the bar / hardware mapping can send. [webName] mirrors the web + * `KEY_MAP` property name (iOS's `enum Key: String` raw value); declaration + * order mirrors the web table. + */ + public enum class Key(public val webName: String) { + ESC("esc"), + ESC_ESC("escEsc"), + SHIFT_TAB("shiftTab"), + ARROW_UP("arrowUp"), + ARROW_DOWN("arrowDown"), + ARROW_LEFT("arrowLeft"), + ARROW_RIGHT("arrowRight"), + ENTER("enter"), + CTRL_C("ctrlC"), + CTRL_R("ctrlR"), + CTRL_O("ctrlO"), + CTRL_L("ctrlL"), + CTRL_T("ctrlT"), + CTRL_B("ctrlB"), + CTRL_D("ctrlD"), + TAB("tab"), + SLASH("slash"), + } + + /** + * The exact byte string to send as `ClientMessage.Input(data)` for [key]. + * Values are verbatim from public/keybar.ts KEY_MAP (see `web '\xNN'` notes); + * never edit one side alone. + */ + public fun bytes(key: Key): String = when (key) { + Key.ESC -> esc() // web '\x1b' + Key.ESC_ESC -> esc() + esc() // web '\x1b\x1b' + Key.SHIFT_TAB -> esc() + "[Z" // web '\x1b[Z' + Key.ARROW_UP -> esc() + "[A" // web '\x1b[A' + Key.ARROW_DOWN -> esc() + "[B" // web '\x1b[B' + Key.ARROW_LEFT -> esc() + "[D" // web '\x1b[D' + Key.ARROW_RIGHT -> esc() + "[C" // web '\x1b[C' + Key.ENTER -> "\r" // web '\r' — NOT "\n" (CLAUDE.md gotcha) + Key.CTRL_C -> ctrl(0x03) // web '\x03' + Key.CTRL_R -> ctrl(0x12) // web '\x12' + Key.CTRL_O -> ctrl(0x0F) // web '\x0f' + Key.CTRL_L -> ctrl(0x0C) // web '\x0c' + Key.CTRL_T -> ctrl(0x14) // web '\x14' + Key.CTRL_B -> ctrl(0x02) // web '\x02' + Key.CTRL_D -> ctrl(0x04) // web '\x04' + Key.TAB -> "\t" // web '\t' + Key.SLASH -> "/" // web '/' + } + + private fun esc(): String = ctrl(ESC_CODE) + + /** A single control byte as a 1-char String (matches the web `\xNN` escape). */ + private fun ctrl(code: Int): String = Char(code).toString() +} diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/PingScheduler.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/PingScheduler.kt new file mode 100644 index 0000000..c788140 --- /dev/null +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/PingScheduler.kt @@ -0,0 +1,56 @@ +package wang.yaojia.webterm.session + +import kotlinx.coroutines.delay +import wang.yaojia.webterm.wire.Tunables +import kotlin.time.Duration + +/** + * WS keep-alive pacer (A5, plan §5). The Android analogue of iOS `SessionCore.PingScheduler`. + * + * OkHttp can drive an automatic ping, but the miss POLICY must be deterministic in tests, so + * [run] sends one explicit ping every [interval] (default [Tunables.PING_INTERVAL] = 25 s) using + * a virtual-time [delay]. A half-dead connection otherwise "looks connected" forever. + * + * Miss policy ([Tunables.PONG_MISS_LIMIT] = 2): one missed pong is tolerated; the moment two + * CONSECUTIVE pings go unanswered the connection is declared dead and [run] returns + * [Outcome.CONNECTION_LOST] — the caller (SessionEngine, A14) then feeds [ReconnectMachine.Input.Disconnected] + * into the reconnect machine. Any answered ping resets the consecutive-miss counter. + * + * Zero real timers: [run] suspends on [delay], so under `runTest` a test advances virtual time + * and never waits wall-clock time. + */ +public class PingScheduler( + /** Ping period. Frozen default: [Tunables.PING_INTERVAL] (25 s). */ + public val interval: Duration = Tunables.PING_INTERVAL, +) { + /** How [run] RETURNS. Cancellation is NOT a return value — it propagates as an exception. */ + public enum class Outcome { + /** [Tunables.PONG_MISS_LIMIT] consecutive pings went unanswered — treat as disconnected. */ + CONNECTION_LOST, + } + + /** + * Loops until the connection dies or the coroutine is cancelled: [delay] one [interval], then + * await [sendPing]. + * + * [sendPing] returns true iff the pong arrived — the transport adapter (A7) owns ping/pong + * resolution; false = a miss. Cancellation of the enclosing coroutine surfaces as a + * `CancellationException` from [delay] and is allowed to PROPAGATE (structured concurrency): it + * is a teardown signal owned by the caller (launch{} + cancel), and swallowing it would run the + * rest of the loop in an already-cancelled scope and defeat `withTimeout`. [run] therefore only + * ever *returns* [Outcome.CONNECTION_LOST]; teardown exits via the thrown exception. + */ + public suspend fun run(sendPing: suspend () -> Boolean): Outcome { + var consecutiveMisses = 0 + while (true) { + // Not wrapped in try/catch: a CancellationException from delay() must propagate, not be + // absorbed into a return value (see the KDoc — structured concurrency). + delay(interval) + val isPongReceived = sendPing() + consecutiveMisses = if (isPongReceived) 0 else consecutiveMisses + 1 + if (consecutiveMisses >= Tunables.PONG_MISS_LIMIT) { + return Outcome.CONNECTION_LOST + } + } + } +} diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/ReconnectMachine.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/ReconnectMachine.kt new file mode 100644 index 0000000..71a778e --- /dev/null +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/ReconnectMachine.kt @@ -0,0 +1,111 @@ +package wang.yaojia.webterm.session + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Pure reconnect back-off reducer (A5, plan §5). The Android analogue of iOS + * `SessionCore.ReconnectMachine`, mirroring the web client's behaviour in + * `public/terminal-session.ts`: + * - the first retry delay is 1 s (`reconnectDelay = 1000`), + * - each disconnect schedules the CURRENT delay, then doubles-and-caps it at 30 s + * (`Math.min(delay * 2, 30_000)`), + * - a successful open resets the delay to 1 s. + * + * Ladder: 1s -> 2s -> 4s -> 8s -> 16s -> 30s -> 30s ... + * + * PURE state machine: [reduce] never mutates the receiver — it returns a new immutable + * snapshot plus the single [Effect] the caller (SessionEngine, A14) must perform. There is + * no clock and no timer here; timing lives with the caller, which is what makes this fully + * testable with zero real waits. + * + * DIVERGENCE FROM iOS (deliberate, plan §5 A5 "carries sessionId"): the machine also carries + * the [sessionId] of the session being (re)connected. iOS's `.connected` returned a brand-new + * `.initial`, dropping any id; here `.connected` resets ONLY the delay ladder and PRESERVES the + * [sessionId], so every reconnect re-attaches to the same server session and the ring buffer is + * replayed (invariant #2). The id is set via [withSessionId] when the server confirms the attach. + */ +public data class ReconnectMachine( + /** Delay the NEXT [Input.Disconnected] will schedule (the current ladder rung). */ + public val nextRetryDelay: Duration, + /** The session being reconnected; `null` before the server has issued an id. */ + public val sessionId: String?, +) { + /** What happened to the connection (fed by the caller). */ + public sealed interface Input { + /** The transport came up and the attach handshake succeeded. */ + public data object Connected : Input + + /** The transport dropped (or a connect attempt failed). */ + public data object Disconnected : Input + + /** The scheduled back-off timer elapsed. */ + public data object RetryTimerFired : Input + + /** The app came to the foreground — connect NOW, skip the timer. */ + public data object Foregrounded : Input + + /** The user tapped "retry" — connect NOW, skip the timer. */ + public data object UserRetry : Input + } + + /** The single side effect the caller must perform for an [Input]. */ + public sealed interface Effect { + /** Establish (or re-establish) the connection immediately. */ + public data object ConnectNow : Effect + + /** Arm a timer for [after]; feed [Input.RetryTimerFired] when it elapses. */ + public data class ScheduleRetry(val after: Duration) : Effect + + /** Nothing to do. */ + public data object None : Effect + } + + /** + * Feeds one input and returns `(next snapshot, effect to perform)`. + * + * - [Input.Connected]: back-off resets to the initial 1 s rung; the [sessionId] is kept + * (a successful reconnect is still the same session). No effect. + * - [Input.Disconnected]: schedule a retry after the current delay; the next snapshot + * carries the doubled-and-capped delay. + * - [Input.RetryTimerFired] / [Input.Foregrounded] / [Input.UserRetry]: connect NOW. + * Foreground/user-initiated attempts deliberately do NOT reset the ladder — only a + * successful [Input.Connected] does, so mashing retry can never turn back-off into + * hammering (no-reset-on-foreground). + */ + public fun reduce(input: Input): Pair = + when (input) { + Input.Connected -> + copy(nextRetryDelay = INITIAL_RETRY_DELAY) to Effect.None + + Input.Disconnected -> { + val doubled = nextRetryDelay * BACKOFF_MULTIPLIER + val next = copy(nextRetryDelay = minOf(doubled, MAX_RETRY_DELAY)) + next to Effect.ScheduleRetry(after = nextRetryDelay) + } + + Input.RetryTimerFired, Input.Foregrounded, Input.UserRetry -> + this to Effect.ConnectNow + } + + /** Returns a copy carrying [sessionId] (adopted when the server confirms the attach). */ + public fun withSessionId(sessionId: String?): ReconnectMachine = copy(sessionId = sessionId) + + public companion object { + /** First retry delay after a disconnect (`public/terminal-session.ts` `reconnectDelay`). */ + public val INITIAL_RETRY_DELAY: Duration = 1.seconds + + /** Back-off ceiling (`Math.min(delay * 2, 30_000)`). */ + public val MAX_RETRY_DELAY: Duration = 30.seconds + + /** Doubling factor per failed attempt. */ + public const val BACKOFF_MULTIPLIER: Int = 2 + + /** + * Fresh machine with no session id yet. Seed a known id (reconnecting an existing session) + * with `ReconnectMachine.initial.withSessionId(id)`. + */ + public val initial: ReconnectMachine = + ReconnectMachine(nextRetryDelay = INITIAL_RETRY_DELAY, sessionId = null) + } +} diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent-cockpit.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent-cockpit.kt new file mode 100644 index 0000000..d2d465d --- /dev/null +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent-cockpit.kt @@ -0,0 +1,29 @@ +package wang.yaojia.webterm.session + +import wang.yaojia.webterm.wire.StatusTelemetry + +/** + * A6's half of the [SessionEvent] sealed hierarchy: the COCKPIT subtypes, appended here per the + * split-file ownership A5 reserved in `SessionEvent.kt` (Kotlin allows a sealed hierarchy to be + * extended within the same module+package, and these subtypes carry A6-owned payloads — + * [GateState] / [AwayDigest] — so they live with A6's reducers). Every subtype is TOP-LEVEL so the + * UI reads `is Gate`, `is Digest`, `is Telemetry` uniformly alongside A5's `is Output` etc. + * + * Ports the iOS `SessionEvent.gate` / `.digest` / `.telemetry` cases 1:1. + */ + +/** + * The held permission gate changed; `null` = gate lifted ([GateTracker]). Carries the epoch the UI + * must hand back when deciding (the stale-guard, [GateState.epoch]). + */ +public data class Gate(val gate: GateState?) : SessionEvent + +/** + * "What happened while I was away" — emitted exactly ONCE after each completed reconnect, reduced + * over the events since the disconnect moment ([AwayDigest.reduce]). An [AwayDigest.isEmpty] digest + * is still delivered; the UI suppresses rendering it (all-zero suppressed). + */ +public data class Digest(val digest: AwayDigest) : SessionEvent + +/** Latest statusLine telemetry broadcast (mirrors the server `telemetry` frame, B2). */ +public data class Telemetry(val telemetry: StatusTelemetry) : SessionEvent diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent.kt new file mode 100644 index 0000000..ca01027 --- /dev/null +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent.kt @@ -0,0 +1,71 @@ +package wang.yaojia.webterm.session + +import kotlin.time.Duration + +/** + * Events the SessionEngine (A14) publishes to the UI (FROZEN contract, plan §5). The Android + * analogue of iOS `SessionCore.SessionEvent`. A single event type carries every engine->UI + * signal so the UI consumes one stream with one `when`. + * + * SPLIT-FILE HIERARCHY (ownership, plan §5): this file (A5) freezes the sealed interface plus the + * connection-lifecycle subtypes ([Connection], [Adopted], [Output], [Exited]). The cockpit + * subtypes (`Gate`, `Digest`, `Telemetry`, which carry A6-owned payloads GateState/AwayDigest/ + * StatusTelemetry) are appended by A6 in `SessionEvent-cockpit.kt`. Kotlin only allows a sealed + * hierarchy to be extended within the SAME module+package and a nested subtype cannot be added + * from another file, so ALL subtypes are declared TOP-LEVEL (not nested under the interface) to + * keep A5's and A6's additions uniform — every event reads as `is Output`, `is Gate`, ... + */ +public sealed interface SessionEvent + +/** + * Connection lifecycle changed. `.failed` inside [ConnectionState] is a NON-retryable terminal + * state — the engine will not reconnect. + */ +public data class Connection(val state: ConnectionState) : SessionEvent + +/** + * The server confirmed the attach. ALWAYS adopt the server-issued id — attaching with an unknown + * id yields a brand-new session and a NEW id (`src/session/manager.ts`). The wire carries the id + * as a string ([wang.yaojia.webterm.wire.ServerMessage.Attached]). + */ +public data class Adopted(val sessionId: String) : SessionEvent + +/** Opaque terminal bytes; ring-buffer replay and the live stream share this shape. Feed verbatim. */ +public data class Output(val data: String) : SessionEvent + +/** + * The shell exited (terminal). `code == WireConstants.SPAWN_FAILED_EXIT_CODE` (-1) means the spawn + * never succeeded. Either way the session is over — the engine stops reconnecting. + */ +public data class Exited(val code: Int, val reason: String? = null) : SessionEvent + +/** Connection lifecycle states carried by [Connection]. */ +public sealed interface ConnectionState { + /** `open()` was called; the first connect is being established. */ + public data object Connecting : ConnectionState + + /** Live: transport up, attach handshake done, frames flowing. */ + public data object Connected : ConnectionState + + /** + * The transport dropped; retry number [attempt] fires after [next]. The ladder mirrors the + * web client via [ReconnectMachine] (1s -> ... -> 30s cap). + */ + public data class Reconnecting(val attempt: Int, val next: Duration) : ConnectionState + + /** Ended deliberately: client `close()` (detach — the server-side PTY keeps running) or exit. */ + public data object Closed : ConnectionState + + /** NON-retryable terminal failure — the engine stopped for good; the UI shows actionable copy. */ + public data class Failed(val reason: FailureReason) : ConnectionState +} + +/** Why the engine gave up (non-retryable terminal reasons). */ +public enum class FailureReason { + /** + * The single-frame ring-buffer replay exceeded [wang.yaojia.webterm.wire.Tunables.MAX_WS_MESSAGE_BYTES]: + * reconnecting would deterministically hit the same wall forever, so the engine never enters + * back-off. UI copy: lower the server's SCROLLBACK_BYTES or raise the client cap. + */ + REPLAY_TOO_LARGE, +} diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/TitleSanitizer.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/TitleSanitizer.kt new file mode 100644 index 0000000..1f744f6 --- /dev/null +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/TitleSanitizer.kt @@ -0,0 +1,76 @@ +package wang.yaojia.webterm.session + +import wang.yaojia.webterm.wire.Tunables +import java.text.BreakIterator + +/** + * OSC-title sanitiser (A6; ports iOS `SessionCore.TitleSanitizer`). Terminal + * titles (OSC 0/2, surfaced by the emulator's title callback) are HOST/ATTACKER- + * CONTROLLED input — they must pass through here before any list/registry/UI use + * (plan §7). + * + * Rules, in order: + * 1. Strip ALL Unicode control characters (C0 U+0000-001F + C1 U+0080-009F + DEL + * U+007F). The emulator's OSC parsing should already exclude C0, but that is + * NOT assumed — escape injection gets zero tolerance at this boundary. + * 2. Strip the spoofing vectors OSC parsing does NOT touch: zero-width and + * bidirectional formatting characters — U+200B-200F (zero-width space / + * non-joiner / joiner, LRM/RLM), U+202A-202E (bidi embeddings + the classic + * RLO override), U+2066-2069 (bidi isolates), plus U+FEFF (zero-width no-break + * space / BOM). Note: stripping U+200D (ZWJ) splits multi-person emoji into + * their parts — safety over ligature aesthetics. + * 3. Trim leading/trailing whitespace (mirrors web `title.trim() || null`, + * public/tabs.ts:626 — the caller treats "" as "no title"). + * 4. Truncate to [Tunables.TITLE_MAX_LENGTH] grapheme clusters (an emoji flood + * caps at 256 visible glyphs and no glyph is ever split). + * + * Pure + idempotent: sanitize(sanitize(x)) == sanitize(x). + */ +public object TitleSanitizer { + /** Zero-width / bidi code-point ranges stripped on top of the control set. */ + private val strippedRanges: List = listOf( + 0x200B..0x200F, // zero-width space/non-joiner/joiner, LRM, RLM + 0x202A..0x202E, // bidi embeddings, pops and overrides (incl. RLO) + 0x2066..0x2069, // bidi isolates (LRI/RLI/FSI/PDI) + 0xFEFF..0xFEFF, // zero-width no-break space / BOM + ) + + public fun sanitize(raw: String): String { + val kept = StringBuilder(raw.length) + var i = 0 + while (i < raw.length) { + val cp = Character.codePointAt(raw, i) + if (!isDisallowed(cp)) kept.appendCodePoint(cp) + i += Character.charCount(cp) + } + val trimmed = kept.toString().trim() + return truncateToGraphemes(trimmed, Tunables.TITLE_MAX_LENGTH) + } + + private fun isDisallowed(codePoint: Int): Boolean { + // Character.CONTROL (Cc) covers C0 (U+0000-001F), DEL (U+007F) and C1 + // (U+0080-009F) in one authoritative check — the iOS `.control` analogue. + if (Character.getType(codePoint) == Character.CONTROL.toInt()) return true + return strippedRanges.any { codePoint in it } + } + + /** + * Keep the first [max] grapheme clusters (extended grapheme boundaries, the + * Swift `Character` analogue) so an astral glyph or emoji sequence is never + * split mid-cluster. `max <= 0` yields "". + */ + private fun truncateToGraphemes(text: String, max: Int): String { + if (max <= 0) return "" + if (text.isEmpty()) return text + val boundaries = BreakIterator.getCharacterInstance() + boundaries.setText(text) + var count = 0 + var boundary = boundaries.next() + while (boundary != BreakIterator.DONE) { + count++ + if (count == max) return text.substring(0, boundary) + boundary = boundaries.next() + } + return text // fewer than `max` clusters → whole string + } +} diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/UnreadLedger.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/UnreadLedger.kt new file mode 100644 index 0000000..b6eefad --- /dev/null +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/UnreadLedger.kt @@ -0,0 +1,80 @@ +package wang.yaojia.webterm.session + +/** + * Pure unread-watermark bookkeeping for the session switcher (A6; ports iOS + * `SessionCore.UnreadLedger`). + * + * A session shows an unread dot iff the server's `lastOutputAt` snapshot + * (`GET /live-sessions`, optional field) is STRICTLY newer than the local + * last-seen watermark — mirroring the web tab dot (public/tabs.ts `hasActivity`). + * + * Semantics (documented decisions): + * - **Missing watermark = 0**: a session never seen on this device has unseen + * output by definition (`lastOutputAt >= createdAt > 0` server-side), so it + * lights up until first viewed. + * - **`lastOutputAt` null / non-positive → never unread**: null means a pre-P1 + * server that does not serialize the field; non-positive is malformed untrusted + * input — no data, no dot (plan §4: never trust, never guess). + * - **Monotonic**: recording an EARLIER time never lowers an existing watermark + * (late/out-of-order callbacks cannot resurrect a dot). + * - **Capped**: at most [MAX_ENTRIES] watermarks, oldest-seen dropped first — the + * App persists this map (DataStore) and sessions are ephemeral, so unbounded + * growth is a slow leak. + * + * Persistence-agnostic pure type: the App layer owns loading / saving + * [watermarks]; this type never does I/O. Session ids are the wire's UUID-v4 + * lowercase strings (matching `ServerMessage.Attached.sessionId`). + */ +public class UnreadLedger(watermarks: Map = emptyMap()) { + /** sessionId → last-seen instant (ms since epoch, same clock as `lastOutputAt`). */ + public val watermarks: Map = capped(watermarks) + + /** + * New ledger with [sessionId] marked seen at [atMs] (monotonic max — an earlier + * timestamp never lowers the stored watermark). The receiver is untouched. + */ + public fun record(sessionId: String, atMs: Long): UnreadLedger { + val existing = watermarks[sessionId] + val next = if (existing != null) maxOf(existing, atMs) else atMs + return UnreadLedger(watermarks + (sessionId to next)) + } + + /** + * Unread test: strictly newer server output than the local watermark. null / + * non-positive [lastOutputAt] is never unread (see type doc). + */ + public fun isUnread(sessionId: String, lastOutputAt: Long?): Boolean { + if (lastOutputAt == null || lastOutputAt <= 0L) return false + return lastOutputAt > (watermarks[sessionId] ?: 0L) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is UnreadLedger) return false + return watermarks == other.watermarks + } + + override fun hashCode(): Int = watermarks.hashCode() + + override fun toString(): String = "UnreadLedger(watermarks=$watermarks)" + + public companion object { + /** Upper bound on persisted watermarks (oldest dropped beyond it). */ + public const val MAX_ENTRIES: Int = 512 + + /** + * Keep the [MAX_ENTRIES] NEWEST watermarks (deterministic tie-break by + * sessionId string so equal timestamps cannot flap across runs). + */ + private fun capped(watermarks: Map): Map { + if (watermarks.size <= MAX_ENTRIES) return watermarks + return watermarks.entries + .sortedWith( + compareByDescending> { it.value } + .thenBy { it.key }, + ) + .take(MAX_ENTRIES) + .associate { it.key to it.value } + } + } +} diff --git a/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/.gitkeep b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/AwayDigestTest.kt b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/AwayDigestTest.kt new file mode 100644 index 0000000..01853ac --- /dev/null +++ b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/AwayDigestTest.kt @@ -0,0 +1,96 @@ +package wang.yaojia.webterm.session + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.wire.TimelineEvent + +/** Ports iOS `AwayDigestTests` — once-per-reconnect fold, all-zero suppressed. */ +class AwayDigestTest { + + private fun event(at: Long, cls: String, label: String = cls): TimelineEvent = + TimelineEvent(at = at, eventClass = cls, label = label) + + @Test + fun emptyDigestReportsEmpty() { + assertTrue(AwayDigest.EMPTY.isEmpty) + assertEquals(0, AwayDigest.EMPTY.toolRuns) + assertTrue(AwayDigest.EMPTY.recent.isEmpty()) + } + + @Test + fun noEventsReducesToEmpty() { + assertEquals(AwayDigest.EMPTY, AwayDigest.reduce(emptyList(), sinceMs = 100, limit = 5)) + } + + @Test + fun eventsStrictlyBeforeSinceAreExcluded() { + val events = listOf(event(at = 99, cls = "tool")) + assertEquals(AwayDigest.EMPTY, AwayDigest.reduce(events, sinceMs = 100, limit = 5)) + } + + @Test + fun anEventAtTheSinceInstantStillCounts() { + val events = listOf(event(at = 100, cls = "tool")) + val digest = AwayDigest.reduce(events, sinceMs = 100, limit = 5) + assertEquals(1, digest.toolRuns) + assertFalse(digest.isEmpty) + } + + @Test + fun countsAndFlagsAreFoldedPerClass() { + val events = listOf( + event(at = 100, cls = "tool"), + event(at = 101, cls = "tool"), + event(at = 102, cls = "waiting"), + event(at = 103, cls = "done"), + event(at = 104, cls = "stuck"), + event(at = 105, cls = "user"), + ) + val digest = AwayDigest.reduce(events, sinceMs = 100, limit = 10) + assertEquals(2, digest.toolRuns) + assertEquals(1, digest.waitingCount) + assertTrue(digest.sawDone) + assertTrue(digest.sawStuck) + } + + @Test + fun recentIsSortedOldestToNewestAndTruncatedToNewest() { + // Deliberately out of order on input. + val events = listOf( + event(at = 300, cls = "tool", label = "c"), + event(at = 100, cls = "tool", label = "a"), + event(at = 200, cls = "tool", label = "b"), + ) + val digest = AwayDigest.reduce(events, sinceMs = 0, limit = 2) + // limit=2 keeps the two NEWEST, still oldest→newest ordered. + assertEquals(listOf("b", "c"), digest.recent.map { it.label }) + } + + @Test + fun nonPositiveLimitKeepsNoRecentEntries() { + val events = listOf(event(at = 100, cls = "tool")) + val digest = AwayDigest.reduce(events, sinceMs = 0, limit = 0) + assertTrue(digest.recent.isEmpty()) + assertEquals(1, digest.toolRuns) // counts still fold + } + + @Test + fun onlyUserEventsWithNoRecentSuppressesTheDigest() { + // away events exist but none are tool/waiting/done/stuck AND none kept → + // equals EMPTY → the UI suppresses it (all-zero suppressed). + val events = listOf(event(at = 100, cls = "user"), event(at = 101, cls = "user")) + val digest = AwayDigest.reduce(events, sinceMs = 0, limit = 0) + assertTrue(digest.isEmpty) + assertEquals(AwayDigest.EMPTY, digest) + } + + @Test + fun userOnlyEventsWithRecentAreNotSuppressed() { + val events = listOf(event(at = 100, cls = "user", label = "typed")) + val digest = AwayDigest.reduce(events, sinceMs = 0, limit = 5) + assertFalse(digest.isEmpty) // recent non-empty even though counters are zero + assertEquals(listOf("typed"), digest.recent.map { it.label }) + } +} diff --git a/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/GateTrackerTest.kt b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/GateTrackerTest.kt new file mode 100644 index 0000000..4975021 --- /dev/null +++ b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/GateTrackerTest.kt @@ -0,0 +1,125 @@ +package wang.yaojia.webterm.session + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.wire.ApproveMode +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.GateKind + +/** Ports iOS `GateStateTests` — the two-line epoch stale-guard (security-load-bearing). */ +class GateTrackerTest { + + @Test + fun initialHoldsNoGateAndDecidesNothing() { + val t = GateTracker.INITIAL + assertNull(t.current) + assertFalse(t.canDecide(epoch = 0)) + assertFalse(t.canDecide(epoch = 1)) + } + + @Test + fun risingEdgeMintsEpochOne() { + val t = GateTracker.INITIAL.reduce(pending = true, gate = GateKind.TOOL, detail = "Bash") + val gate = t.current!! + assertEquals(GateKind.TOOL, gate.kind) + assertEquals("Bash", gate.detail) + assertEquals(1, gate.epoch) + assertTrue(t.canDecide(epoch = 1)) + } + + @Test + fun nullGateWhilePendingIsTreatedAsTool() { + val t = GateTracker.INITIAL.reduce(pending = true, gate = null, detail = null) + assertEquals(GateKind.TOOL, t.current!!.kind) + } + + @Test + fun sustainedPendingKeepsEpochAndRefreshesKindAndDetail() { + val first = GateTracker.INITIAL.reduce(pending = true, gate = GateKind.TOOL, detail = "Bash") + val second = first.reduce(pending = true, gate = GateKind.PLAN, detail = "ExitPlanMode") + val gate = second.current!! + assertEquals(1, gate.epoch, "epoch must not advance while a gate stays held") + assertEquals(GateKind.PLAN, gate.kind) + assertEquals("ExitPlanMode", gate.detail) + } + + @Test + fun fallingEdgeClearsGateButRetainsEpochCounter() { + val held = GateTracker.INITIAL.reduce(pending = true, gate = GateKind.TOOL, detail = null) + val lifted = held.reduce(pending = false, gate = null, detail = null) + assertNull(lifted.current) + assertFalse(lifted.canDecide(epoch = 1)) + + // Next rising edge must NOT reuse epoch 1 — the counter is retained. + val next = lifted.reduce(pending = true, gate = GateKind.TOOL, detail = null) + assertEquals(2, next.current!!.epoch) + } + + @Test + fun staleDecisionAgainstAResolvedGateIsDropped() { + // gate #1 held at epoch 1, then resolved, then gate #2 rises at epoch 2. + val gate1 = GateTracker.INITIAL.reduce(pending = true, gate = GateKind.TOOL, detail = null) + val resolved = gate1.reduce(pending = false, gate = null, detail = null) + val gate2 = resolved.reduce(pending = true, gate = GateKind.TOOL, detail = null) + + assertEquals(2, gate2.current!!.epoch) + assertFalse(gate2.canDecide(epoch = 1), "a slow tap for gate #1 must never approve gate #2") + assertTrue(gate2.canDecide(epoch = 2)) + } + + @Test + fun canDecideIsFalseWhenNoGateIsHeld() { + val lifted = GateTracker.INITIAL + .reduce(pending = true, gate = GateKind.TOOL, detail = null) + .reduce(pending = false, gate = null, detail = null) + assertFalse(lifted.canDecide(epoch = 1)) + } + + @Test + fun equalityFoldsCurrentAndEpoch() { + val a = GateTracker.INITIAL.reduce(pending = true, gate = GateKind.TOOL, detail = "x") + val b = GateTracker.INITIAL.reduce(pending = true, gate = GateKind.TOOL, detail = "x") + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun planGateOffersThreeWayAffordances() { + val gate = GateState(kind = GateKind.PLAN, detail = null, epoch = 1) + assertEquals( + listOf( + GateState.Affordance.APPROVE_AUTO, + GateState.Affordance.APPROVE_REVIEW, + GateState.Affordance.KEEP_PLANNING, + ), + gate.affordances, + ) + } + + @Test + fun toolGateOffersTwoWayAffordances() { + val gate = GateState(kind = GateKind.TOOL, detail = null, epoch = 1) + assertEquals( + listOf(GateState.Affordance.APPROVE, GateState.Affordance.REJECT), + gate.affordances, + ) + } + + @Test + fun affordanceClientMessagesMatchTheWebThreeWayContract() { + assertEquals( + ClientMessage.Approve(ApproveMode.ACCEPT_EDITS), + GateState.Affordance.APPROVE_AUTO.clientMessage, + ) + assertEquals( + ClientMessage.Approve(ApproveMode.DEFAULT), + GateState.Affordance.APPROVE_REVIEW.clientMessage, + ) + assertEquals(ClientMessage.Reject, GateState.Affordance.KEEP_PLANNING.clientMessage) + assertEquals(ClientMessage.Approve(null), GateState.Affordance.APPROVE.clientMessage) + assertEquals(ClientMessage.Reject, GateState.Affordance.REJECT.clientMessage) + } +} diff --git a/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/KeyByteMapTest.kt b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/KeyByteMapTest.kt new file mode 100644 index 0000000..7054011 --- /dev/null +++ b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/KeyByteMapTest.kt @@ -0,0 +1,71 @@ +package wang.yaojia.webterm.session + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test + +/** + * Guards the byte-for-byte contract with `public/keybar.ts` `KEY_MAP`. + * + * [WEB_KEY_MAP] is the KEY_MAP from public/keybar.ts, transcribed with the web's + * `\xNN` escapes rebuilt from explicit hex code points (`Char(0x1B)` = ESC = web + * `\x1b`, etc.). If keybar.ts changes a byte and KeyByteMap is not updated in + * lockstep, [bytesMatchKeybarKeyMapExactly] fails. + */ +class KeyByteMapTest { + + private val esc = Char(0x1B).toString() + + /** webName (keybar.ts KEY_MAP property) → byte string, in keybar.ts order. */ + private val WEB_KEY_MAP: Map = linkedMapOf( + "esc" to esc, // '\x1b' + "escEsc" to esc + esc, // '\x1b\x1b' + "shiftTab" to esc + "[Z", // '\x1b[Z' + "arrowUp" to esc + "[A", // '\x1b[A' + "arrowDown" to esc + "[B", //'\x1b[B' + "arrowLeft" to esc + "[D", //'\x1b[D' + "arrowRight" to esc + "[C", //'\x1b[C' + "enter" to "\r", // '\r' + "ctrlC" to Char(0x03).toString(), // '\x03' + "ctrlR" to Char(0x12).toString(), // '\x12' + "ctrlO" to Char(0x0F).toString(), // '\x0f' + "ctrlL" to Char(0x0C).toString(), // '\x0c' + "ctrlT" to Char(0x14).toString(), // '\x14' + "ctrlB" to Char(0x02).toString(), // '\x02' + "ctrlD" to Char(0x04).toString(), // '\x04' + "tab" to "\t", // '\t' + "slash" to "/", // '/' + ) + + @Test + fun bytesMatchKeybarKeyMapExactly() { + for (key in KeyByteMap.Key.entries) { + val expected = WEB_KEY_MAP[key.webName] + ?: error("no keybar.ts KEY_MAP entry for webName='${key.webName}'") + assertEquals(expected, KeyByteMap.bytes(key), "byte mismatch for ${key.name}") + } + } + + @Test + fun everyKeybarKeyIsCoveredAndNothingExtra() { + assertEquals(WEB_KEY_MAP.keys, KeyByteMap.Key.entries.map { it.webName }.toSet()) + assertEquals(WEB_KEY_MAP.size, KeyByteMap.Key.entries.size) + } + + @Test + fun declarationOrderMirrorsTheWebTable() { + assertEquals(WEB_KEY_MAP.keys.toList(), KeyByteMap.Key.entries.map { it.webName }) + } + + @Test + fun enterIsCarriageReturnNotLinefeed() { + assertEquals("\r", KeyByteMap.bytes(KeyByteMap.Key.ENTER)) + assertNotEquals("\n", KeyByteMap.bytes(KeyByteMap.Key.ENTER)) + } + + @Test + fun escIsASingleByte() { + assertEquals(1, KeyByteMap.bytes(KeyByteMap.Key.ESC).length) + assertEquals(0x1B, KeyByteMap.bytes(KeyByteMap.Key.ESC)[0].code) + } +} diff --git a/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/PingSchedulerTest.kt b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/PingSchedulerTest.kt new file mode 100644 index 0000000..cc8140e --- /dev/null +++ b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/PingSchedulerTest.kt @@ -0,0 +1,121 @@ +package wang.yaojia.webterm.session + +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.testsupport.FakeConnectionPinger +import wang.yaojia.webterm.wire.Tunables + +class PingSchedulerTest { + + /** Fire exactly one ping interval on the virtual clock (mirrors the repo's `tick` helper). */ + private fun TestScope.advanceOneInterval() { + testScheduler.advanceTimeBy(Tunables.PING_INTERVAL) + testScheduler.runCurrent() + } + + @Test + fun `sends one ping per interval on virtual time`() = runTest { + val pinger = FakeConnectionPinger() // defaultPong = true -> every ping answers + val scheduler = PingScheduler() + + val job = launch { scheduler.run { pinger.ping() } } + + assertEquals(0, pinger.pingCallCount) // nothing before the first interval elapses + advanceOneInterval() + assertEquals(1, pinger.pingCallCount) + advanceOneInterval() + assertEquals(2, pinger.pingCallCount) + + job.cancel() + } + + @Test + fun `two consecutive missed pongs declare the connection lost`() = runTest { + val pinger = FakeConnectionPinger() + pinger.scriptPongs(false, false) // the first two pings both miss + val scheduler = PingScheduler() + + var outcome: PingScheduler.Outcome? = null + launch { outcome = scheduler.run { pinger.ping() } } + + advanceOneInterval() // miss #1 — tolerated + assertNull(outcome) + + advanceOneInterval() // miss #2 — dead + assertEquals(PingScheduler.Outcome.CONNECTION_LOST, outcome) + } + + @Test + fun `an answered pong resets the consecutive miss counter so isolated misses survive`() = runTest { + val pinger = FakeConnectionPinger() + // miss, pong, miss, pong, miss, pong — never two misses in a row. + pinger.scriptPongs(false, true, false, true, false, true) + val scheduler = PingScheduler() + + var outcome: PingScheduler.Outcome? = null + val job = launch { outcome = scheduler.run { pinger.ping() } } + + repeat(6) { advanceOneInterval() } + + assertNull(outcome) // three isolated misses, each reset — still alive + assertEquals(6, pinger.pingCallCount) + + job.cancel() + } + + @Test + fun `cancelling the run job propagates cancellation instead of returning a value`() = runTest { + val pinger = FakeConnectionPinger() // always pong -> the loop never ends on its own + val scheduler = PingScheduler() + + var returned: PingScheduler.Outcome? = null + var completedNormally = false + val job = launch { + returned = scheduler.run { pinger.ping() } + completedNormally = true // only reached if run() swallowed cancellation and returned + } + + advanceOneInterval() // one ping fires; the loop is now suspended on the next delay + assertEquals(1, pinger.pingCallCount) + + job.cancel() + job.join() + + // The fix: CancellationException propagates out of run() — it does NOT resume the loop and + // return an Outcome. Swallowing (the old bug) would set both of these. + assertTrue(job.isCancelled, "cancellation must propagate out of run()") + assertFalse(completedNormally, "run() must not resume and return after being cancelled") + assertNull(returned, "run() must not swallow cancellation into an Outcome value") + + // No further pings after cancellation, even as virtual time marches on. + testScheduler.advanceTimeBy(Tunables.PING_INTERVAL * 3) + testScheduler.runCurrent() + assertEquals(1, pinger.pingCallCount) + } + + @Test + fun `a custom interval paces the pings`() = runTest { + val pinger = FakeConnectionPinger() + val scheduler = PingScheduler(interval = Tunables.PING_INTERVAL * 2) + + val job = launch { scheduler.run { pinger.ping() } } + + // One default interval is NOT enough for the doubled period. + testScheduler.advanceTimeBy(Tunables.PING_INTERVAL) + testScheduler.runCurrent() + assertEquals(0, pinger.pingCallCount) + + // A second default interval reaches the custom period. + testScheduler.advanceTimeBy(Tunables.PING_INTERVAL) + testScheduler.runCurrent() + assertEquals(1, pinger.pingCallCount) + + job.cancel() + } +} diff --git a/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/ReconnectMachineTest.kt b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/ReconnectMachineTest.kt new file mode 100644 index 0000000..9d90e38 --- /dev/null +++ b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/ReconnectMachineTest.kt @@ -0,0 +1,102 @@ +package wang.yaojia.webterm.session + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.seconds + +class ReconnectMachineTest { + + private fun scheduledDelay(effect: ReconnectMachine.Effect): kotlin.time.Duration { + assertTrue(effect is ReconnectMachine.Effect.ScheduleRetry) + return (effect as ReconnectMachine.Effect.ScheduleRetry).after + } + + @Test + fun `disconnect ladder climbs 1 2 4 8 16 30 and then caps at 30`() { + var machine = ReconnectMachine.initial + val scheduled = buildList { + repeat(7) { + val (next, effect) = machine.reduce(ReconnectMachine.Input.Disconnected) + add(scheduledDelay(effect)) + machine = next + } + } + + assertEquals( + listOf(1, 2, 4, 8, 16, 30, 30).map { it.seconds }, + scheduled, + ) + } + + @Test + fun `a successful connect resets the backoff ladder to the initial rung`() { + var machine = ReconnectMachine.initial + repeat(3) { machine = machine.reduce(ReconnectMachine.Input.Disconnected).first } // climb the ladder + + val (afterConnect, connectEffect) = machine.reduce(ReconnectMachine.Input.Connected) + assertEquals(ReconnectMachine.Effect.None, connectEffect) + assertEquals(ReconnectMachine.INITIAL_RETRY_DELAY, afterConnect.nextRetryDelay) + + // The next disconnect schedules the initial 1 s rung again. + val (_, effect) = afterConnect.reduce(ReconnectMachine.Input.Disconnected) + assertEquals(1.seconds, scheduledDelay(effect)) + } + + @Test + fun `foregrounding connects now WITHOUT resetting the backoff ladder`() { + // Two disconnects: last scheduled 2 s, stored next rung is 4 s. + var machine = ReconnectMachine.initial + machine = machine.reduce(ReconnectMachine.Input.Disconnected).first + machine = machine.reduce(ReconnectMachine.Input.Disconnected).first + + val (afterForeground, effect) = machine.reduce(ReconnectMachine.Input.Foregrounded) + assertEquals(ReconnectMachine.Effect.ConnectNow, effect) + + // Ladder NOT reset: the next disconnect still schedules 4 s (not 1 s). + val (_, disconnectEffect) = afterForeground.reduce(ReconnectMachine.Input.Disconnected) + assertEquals(4.seconds, scheduledDelay(disconnectEffect)) + } + + @Test + fun `retry timer fired and user retry both connect now and preserve the snapshot`() { + val machine = ReconnectMachine.initial.reduce(ReconnectMachine.Input.Disconnected).first + + val (afterTimer, timerEffect) = machine.reduce(ReconnectMachine.Input.RetryTimerFired) + assertEquals(ReconnectMachine.Effect.ConnectNow, timerEffect) + assertEquals(machine, afterTimer) + + val (afterUser, userEffect) = machine.reduce(ReconnectMachine.Input.UserRetry) + assertEquals(ReconnectMachine.Effect.ConnectNow, userEffect) + assertEquals(machine, afterUser) + } + + @Test + fun `the session id is carried through disconnect connect and foreground`() { + val machine = ReconnectMachine.initial.withSessionId("abc-123") + assertEquals("abc-123", machine.sessionId) + + // Survives a disconnect (entering back-off) ... + val afterDisconnect = machine.reduce(ReconnectMachine.Input.Disconnected).first + assertEquals("abc-123", afterDisconnect.sessionId) + + // ... a successful reconnect that resets ONLY the delay ladder ... + val afterConnect = afterDisconnect.reduce(ReconnectMachine.Input.Connected).first + assertEquals("abc-123", afterConnect.sessionId) + assertEquals(ReconnectMachine.INITIAL_RETRY_DELAY, afterConnect.nextRetryDelay) + + // ... and a foreground connect-now. + val afterForeground = afterConnect.reduce(ReconnectMachine.Input.Foregrounded).first + assertEquals("abc-123", afterForeground.sessionId) + } + + @Test + fun `initial seeds the ladder with no id and withSessionId carries one`() { + assertEquals(null, ReconnectMachine.initial.sessionId) + assertEquals(ReconnectMachine.INITIAL_RETRY_DELAY, ReconnectMachine.initial.nextRetryDelay) + + val seeded = ReconnectMachine.initial.withSessionId("sess-1") + assertEquals("sess-1", seeded.sessionId) + assertEquals(ReconnectMachine.INITIAL_RETRY_DELAY, seeded.nextRetryDelay) + } +} diff --git a/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEventTest.kt b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEventTest.kt new file mode 100644 index 0000000..97935c6 --- /dev/null +++ b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEventTest.kt @@ -0,0 +1,60 @@ +package wang.yaojia.webterm.session + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.wire.WireConstants +import kotlin.time.Duration.Companion.seconds + +class SessionEventTest { + + @Test + fun `connection wraps a lifecycle state by value`() { + val event: SessionEvent = Connection(ConnectionState.Connected) + assertEquals(Connection(ConnectionState.Connected), event) + assertNotEquals(Connection(ConnectionState.Connecting), event) + } + + @Test + fun `reconnecting carries the attempt number and next delay`() { + val state = ConnectionState.Reconnecting(attempt = 3, next = 8.seconds) + assertEquals(3, state.attempt) + assertEquals(8.seconds, state.next) + } + + @Test + fun `failed is the non-retryable terminal state carrying its reason`() { + val state = ConnectionState.Failed(FailureReason.REPLAY_TOO_LARGE) + assertEquals(FailureReason.REPLAY_TOO_LARGE, state.reason) + } + + @Test + fun `exited defaults its reason to null and surfaces the spawn-failure code`() { + assertNull(Exited(code = 0).reason) + + val spawnFailure = Exited(code = WireConstants.SPAWN_FAILED_EXIT_CODE, reason = "spawn failed") + assertEquals(-1, spawnFailure.code) + assertEquals("spawn failed", spawnFailure.reason) + } + + @Test + fun `adopted and output carry their payloads by value`() { + assertEquals(Adopted("id-1"), Adopted("id-1")) + assertNotEquals(Adopted("id-1"), Adopted("id-2")) + + assertEquals(Output("bytes"), Output("bytes")) + assertNotEquals(Output("a"), Output("b")) + } + + @Test + fun `every A5 lifecycle subtype is a SessionEvent`() { + val events: List = listOf( + Connection(ConnectionState.Closed), + Adopted("id"), + Output("data"), + Exited(code = 0), + ) + assertEquals(4, events.size) + } +} diff --git a/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/TitleSanitizerTest.kt b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/TitleSanitizerTest.kt new file mode 100644 index 0000000..5b12900 --- /dev/null +++ b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/TitleSanitizerTest.kt @@ -0,0 +1,103 @@ +package wang.yaojia.webterm.session + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.text.BreakIterator +import wang.yaojia.webterm.wire.Tunables + +/** Ports iOS `TitleSanitizerTests` — control/bidi/zero-width stripping + grapheme cap. */ +class TitleSanitizerTest { + + /** Build a String from raw code points (avoids editor-fragile literals). */ + private fun cp(vararg codePoints: Int): String = + buildString { codePoints.forEach { appendCodePoint(it) } } + + private fun graphemeCount(s: String): Int { + val bi = BreakIterator.getCharacterInstance() + bi.setText(s) + var count = 0 + while (bi.next() != BreakIterator.DONE) count++ + return count + } + + @Test + fun plainTitlePassesThroughUnchanged() { + assertEquals("web-terminal", TitleSanitizer.sanitize("web-terminal")) + } + + @Test + fun stripsC0ControlCharacters() { + // ESC (0x1B) + BEL (0x07) embedded in "abc". + val raw = "a" + cp(0x1B) + "b" + cp(0x07) + "c" + assertEquals("abc", TitleSanitizer.sanitize(raw)) + } + + @Test + fun stripsDelAndC1ControlCharacters() { + // DEL (0x7F) and a C1 control (0x9B — CSI). + val raw = "x" + cp(0x7F) + "y" + cp(0x9B) + "z" + assertEquals("xyz", TitleSanitizer.sanitize(raw)) + } + + @Test + fun stripsZeroWidthAndBidiSpoofingCharacters() { + val raw = "a" + + cp(0x200B) + // zero-width space + cp(0x200D) + // zero-width joiner + cp(0x200E) + // LRM + cp(0x202E) + // RLO override + cp(0x2066) + // LRI isolate + cp(0xFEFF) + // BOM + "b" + assertEquals("ab", TitleSanitizer.sanitize(raw)) + } + + @Test + fun trimsSurroundingWhitespace() { + assertEquals("hello", TitleSanitizer.sanitize(" hello \t ")) + } + + @Test + fun emptyAfterSanitizationYieldsEmptyString() { + val raw = cp(0x200B) + " " + cp(0x1B) + assertEquals("", TitleSanitizer.sanitize(raw)) + assertEquals("", TitleSanitizer.sanitize("")) + } + + @Test + fun truncatesToTitleMaxLengthGraphemes() { + val raw = "a".repeat(Tunables.TITLE_MAX_LENGTH + 50) + val out = TitleSanitizer.sanitize(raw) + assertEquals(Tunables.TITLE_MAX_LENGTH, out.length) + } + + @Test + fun emojiFloodCapsAtMaxGlyphsWithoutSplittingASurrogatePair() { + // Each 😀 (U+1F600) is one grapheme = one surrogate pair (2 UTF-16 units). + val emoji = cp(0x1F600) + val raw = emoji.repeat(Tunables.TITLE_MAX_LENGTH + 40) + val out = TitleSanitizer.sanitize(raw) + + assertEquals(Tunables.TITLE_MAX_LENGTH, graphemeCount(out), "capped at 256 visible glyphs") + assertEquals(Tunables.TITLE_MAX_LENGTH * 2, out.length, "no half-emoji: even UTF-16 length") + assertFalse(out.last().isHighSurrogate(), "must not end on a lone high surrogate") + } + + @Test + fun sanitizeIsIdempotent() { + val raw = " " + cp(0x202E) + "danger" + cp(0x200B) + " " + cp(0x1B) + " " + val once = TitleSanitizer.sanitize(raw) + val twice = TitleSanitizer.sanitize(once) + assertEquals(once, twice) + } + + @Test + fun keepsNonAsciiVisibleText() { + // CJK + accented text must survive (only control/bidi/zero-width are stripped). + val raw = "会话 café" + assertEquals("会话 café", TitleSanitizer.sanitize(raw)) + assertTrue(TitleSanitizer.sanitize(raw).isNotEmpty()) + } +} diff --git a/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/UnreadLedgerTest.kt b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/UnreadLedgerTest.kt new file mode 100644 index 0000000..7c1a4b5 --- /dev/null +++ b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/UnreadLedgerTest.kt @@ -0,0 +1,75 @@ +package wang.yaojia.webterm.session + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** Ports iOS `UnreadLedgerTests` — watermark monotonicity, cap, untrusted-input guards. */ +class UnreadLedgerTest { + + private val sid = "11111111-1111-4111-8111-111111111111" + + @Test + fun aNeverSeenSessionIsUnreadWhenServerHasOutput() { + val ledger = UnreadLedger() + assertTrue(ledger.isUnread(sid, lastOutputAt = 1)) + } + + @Test + fun nullLastOutputAtIsNeverUnread() { + assertFalse(UnreadLedger().isUnread(sid, lastOutputAt = null)) + } + + @Test + fun nonPositiveLastOutputAtIsNeverUnread() { + val ledger = UnreadLedger() + assertFalse(ledger.isUnread(sid, lastOutputAt = 0)) + assertFalse(ledger.isUnread(sid, lastOutputAt = -5)) + } + + @Test + fun unreadRequiresStrictlyNewerThanWatermark() { + val ledger = UnreadLedger().record(sid, atMs = 1_000) + assertFalse(ledger.isUnread(sid, lastOutputAt = 999), "older is read") + assertFalse(ledger.isUnread(sid, lastOutputAt = 1_000), "equal is read") + assertTrue(ledger.isUnread(sid, lastOutputAt = 1_001), "newer is unread") + } + + @Test + fun recordIsMonotonicAndDoesNotLowerAnExistingWatermark() { + val ledger = UnreadLedger().record(sid, atMs = 1_000).record(sid, atMs = 500) + assertEquals(1_000L, ledger.watermarks[sid]) + assertFalse(ledger.isUnread(sid, lastOutputAt = 900)) + } + + @Test + fun recordDoesNotMutateTheReceiver() { + val base = UnreadLedger() + val next = base.record(sid, atMs = 1_000) + assertTrue(base.watermarks.isEmpty()) + assertEquals(1_000L, next.watermarks[sid]) + } + + @Test + fun capKeepsTheNewestEntriesByWatermark() { + // One over the cap; the single OLDEST (smallest value) must be dropped. + val raw = buildMap { + for (i in 0..UnreadLedger.MAX_ENTRIES) { // MAX_ENTRIES + 1 entries + put("session-%04d".format(i), i.toLong()) + } + } + val ledger = UnreadLedger(raw) + assertEquals(UnreadLedger.MAX_ENTRIES, ledger.watermarks.size) + assertFalse(ledger.watermarks.containsKey("session-0000"), "oldest (value 0) dropped") + assertTrue(ledger.watermarks.containsKey("session-%04d".format(UnreadLedger.MAX_ENTRIES))) + } + + @Test + fun equalityFoldsWatermarks() { + val a = UnreadLedger().record(sid, atMs = 5) + val b = UnreadLedger(mapOf(sid to 5L)) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..10d1422 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,38 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + // google() is only needed by the Android-framework modules (see below). + // It requires the Android Gradle Plugin / SDK, absent in this env. + } +} + +@Suppress("UnstableApiUsage") +dependencyResolutionManagement { + // Modules must not declare their own repositories. + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + } + // The version catalog at gradle/libs.versions.toml is auto-registered as `libs`. +} + +rootProject.name = "webterm-android" + +// ── Pure Kotlin/JVM modules (JVM-unit-testable; the 80% Kover targets) ────────── +// These build with the JVM toolchain only — NO Android SDK required. +include(":wire-protocol") +include(":session-core") +include(":api-client") +include(":client-tls") +include(":test-support") + +// ── Android-framework modules ─────────────────────────────────────────────────── +// Scaffolded (dirs + build.gradle.kts stubs exist) but NOT included, because this +// environment has NO Android SDK. Their build scripts apply `com.android.*` +// plugins that cannot resolve here. +// TODO(android-sdk): enable when an Android SDK is available. +// include(":app") +// include(":terminal-view") +// include(":host-registry") +// include(":client-tls-android") diff --git a/android/terminal-view/build.gradle.kts b/android/terminal-view/build.gradle.kts new file mode 100644 index 0000000..c976bb2 --- /dev/null +++ b/android/terminal-view/build.gradle.kts @@ -0,0 +1,31 @@ +// ───────────────────────────────────────────────────────────────────────────── +// :terminal-view — Android-framework-bound Termux `terminal-emulator` + +// `terminal-view` wrap (RemoteTerminalSession: no local process, WS-driven). +// Mirrors the iOS SwiftTerm host view. Depends ONLY on :wire-protocol. +// +// SCAFFOLD STUB ONLY — COMMENTED OUT in settings.gradle.kts (no Android SDK here). +// TODO(android-sdk): enable when an Android SDK is available. +// ───────────────────────────────────────────────────────────────────────────── + +/* +plugins { + id("com.android.library") + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "wang.yaojia.webterm.terminalview" + compileSdk = 35 + defaultConfig { minSdk = 29 } +} + +kotlin { jvmToolchain(17) } + +dependencies { + implementation(project(":wire-protocol")) + // Termux VT100/xterm emulator (Apache-2.0) — scope to these two libs ONLY, + // never `termux-shared`/app module (GPLv3). Consumable via JitPack. + // implementation("com.termux:terminal-view:0.118.0") + // implementation("com.termux:terminal-emulator:0.118.0") +} +*/ diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/.gitkeep b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/android/test-support/build.gradle.kts b/android/test-support/build.gradle.kts new file mode 100644 index 0000000..70623bf --- /dev/null +++ b/android/test-support/build.gradle.kts @@ -0,0 +1,28 @@ +// :test-support — shared fakes (FakeTransport / FakeHttpTransport / FakeTimeSource) +// mirroring iOS's TestSupport package. Consumed by other modules' test source sets. +// Pure Kotlin/JVM; depends only on :wire-protocol (+ coroutines for the fakes). + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + api(project(":wire-protocol")) + api(libs.kotlinx.coroutines.core) + // Exposed as `api` on purpose: the fakes live in `main` (Owns), and downstream + // modules consume both the fakes AND `TestScope`/virtual-time from their test + // source sets via a single `testImplementation(project(":test-support"))`. + api(libs.kotlinx.coroutines.test) + + testImplementation(libs.bundles.unit.test) + testRuntimeOnly(libs.junit.platform.launcher) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/android/test-support/src/main/kotlin/wang/yaojia/webterm/testsupport/FakeHttpTransport.kt b/android/test-support/src/main/kotlin/wang/yaojia/webterm/testsupport/FakeHttpTransport.kt new file mode 100644 index 0000000..b3fb037 --- /dev/null +++ b/android/test-support/src/main/kotlin/wang/yaojia/webterm/testsupport/FakeHttpTransport.kt @@ -0,0 +1,114 @@ +package wang.yaojia.webterm.testsupport + +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest +import wang.yaojia.webterm.wire.HttpResponse +import wang.yaojia.webterm.wire.HttpTransport + +/** + * Errors [FakeHttpTransport] raises on its own. Android analogue of iOS `FakeHTTPTransportError`; + * only the "you forgot to script this route" case survives the port — the Swift `requestMissingURL` + * / `responseConstructionFailed` cases are unreachable here because [HttpRequest.url] is a non-null + * `String` and [HttpResponse] is a plain data class (no `HTTPURLResponse` construction to fail). + */ +public sealed class FakeHttpTransportError(message: String) : Exception(message) { + /** + * [FakeHttpTransport.send] was called for a route with no queued response — the test forgot to + * script it. Loud and identifying (data class → exact-value assertable), never a silent hang. + */ + public data class NoQueuedResponse(val method: HttpMethod, val url: String) : + FakeHttpTransportError("FakeHttpTransport: no queued response for ${method.name} $url") +} + +/** + * In-memory [HttpTransport] double (A3, mirrors iOS `FakeHTTPTransport`). `:api-client` (A8/A9) + * cannot tell it apart from `:transport-okhttp`'s real impl (plan §3.4). + * + * - **Queue responses per route (method + exact url), FIFO**: [queueSuccess] / [queueFailure]. An + * unqueued route throws [FakeHttpTransportError.NoQueuedResponse] immediately. + * - **Record every request verbatim, headers included** — so Origin-iff-guarded tests (plan §4.3 + * 铁律) can assert exactly which requests carried the `Origin` header, and query-string tests + * (`staged=1`) can assert the exact url. + * + * Thread-safe: one JVM monitor guards the queues and the recording; every section is synchronous. + */ +public class FakeHttpTransport : HttpTransport { + private data class RouteKey(val method: HttpMethod, val url: String) + + private sealed interface QueuedResult { + data class Success( + val status: Int, + val headers: Map, + val body: ByteArray, + ) : QueuedResult + + data class Failure(val error: Throwable) : QueuedResult + } + + private val lock = Any() + private val queues = mutableMapOf>() + private val recorded = mutableListOf() + + /** + * Every request passed to [send], in order, verbatim (method, url, headers, body) — including + * ones that found no queued response and threw. + */ + public val recordedRequests: List + get() = synchronized(lock) { recorded.toList() } + + // ── Scripting ──────────────────────────────────────────────────────────────────────── + + /** Queue one successful response for `method url` (FIFO per route). */ + public fun queueSuccess( + method: HttpMethod = HttpMethod.GET, + url: String, + status: Int = DEFAULT_OK_STATUS, + headers: Map = emptyMap(), + body: ByteArray = EMPTY_BODY, + ) { + enqueue(RouteKey(method, url), QueuedResult.Success(status, headers, body)) + } + + /** + * Queue one transport-level failure for `method url` (e.g. connection-refused → + * `PairingError.hostUnreachable` classification tests, A9). The error is re-thrown by [send]. + */ + public fun queueFailure( + method: HttpMethod = HttpMethod.GET, + url: String, + error: Throwable, + ) { + enqueue(RouteKey(method, url), QueuedResult.Failure(error)) + } + + // ── HttpTransport ──────────────────────────────────────────────────────────────────── + + override suspend fun send(request: HttpRequest): HttpResponse { + val next: QueuedResult = + synchronized(lock) { + recorded.add(request) + val key = RouteKey(request.method, request.url) + val queue = queues[key] + if (queue.isNullOrEmpty()) { + throw FakeHttpTransportError.NoQueuedResponse(request.method, request.url) + } + queue.removeFirst() + } + return when (next) { + is QueuedResult.Failure -> throw next.error + is QueuedResult.Success -> HttpResponse(next.status, next.body, next.headers) + } + } + + // ── Internals ────────────────────────────────────────────────────────────────────── + + private fun enqueue(key: RouteKey, result: QueuedResult) { + synchronized(lock) { queues.getOrPut(key) { ArrayDeque() }.addLast(result) } + } + + public companion object { + /** Default scripted success status (matches iOS `defaultOKStatus`). */ + public const val DEFAULT_OK_STATUS: Int = 200 + private val EMPTY_BODY: ByteArray = ByteArray(0) + } +} diff --git a/android/test-support/src/main/kotlin/wang/yaojia/webterm/testsupport/FakePingableTermTransport.kt b/android/test-support/src/main/kotlin/wang/yaojia/webterm/testsupport/FakePingableTermTransport.kt new file mode 100644 index 0000000..5637218 --- /dev/null +++ b/android/test-support/src/main/kotlin/wang/yaojia/webterm/testsupport/FakePingableTermTransport.kt @@ -0,0 +1,62 @@ +package wang.yaojia.webterm.testsupport + +import wang.yaojia.webterm.wire.ConnectionPinger +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.PingableConnection +import wang.yaojia.webterm.wire.PingableTermTransport + +/** + * A controllable [ConnectionPinger] double. `PingScheduler` (A5) calls [ping] once per interval and + * counts misses against `Tunables.PONG_MISS_LIMIT`; this fake lets a test decide, deterministically, + * which pings answer and which are dropped. + * + * Scripted results are consumed FIFO; once exhausted [ping] returns [defaultPong]. `true` = pong + * arrived in time, `false` = a miss. + */ +public class FakeConnectionPinger( + private val defaultPong: Boolean = true, +) : ConnectionPinger { + private val lock = Any() + private val scriptedPongs = ArrayDeque() + private var callCount = 0 + + /** Total [ping] calls so far — for `PingScheduler` assertions. */ + public val pingCallCount: Int + get() = synchronized(lock) { callCount } + + /** Queue pong outcomes consumed FIFO by [ping]; when the queue drains, [ping] uses [defaultPong]. */ + public fun scriptPongs(vararg results: Boolean) { + synchronized(lock) { scriptedPongs.addAll(results.toList()) } + } + + override suspend fun ping(): Boolean = + synchronized(lock) { + callCount++ + scriptedPongs.removeFirstOrNull() ?: defaultPong + } +} + +/** + * A [FakeTermTransport] that ALSO implements [PingableTermTransport] (A3, mirrors iOS's + * `transport as? any PingableTermTransport` path). Use this when a test wants `SessionEngine`/ + * `PingScheduler` to drive keep-alive through [connectPingable]; use the plain [FakeTermTransport] + * when the closure-driven ping path is under test instead. + * + * The [pinger] is shared across every connection this transport opens, so a test can script pong + * outcomes up front and inspect [FakeConnectionPinger.pingCallCount] after — reconnects reuse it, + * which matches "one keep-alive policy per transport" for assertion simplicity. + */ +public class FakePingableTermTransport( + public val pinger: FakeConnectionPinger = FakeConnectionPinger(), +) : FakeTermTransport(), PingableTermTransport { + + /** How many times [connectPingable] (vs the plain [connect]) was taken — for path assertions. */ + public var connectPingableCount: Int = 0 + private set + + override suspend fun connectPingable(endpoint: HostEndpoint): PingableConnection { + val connection = connect(endpoint) + synchronized(this) { connectPingableCount++ } + return PingableConnection(connection = connection, pinger = pinger) + } +} diff --git a/android/test-support/src/main/kotlin/wang/yaojia/webterm/testsupport/FakeTermTransport.kt b/android/test-support/src/main/kotlin/wang/yaojia/webterm/testsupport/FakeTermTransport.kt new file mode 100644 index 0000000..0abc3ad --- /dev/null +++ b/android/test-support/src/main/kotlin/wang/yaojia/webterm/testsupport/FakeTermTransport.kt @@ -0,0 +1,186 @@ +package wang.yaojia.webterm.testsupport + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.consumeAsFlow +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.TermTransport +import wang.yaojia.webterm.wire.TransportConnection + +/** + * Errors [FakeTermTransport] raises on its own (sealed so tests can match exact values, e.g. + * `assertFailsWith`). Android analogue of iOS + * `FakeTransportError` (there an `Equatable` enum). + */ +public sealed class FakeTransportError(message: String) : Exception(message) { + /** Default error thrown by a scripted connect failure. */ + public data object ScriptedConnectFailure : + FakeTransportError("FakeTermTransport: scripted connect failure") + + /** + * `send` was called on a connection that already terminated (client `close()`, server finish, + * or server error) — mirrors iOS `FakeTransportError.sendAfterClose`. + */ + public data object SendAfterClose : + FakeTransportError("FakeTermTransport: send on a terminated connection") +} + +/** + * In-memory [TermTransport] double (A3, mirrors iOS `FakeTransport`). `SessionEngine` (A14) cannot + * tell it apart from `:transport-okhttp`'s real impl — same frozen contract (plan §3.2). + * + * Capabilities: + * - **Scripted connect failures**: [scriptConnectFailure] queues errors thrown by subsequent + * [connect] calls, FIFO (reconnect/backoff tests). + * - **Manual frame injection** (the server side of the wire): [emit] / [emitError] / + * [finishFrames] drive the latest connection's [TransportConnection.frames] stream. With no live + * connection the event is queued and flushed, in submission order, into the next successful + * [connect] — so a test can script "attach then replay" up front and nothing is silently dropped. + * - **Recording**: every [connect] attempt (scripted failures included), every sent frame (per + * connection and flattened), and every [TransportConnection.close] call. + * + * Thread-safe by construction: all mutable state is guarded by one JVM monitor and every critical + * section is synchronous (no suspension inside `synchronized`), so it is safe under `runTest` + * virtual time and under real multi-thread dispatch alike. Frames buffer unboundedly + * ([Channel.UNLIMITED]) until the consumer collects — zero real-time sleeps. + * + * `open` so the pingable variant ([FakePingableTermTransport]) can reuse [connect]. NOTE: this base + * intentionally does NOT implement `PingableTermTransport` — matching the frozen contract note in + * `TermTransport.kt`, so engine tests can exercise the closure-driven ping path. Use + * [FakePingableTermTransport] when a test wants the `connectPingable` path instead. + */ +public open class FakeTermTransport : TermTransport { + private sealed interface ServerEvent { + data class Frame(val frame: String) : ServerEvent + data class Failure(val error: Throwable) : ServerEvent + data object Finish : ServerEvent + } + + private class ConnectionState { + val channel: Channel = Channel(Channel.UNLIMITED) + // consumeAsFlow() is single-collection by contract, matching "one live WS, consumed once". + val framesFlow: Flow = channel.consumeAsFlow() + val sentFrames: MutableList = mutableListOf() + var isTerminated: Boolean = false + } + + private val lock = Any() + private val scriptedConnectFailures = ArrayDeque() + private val pendingEvents = ArrayDeque() + private val connections = mutableListOf() + private val connectAttemptsInternal = mutableListOf() + private var closeCount = 0 + + /** Every endpoint [connect] was called with, in order — scripted failures included. */ + public val connectAttempts: List + get() = synchronized(lock) { connectAttemptsInternal.toList() } + + /** Total [TransportConnection.close] calls across all connections (double-close included). */ + public val closeCallCount: Int + get() = synchronized(lock) { closeCount } + + /** All frames sent by the client, flattened in connection order. */ + public val sentFrames: List + get() = synchronized(lock) { connections.flatMap { it.sentFrames.toList() } } + + /** + * Frames sent by the client, grouped per successful connection (reconnect tests assert the + * re-attach frame landed on connection index 1, not 0). + */ + public val sentFramesByConnection: List> + get() = synchronized(lock) { connections.map { it.sentFrames.toList() } } + + // ── Scripting ──────────────────────────────────────────────────────────────────────── + + /** Queue an error for the next [connect] call (FIFO across calls). */ + public fun scriptConnectFailure( + error: Throwable = FakeTransportError.ScriptedConnectFailure, + ) { + synchronized(lock) { scriptedConnectFailures.addLast(error) } + } + + // ── TermTransport ──────────────────────────────────────────────────────────────────── + + override suspend fun connect(endpoint: HostEndpoint): TransportConnection { + val index: Int + val state: ConnectionState + synchronized(lock) { + connectAttemptsInternal.add(endpoint) + scriptedConnectFailures.removeFirstOrNull()?.let { throw it } + state = ConnectionState() + index = connections.size + connections.add(state) + // Flush anything scripted before this connection existed, in submission order. + while (pendingEvents.isNotEmpty()) { + apply(pendingEvents.removeFirst(), state) + } + } + return object : TransportConnection { + override val frames: Flow = state.framesFlow + override suspend fun send(frame: String): Unit = recordSend(frame, index) + override suspend fun close(): Unit = recordClose(index) + } + } + + // ── Manual injection (server side of the wire) ───────────────────────────────────────── + + /** Yield one server JSON text frame into the latest connection (or queue for the next connect). */ + public fun emit(frame: String) { + deliver(ServerEvent.Frame(frame)) + } + + /** Terminate the latest connection's stream with [error] (the "flow throws" half of the contract). */ + public fun emitError(error: Throwable) { + deliver(ServerEvent.Failure(error)) + } + + /** Finish the latest connection's stream cleanly (the "flow completes normally" half). */ + public fun finishFrames() { + deliver(ServerEvent.Finish) + } + + // ── Internals (all callers hold, or take, the monitor) ───────────────────────────────── + + private fun deliver(event: ServerEvent) { + synchronized(lock) { + val live = connections.lastOrNull()?.takeUnless { it.isTerminated } + if (live == null) { + pendingEvents.addLast(event) + } else { + apply(event, live) + } + } + } + + private fun apply(event: ServerEvent, state: ConnectionState) { + when (event) { + is ServerEvent.Frame -> state.channel.trySend(event.frame) + is ServerEvent.Failure -> { + state.isTerminated = true + state.channel.close(event.error) + } + ServerEvent.Finish -> { + state.isTerminated = true + state.channel.close() + } + } + } + + private fun recordSend(frame: String, index: Int) { + synchronized(lock) { + val state = connections[index] + if (state.isTerminated) throw FakeTransportError.SendAfterClose + state.sentFrames.add(frame) + } + } + + private fun recordClose(index: Int) { + synchronized(lock) { + closeCount++ + val state = connections[index] + if (state.isTerminated) return + state.isTerminated = true + state.channel.close() + } + } +} diff --git a/android/test-support/src/main/kotlin/wang/yaojia/webterm/testsupport/FakeTimeSource.kt b/android/test-support/src/main/kotlin/wang/yaojia/webterm/testsupport/FakeTimeSource.kt new file mode 100644 index 0000000..b2f85e0 --- /dev/null +++ b/android/test-support/src/main/kotlin/wang/yaojia/webterm/testsupport/FakeTimeSource.kt @@ -0,0 +1,74 @@ +package wang.yaojia.webterm.testsupport + +import kotlinx.coroutines.test.TestScope +import kotlin.time.ComparableTimeMark +import kotlin.time.Duration +import kotlin.time.ExperimentalTime +import kotlin.time.TestTimeSource +import kotlin.time.TimeSource + +/** + * Manually-advanced [TimeSource] (A3, the Android analogue of iOS `FakeClock`). Drives reducers + * that read elapsed time — `PingScheduler`, `AwayDigest`'s fade, telemetry staleness — with ZERO + * real-time waiting: code under test reads a mark and the test moves time with [advance]. + * + * Two readable shapes so it fits both reducer styles without inventing A5/A6's clock contract: + * - as a [TimeSource]: inject it and take [TimeSource.markNow]/[ComparableTimeMark.elapsedNow] for + * duration-based logic (ping interval, digest fade); + * - [epochMillis]: an epoch-ms reader for the `at`-timestamp staleness reducers + * (`StatusTelemetry.at` vs `Tunables.TELEMETRY_STALE_TTL_MS`). + * + * Thin wrapper over stdlib [TestTimeSource] (which owns the mark machinery); this class only tracks + * the elapsed offset in parallel so [epochMillis]/[now] can be read. Not tied to the coroutine + * scheduler — see [tick] to advance virtual `delay()` and this clock together in one call. + */ +@OptIn(ExperimentalTime::class) +public class FakeTimeSource( + /** Wall-clock epoch (ms) reported at zero elapsed; [epochMillis] = this + elapsed. */ + public val epochBaseMillis: Long = DEFAULT_EPOCH_BASE_MILLIS, +) : TimeSource.WithComparableMarks { + private val backing = TestTimeSource() + private var elapsed: Duration = Duration.ZERO + + override fun markNow(): ComparableTimeMark = backing.markNow() + + /** Total time advanced since construction. */ + public val now: Duration + get() = elapsed + + /** Current wall-clock reading in epoch milliseconds ([epochBaseMillis] + [now]). */ + public fun epochMillis(): Long = epochBaseMillis + elapsed.inWholeMilliseconds + + /** Move time forward (never backward); wakes any mark's [ComparableTimeMark.elapsedNow]. */ + public fun advance(by: Duration) { + require(by >= Duration.ZERO) { "FakeTimeSource cannot move backwards (got $by)" } + backing += by + elapsed += by + } + + /** `clock += 25.seconds` sugar over [advance]. */ + public operator fun plusAssign(by: Duration) { + advance(by) + } + + public companion object { + public const val DEFAULT_EPOCH_BASE_MILLIS: Long = 0L + } +} + +/** + * Advance BOTH the coroutine virtual clock and a [FakeTimeSource] by [duration] in one call, keeping + * `delay()`-driven scheduling and elapsed-time reads in lockstep. Use inside `runTest` when a + * reducer both suspends on `delay()` AND reads the injected clock: + * + * runTest { + * val clock = FakeTimeSource() + * // ... start the reducer ... + * tick(clock, Tunables.PING_INTERVAL) // fires the delay AND moves the readable clock + * } + */ +public fun TestScope.tick(clock: FakeTimeSource, duration: Duration) { + testScheduler.advanceTimeBy(duration) + testScheduler.runCurrent() + clock.advance(duration) +} diff --git a/android/test-support/src/test/kotlin/wang/yaojia/webterm/testsupport/FakeHttpTransportTest.kt b/android/test-support/src/test/kotlin/wang/yaojia/webterm/testsupport/FakeHttpTransportTest.kt new file mode 100644 index 0000000..8ecb577 --- /dev/null +++ b/android/test-support/src/test/kotlin/wang/yaojia/webterm/testsupport/FakeHttpTransportTest.kt @@ -0,0 +1,75 @@ +package wang.yaojia.webterm.testsupport + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest +import java.io.IOException + +/** Ports iOS `FakeHTTPTransportTests`, plus FIFO/method-scope and failure re-throw. */ +class FakeHttpTransportTest { + private companion object { + const val ORIGIN = "http://192.168.1.5:3000" + const val LIST_URL = "http://192.168.1.5:3000/live-sessions" + const val UNQUEUED_URL = "http://192.168.1.5:3000/other" + } + + @Test + fun replaysQueuedResponsesRecordsRequestsAndFailsLoudlyWhenUnqueued() = runTest { + // Arrange + val transport = FakeHttpTransport() + transport.queueSuccess(url = LIST_URL, body = "[]".toByteArray()) + + val request = HttpRequest( + method = HttpMethod.GET, + url = LIST_URL, + headers = mapOf("Origin" to ORIGIN), + ) + + // Act + val response = transport.send(request) + + // Assert: the queued response came back for that route. + assertEquals("[]", String(response.body)) + assertEquals(FakeHttpTransport.DEFAULT_OK_STATUS, response.status) + + // Assert: the request was recorded verbatim, headers included (Origin-iff-guarded tests). + val recorded = transport.recordedRequests + assertEquals(1, recorded.size) + assertEquals(LIST_URL, recorded[0].url) + assertEquals(ORIGIN, recorded[0].headers["Origin"]) + + // Assert: an unqueued route throws an explicit, identifying error — and is still recorded. + val thrown = runCatching { + transport.send(HttpRequest(HttpMethod.GET, UNQUEUED_URL)) + }.exceptionOrNull() + assertEquals(FakeHttpTransportError.NoQueuedResponse(HttpMethod.GET, UNQUEUED_URL), thrown) + assertEquals(2, transport.recordedRequests.size) + } + + @Test + fun queuesAreFifoPerRouteAndMethodScoped() = runTest { + val transport = FakeHttpTransport() + val url = "http://h/live-sessions/abc" + transport.queueSuccess(method = HttpMethod.GET, url = url, body = "first".toByteArray()) + transport.queueSuccess(method = HttpMethod.GET, url = url, body = "second".toByteArray()) + transport.queueSuccess(method = HttpMethod.DELETE, url = url, status = 204) + + assertEquals("first", String(transport.send(HttpRequest(HttpMethod.GET, url)).body)) + assertEquals("second", String(transport.send(HttpRequest(HttpMethod.GET, url)).body)) + assertEquals(204, transport.send(HttpRequest(HttpMethod.DELETE, url)).status) + } + + @Test + fun queuedFailureIsRethrown() = runTest { + val transport = FakeHttpTransport() + val url = "http://h/x" + transport.queueFailure(url = url, error = IOException("connection refused")) + + val thrown = runCatching { transport.send(HttpRequest(HttpMethod.GET, url)) }.exceptionOrNull() + assertTrue(thrown is IOException) + assertEquals("connection refused", thrown?.message) + } +} diff --git a/android/test-support/src/test/kotlin/wang/yaojia/webterm/testsupport/FakePingableTermTransportTest.kt b/android/test-support/src/test/kotlin/wang/yaojia/webterm/testsupport/FakePingableTermTransportTest.kt new file mode 100644 index 0000000..aeee83b --- /dev/null +++ b/android/test-support/src/test/kotlin/wang/yaojia/webterm/testsupport/FakePingableTermTransportTest.kt @@ -0,0 +1,42 @@ +package wang.yaojia.webterm.testsupport + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.PingableTermTransport + +class FakePingableTermTransportTest { + private fun endpoint(): HostEndpoint = + requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000")) + + @Test + fun connectPingableReturnsSharedScriptablePinger() = runTest { + val transport = FakePingableTermTransport() + transport.pinger.scriptPongs(true, false) + + val pingable = transport.connectPingable(endpoint()) + + assertEquals(true, pingable.pinger.ping()) + assertEquals(false, pingable.pinger.ping()) + assertEquals(true, pingable.pinger.ping()) // default once the scripted queue drains + assertEquals(3, transport.pinger.pingCallCount) + assertEquals(1, transport.connectPingableCount) + assertEquals(listOf(endpoint()), transport.connectAttempts) // base recording still works + } + + @Test + fun isRecognizedAsPingableTransport() { + val transport: Any = FakePingableTermTransport() + assertTrue(transport is PingableTermTransport) + } + + @Test + fun pingerUsesConfiguredDefaultWhenUnscripted() = runTest { + val pinger = FakeConnectionPinger(defaultPong = false) + assertEquals(false, pinger.ping()) + assertEquals(false, pinger.ping()) + assertEquals(2, pinger.pingCallCount) + } +} diff --git a/android/test-support/src/test/kotlin/wang/yaojia/webterm/testsupport/FakeTermTransportTest.kt b/android/test-support/src/test/kotlin/wang/yaojia/webterm/testsupport/FakeTermTransportTest.kt new file mode 100644 index 0000000..1f7dee1 --- /dev/null +++ b/android/test-support/src/test/kotlin/wang/yaojia/webterm/testsupport/FakeTermTransportTest.kt @@ -0,0 +1,98 @@ +package wang.yaojia.webterm.testsupport + +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.wire.HostEndpoint + +/** Ports iOS `FakeTransportTests`, plus the pending-flush / reconnect / termination behaviors. */ +class FakeTermTransportTest { + private fun endpoint(): HostEndpoint = + requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000")) + + @Test + fun scriptsFailuresDeliversFramesAndRecordsTraffic() = runTest { + // Arrange + val transport = FakeTermTransport() + val endpoint = endpoint() + val attachFrame = """{"type":"attach","sessionId":null}""" + + // Act + Assert: a scripted failure makes the next connect throw it. + transport.scriptConnectFailure() + val failure = runCatching { transport.connect(endpoint) }.exceptionOrNull() + assertEquals(FakeTransportError.ScriptedConnectFailure, failure) + + // Act: connect for real, send one frame, inject one frame, close cleanly. + val connection = transport.connect(endpoint) + connection.send(attachFrame) + transport.emit("server-frame-1") + transport.finishFrames() + + val received = connection.frames.toList() + connection.close() + + // Assert: injected frames arrived in order and ended with a clean finish. + assertEquals(listOf("server-frame-1"), received) + // Assert: the double recorded everything the client did. + assertEquals(listOf(attachFrame), transport.sentFrames) + assertEquals(1, transport.closeCallCount) + assertEquals(listOf(endpoint, endpoint), transport.connectAttempts) + } + + @Test + fun emitErrorTerminatesStreamWithThatError() = runTest { + val transport = FakeTermTransport() + val connection = transport.connect(endpoint()) + val boom = IllegalStateException("boom") + + transport.emit("f1") + transport.emitError(boom) + + // The buffered frame is delivered, then the stream throws the transport error. + val thrown = runCatching { connection.frames.toList() }.exceptionOrNull() + assertTrue(thrown is IllegalStateException) + assertEquals("boom", thrown?.message) + } + + @Test + fun sendAfterCloseThrows() = runTest { + val transport = FakeTermTransport() + val connection = transport.connect(endpoint()) + connection.close() + + val thrown = runCatching { connection.send("late") }.exceptionOrNull() + assertEquals(FakeTransportError.SendAfterClose, thrown) + assertEquals(1, transport.closeCallCount) + } + + @Test + fun queuesEventsBeforeConnectAndFlushesInOrderOnNextConnect() = runTest { + val transport = FakeTermTransport() + // Script server behavior up front, before any connection exists (attach->replay pattern). + transport.emit("replay-1") + transport.emit("replay-2") + transport.finishFrames() + + val connection = transport.connect(endpoint()) + assertEquals(listOf("replay-1", "replay-2"), connection.frames.toList()) + } + + @Test + fun reattachFrameLandsOnSecondConnection() = runTest { + val transport = FakeTermTransport() + val endpoint = endpoint() + + val c0 = transport.connect(endpoint) + c0.send("attach-0") + transport.finishFrames() // connection 0 terminated (server close) + + val c1 = transport.connect(endpoint) + c1.send("attach-1") + + assertEquals(listOf(listOf("attach-0"), listOf("attach-1")), transport.sentFramesByConnection) + assertEquals(listOf("attach-0", "attach-1"), transport.sentFrames) + assertEquals(listOf(endpoint, endpoint), transport.connectAttempts) + } +} diff --git a/android/test-support/src/test/kotlin/wang/yaojia/webterm/testsupport/FakeTimeSourceTest.kt b/android/test-support/src/test/kotlin/wang/yaojia/webterm/testsupport/FakeTimeSourceTest.kt new file mode 100644 index 0000000..2140d6b --- /dev/null +++ b/android/test-support/src/test/kotlin/wang/yaojia/webterm/testsupport/FakeTimeSourceTest.kt @@ -0,0 +1,60 @@ +package wang.yaojia.webterm.testsupport + +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** Ports iOS `FakeClockTests` — manual advance, zero real-time waiting — plus the epoch/tick shapes. */ +class FakeTimeSourceTest { + @Test + fun markElapsesOnlyAfterManualAdvance() { + val clock = FakeTimeSource() + val mark = clock.markNow() + assertEquals(Duration.ZERO, mark.elapsedNow()) + + clock.advance(10.seconds) // short of a 25s deadline + assertEquals(10.seconds, mark.elapsedNow()) + + clock += 15.seconds // reach it + assertEquals(25.seconds, mark.elapsedNow()) + assertEquals(25.seconds, clock.now) + } + + @Test + fun epochMillisTracksAdvanceFromBase() { + val clock = FakeTimeSource(epochBaseMillis = 1_000L) + assertEquals(1_000L, clock.epochMillis()) + + clock.advance(30.seconds) + assertEquals(31_000L, clock.epochMillis()) + } + + @Test + fun cannotMoveBackwards() { + val clock = FakeTimeSource() + assertThrows { clock.advance((-1).seconds) } + } + + @Test + fun tickAdvancesVirtualDelayAndReadableClockTogether() = runTest { + val clock = FakeTimeSource() + var fired = false + launch { + delay(25.seconds) + fired = true + } + + assertFalse(fired) // the delayed body has not run yet + tick(clock, 25.seconds) + + assertTrue(fired) // virtual delay fired + assertEquals(25.seconds, clock.now) // readable clock moved in lockstep + } +} diff --git a/android/wire-protocol/build.gradle.kts b/android/wire-protocol/build.gradle.kts new file mode 100644 index 0000000..f0c28ae --- /dev/null +++ b/android/wire-protocol/build.gradle.kts @@ -0,0 +1,25 @@ +// :wire-protocol — the FROZEN shared contract (Android analogue of src/types.ts + +// WireProtocol). Pure Kotlin/JVM: sealed ClientMessage/ServerMessage, MessageCodec, +// Validation, WireConstants, HostEndpoint, and the transport boundary interfaces. +// Depends on nothing else in the build (top of the dependency graph). + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + api(libs.kotlinx.serialization.json) + api(libs.kotlinx.coroutines.core) + + testImplementation(libs.bundles.unit.test) + testRuntimeOnly(libs.junit.platform.launcher) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/.gitkeep b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ClientMessage.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ClientMessage.kt new file mode 100644 index 0000000..3a58464 --- /dev/null +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ClientMessage.kt @@ -0,0 +1,82 @@ +package wang.yaojia.webterm.wire + +/** + * Client -> server WS frames (FROZEN contract, plan §3.1). The Android analogue of + * `src/types.ts:87-92` `ClientMessage` and iOS `WireProtocol.ClientMessage`. + * + * Immutable value types. Encoding to the wire is A4 (`MessageCodec`) — this file only + * freezes the SHAPES so every other module reads stable names. + * + * Server-side validation the caller must respect (the server SILENTLY DISCARDS invalid + * frames — `src/protocol.ts:45-86` — no error reply comes back): + * - `attach` MUST be the FIRST frame on a connection (`src/server.ts:766-773`). + * - `resize` cols/rows must be integers in [WireConstants.RESIZE_RANGE] (1..1000). + * - `attach.cwd`, when present, must be an absolute path (leading '/'). + * - `input.data` is raw keyboard bytes, passed through VERBATIM — never filtered + * (ARCHITECTURE invariant #9). + * + * DEVIATION NOTE (A2): the task brief listed `Attach(sessionId, cols, rows, cwd)`, but the + * server `attach` frame (`src/protocol.ts` validateAttach, `src/types.ts:88`) and the iOS + * `ClientMessage.attach(sessionId:cwd:)` carry NO cols/rows — the server hardcodes + * DEFAULT_COLS/ROWS on attach (`src/server.ts:774-781`) and takes real dimensions from a + * separate `resize` frame sent right after attach. Adding cols/rows here would make the + * encoded attach frame differ from the web client and break A4's byte-equality vectors, so + * the faithful wire contract (sessionId + optional cwd) is frozen instead. + */ +public sealed interface ClientMessage { + /** + * First frame. `sessionId == null` spawns a NEW session (A4 encodes this as an explicit + * JSON `"sessionId":null` — the key is REQUIRED by the server, `src/protocol.ts:132-134`). + * `cwd` = "new tab here" spawn directory (M6); appended only when [sessionId] is null. + */ + public data class Attach(val sessionId: String?, val cwd: String? = null) : ClientMessage + + /** Raw keyboard bytes, verbatim (invariant #9 — no content filtering). */ + public data class Input(val data: String) : ClientMessage + + /** Own message type so the server can `ioctl(TIOCSWINSZ)` -> SIGWINCH for TUIs. */ + public data class Resize(val cols: Int, val rows: Int) : ClientMessage + + /** + * Resolve a held permission gate with allow. [mode] is only meaningful for a `plan` gate + * and is encoded as a TOP-LEVEL `mode` key — the server's WS wiring re-parses the raw + * frame for it (`src/server.ts:91-102`). No [mode] = a plain approve. + */ + public data class Approve(val mode: ApproveMode? = null) : ClientMessage + + /** Resolve a held permission gate with deny. */ + public data object Reject : ClientMessage +} + +/** + * Permission mode written back when resolving a `plan` gate. Wire values mirror the server + * whitelist `PermissionMode` (`src/types.ts:371`). The Kotlin analogue of Swift's + * `enum ApproveMode: String` RawRepresentable — [wire] is the JSON string, [fromWire] the + * reverse lookup. + * + * Note: the plan-gate three-way UI only ever sends [ACCEPT_EDITS] / [DEFAULT]; raw [AUTO] is + * gated server-side by ALLOW_AUTO_MODE (default false) and downgraded to `default` otherwise. + */ +public enum class ApproveMode(public val wire: String) { + DEFAULT("default"), + ACCEPT_EDITS("acceptEdits"), + PLAN("plan"), + AUTO("auto"); + + public companion object { + public fun fromWire(value: String): ApproveMode? = entries.firstOrNull { it.wire == value } + } +} + +/** The `type` discriminator whitelist for client frames (`src/protocol.ts:27` ALLOWED_TYPES). */ +public enum class ClientMessageType(public val wire: String) { + ATTACH("attach"), + INPUT("input"), + RESIZE("resize"), + APPROVE("approve"), + REJECT("reject"); + + public companion object { + public fun fromWire(value: String): ClientMessageType? = entries.firstOrNull { it.wire == value } + } +} diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/HostClassifier.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/HostClassifier.kt new file mode 100644 index 0000000..5df4e6b --- /dev/null +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/HostClassifier.kt @@ -0,0 +1,92 @@ +package wang.yaojia.webterm.wire + +import java.net.URI + +/** + * The four network tiers of the pairing/warning table (plan §5.4). The SINGLE canonical type — + * hoisted into :wire-protocol so :api-client and :client-tls share ONE classifier (the two module + * copies had drifted). Faithful port of iOS `APIClient.HostNetworkTier`; the UI layers + * (PairingViewModel scheme+address 分层提示) consume ONE classification instead of re-deriving it. + * + * | tier | matches | §5.4 hint | + * |---|---|---| + * | [LOOPBACK] | localhost · 127.0.0.0/8 · ::1 | no warning | + * | [PRIVATE_LAN] | RFC1918 (10/8, 172.16/12, 192.168/16) · 169.254/16 · mDNS .local | non-blocking ws:// cleartext notice | + * | [TAILSCALE] | CGNAT 100.64.0.0/10 · MagicDNS *.ts.net | no cleartext warning (WireGuard-encrypted) | + * | [PUBLIC] | everything else (incl. out-of-range / malformed — fail-safe) | strongest blocking warning | + */ +public enum class HostNetworkTier { + LOOPBACK, + PRIVATE_LAN, + TAILSCALE, + PUBLIC, +} + +/** + * Pure, stateless classifier over a URL host string (plan §5.4 tiers). Faithful port of iOS + * `HostClassifier`. + * + * IPv6 note: only loopback `::1` is tiered; other IPv6 literals (incl. ULA `fd00::/8`) fall to + * [HostNetworkTier.PUBLIC] — fail-safe over-warning, matching v1 scope (the app dials IPv4 LAN / + * CGNAT / MagicDNS targets). The finer IPv6 handling lives in the UI layer if ever needed. + */ +public object HostClassifier { + private const val MAGIC_DNS_SUFFIX = ".ts.net" + private const val MDNS_SUFFIX = ".local" + private val LOOPBACK_NAMES: Set = setOf("localhost", "::1", "[::1]") + private const val IPV4_OCTET_COUNT = 4 + private val IPV4_OCTET_RANGE = 0..255 + + /** + * Classify a bare host string (as returned by URL host parsing — no scheme, no port). + * Unknown/malformed input is [HostNetworkTier.PUBLIC] by design (fail-safe). + */ + public fun classify(host: String): HostNetworkTier { + val lowered = host.lowercase() + if (lowered in LOOPBACK_NAMES) return HostNetworkTier.LOOPBACK + if (lowered.endsWith(MAGIC_DNS_SUFFIX)) return HostNetworkTier.TAILSCALE + if (lowered.endsWith(MDNS_SUFFIX)) return HostNetworkTier.PRIVATE_LAN + val octets = ipv4Octets(lowered) ?: return HostNetworkTier.PUBLIC + return classifyIpv4(octets) + } + + /** + * Convenience for callers holding a [HostEndpoint] (the VM's shape). A `baseUrl` that no longer + * yields a host classifies as [HostNetworkTier.PUBLIC] (fail-safe). + */ + public fun classify(endpoint: HostEndpoint): HostNetworkTier = classify(hostOf(endpoint)) + + /** + * Extract the bare host from a (validated) [HostEndpoint]. Shared by [classify] and pairing-error + * diagnosis so host derivation happens in ONE place. Trims defensively (never trust at a + * boundary) and returns "" on any parse failure (→ PUBLIC, fail-safe). + */ + public fun hostOf(endpoint: HostEndpoint): String = + runCatching { URI(endpoint.baseUrl.trim()).host }.getOrNull() ?: "" + + private fun classifyIpv4(octets: List): HostNetworkTier { + val first = octets[0] + val second = octets[1] + return when { + first == 127 -> HostNetworkTier.LOOPBACK // loopback 127/8 + first == 10 -> HostNetworkTier.PRIVATE_LAN // RFC1918 10/8 + first == 192 && second == 168 -> HostNetworkTier.PRIVATE_LAN // RFC1918 192.168/16 + first == 172 && second in 16..31 -> HostNetworkTier.PRIVATE_LAN // RFC1918 172.16/12 + first == 169 && second == 254 -> HostNetworkTier.PRIVATE_LAN // link-local 169.254/16 + first == 100 && second in 64..127 -> HostNetworkTier.TAILSCALE // CGNAT 100.64/10 (Tailscale) + else -> HostNetworkTier.PUBLIC + } + } + + /** + * Strict dotted-quad parse; anything else (hostnames, IPv6, out-of-range octets, wrong arity) + * → null. Public so pairing-error diagnosis can reuse the exact same rule. + */ + public fun ipv4Octets(host: String): List? { + val parts = host.split(".") + if (parts.size != IPV4_OCTET_COUNT) return null + val octets = parts.map { it.toIntOrNull() ?: return null } + if (octets.any { it !in IPV4_OCTET_RANGE }) return null + return octets + } +} diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/HostEndpoint.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/HostEndpoint.kt new file mode 100644 index 0000000..5992dc6 --- /dev/null +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/HostEndpoint.kt @@ -0,0 +1,102 @@ +package wang.yaojia.webterm.wire + +import java.net.URI + +/** + * A paired web-terminal host (FROZEN contract, plan §3.1). The SINGLE point of derivation for + * the `Origin` header and the WS URL — hand-assembling either anywhere else is a review + * CRITICAL (CSWSH defence, TECH_DOC §7). Android analogue of iOS `WireProtocol.HostEndpoint`. + * + * Derivations are computed once by [fromBaseUrl] and stored immutably: + * - [originHeader] = `://[:]`, omitting the scheme's default port + * (http/80, https/443), scheme+host lowercased, IPv6 bracketed — byte-identical to the + * browser's Origin serialization. The server re-parses BOTH sides via `new URL()` and + * compares protocol/hostname/port (`src/http/origin.ts:31-51`), so this must match one of + * the server's NIC-derived allowed origins exactly or the WS upgrade 401s. + * - [wsUrl] = same host+port as dialed, scheme http->ws / https->wss, path replaced by + * [WireConstants.WS_PATH]; query/fragment/credentials dropped. The dialed port is kept + * verbatim here (unlike the origin, which drops default ports). + * + * Pure Kotlin/JVM only — `java.net.URI` is JVM stdlib parsing, not an Android import. + * Persistence (A12 host-registry) stores the [baseUrl] string and reconstructs via + * [fromBaseUrl], re-validating on load (untrusted at rest). + */ +public class HostEndpoint private constructor( + /** The URL the user dialed (`http(s)://[:]`). Path/query/fragment/credentials it carried are ignored by the derivations. */ + public val baseUrl: String, + /** Derived WS endpoint (`ws(s)://…/term`). Read-only. */ + public val wsUrl: String, + /** Derived `Origin` header value — see class doc. Never hand-assemble elsewhere. */ + public val originHeader: String, +) { + override fun equals(other: Any?): Boolean = + other is HostEndpoint && + baseUrl == other.baseUrl && + wsUrl == other.wsUrl && + originHeader == other.originHeader + + override fun hashCode(): Int { + var result = baseUrl.hashCode() + result = 31 * result + wsUrl.hashCode() + result = 31 * result + originHeader.hashCode() + return result + } + + override fun toString(): String = "HostEndpoint(baseUrl=$baseUrl, wsUrl=$wsUrl, originHeader=$originHeader)" + + public companion object { + private val WS_SCHEME_BY_HTTP: Map = mapOf("http" to "ws", "https" to "wss") + private val DEFAULT_PORT_BY_SCHEME: Map = mapOf("http" to 80, "https" to 443) + + /** + * Validating factory: returns null unless [baseUrl] parses as an http(s) URL with a + * non-empty host. QR-scan / manual-entry payloads are untrusted external input — reject + * early (plan §5). + */ + public fun fromBaseUrl(baseUrl: String): HostEndpoint? { + // Trim ONCE, here, and store the trimmed value: downstream re-parsers (PairingProbe + // httpBaseUrl, HostClassifier.hostOf) do `URI(endpoint.baseUrl)` and would otherwise + // throw / misclassify on stray surrounding whitespace from a QR scan or manual entry. + val trimmed = baseUrl.trim() + val uri = try { + URI(trimmed) + } catch (_: Exception) { + return null + } + val scheme = uri.scheme?.lowercase() ?: return null + val wsScheme = WS_SCHEME_BY_HTTP[scheme] ?: return null // rejects non-http(s) + val host = uri.host ?: return null + if (host.isEmpty()) return null + val port: Int? = uri.port.takeIf { it != -1 } + + return HostEndpoint( + baseUrl = trimmed, + wsUrl = deriveWsUrl(wsScheme, host, port), + originHeader = deriveOrigin(scheme, host.lowercase(), port), + ) + } + + private fun deriveOrigin(scheme: String, host: String, port: Int?): String { + val serializedHost = bracketIfIpv6(host) + val defaultPort = DEFAULT_PORT_BY_SCHEME[scheme] + return if (port == null || port == defaultPort) { + "$scheme://$serializedHost" + } else { + "$scheme://$serializedHost:$port" + } + } + + private fun deriveWsUrl(wsScheme: String, host: String, port: Int?): String { + val serializedHost = bracketIfIpv6(host) + val portPart = if (port == null) "" else ":$port" + return "$wsScheme://$serializedHost$portPart${WireConstants.WS_PATH}" + } + + /** + * IPv6 literals need brackets in an Origin / URL. `URI.getHost()` has returned them both + * bare and pre-bracketed across JVMs — wrap idempotently. + */ + private fun bracketIfIpv6(host: String): String = + if (host.contains(":") && !host.startsWith("[")) "[$host]" else host + } +} diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/HttpTransport.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/HttpTransport.kt new file mode 100644 index 0000000..2ed052c --- /dev/null +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/HttpTransport.kt @@ -0,0 +1,51 @@ +package wang.yaojia.webterm.wire + +/** + * Thin HTTP I/O boundary (FROZEN contract, plan §3.1). The Android analogue of iOS + * `WireProtocol.HTTPTransport`. `:api-client` (A8/A9) builds [HttpRequest]s (stamping `Origin` + * IFF the route is a mutating/guarded one, plan §4.3) and sends them through this seam; + * `:transport-okhttp` (A7) implements it over OkHttp, the fake in `:test-support` (A3) queues + * canned responses. Pure interface — the request/response DTOs are OkHttp-free so both sides + * share them without an Android dependency. + */ +public interface HttpTransport { + /** + * Perform one HTTP exchange. Implementations throw on transport-level failure; a non-2xx + * status is RETURNED (not thrown) — classification (e.g. `PairingError`) is the caller's job. + */ + public suspend fun send(request: HttpRequest): HttpResponse +} + +/** HTTP verbs used by the client surface (`src/http` routes: GET reads, POST/PUT/DELETE guarded). */ +public enum class HttpMethod { + GET, + POST, + PUT, + DELETE, +} + +/** + * A pure HTTP request DTO built by `:api-client`. [headers] carries `Origin` only for guarded + * routes (plan §4.3 铁律). [body] is opaque bytes (JSON for the mutating routes); null for GET. + * + * NOTE: [body] is a `ByteArray`, so generated equality is by reference — these are transient + * I/O carriers, not value-equality keys. + */ +public data class HttpRequest( + val method: HttpMethod, + val url: String, + val headers: Map = emptyMap(), + val body: ByteArray? = null, +) + +/** + * A pure HTTP response DTO. [status] is the HTTP status code (204/404/403/429 all meaningful + * to callers — see plan §4.2/§4.3); [body] is the raw bytes (may be empty for 204). + * + * NOTE: [body] is a `ByteArray`, so generated equality is by reference — see [HttpRequest]. + */ +public data class HttpResponse( + val status: Int, + val body: ByteArray, + val headers: Map = emptyMap(), +) diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/MessageCodec.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/MessageCodec.kt new file mode 100644 index 0000000..c6ee40b --- /dev/null +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/MessageCodec.kt @@ -0,0 +1,232 @@ +package wang.yaojia.webterm.wire + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.longOrNull + +/** + * Pure codec for the WS text-frame protocol (frozen contract, plan §3.1). NEVER throws in + * either direction — the Android analogue of iOS `WireProtocol.MessageCodec`. + * + * - [encode] client → server. HAND-ROLLED so the bytes are IDENTICAL to the web + * client / server `JSON.stringify` (explicit `sessionId:null`, top-level + * `approve.mode`, JS control-char escaping). Every [ClientMessage] has a + * representation, so encode is total. + * - [decodeServer] server → client (UNTRUSTED). Tolerant kotlinx.serialization decode into a + * [ServerMessage]; a malformed / unknown / wrong-typed-required frame → null + * (drop + count, never crash — mirrors the server's own resilience). + * - [decodeClient] the boundary validator — the Android mirror of the server's + * `parseClientMessage` (type whitelist, `resize` 1..1000, `input.data` + * passthrough, `attach.sessionId` UUID-v4 / explicit-null). Invalid → null. + * Exact inverse of [encode] for round-trip verification. + */ +public object MessageCodec { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + // ─── encode (client → server) ──────────────────────────────────────────────── + + /** + * Encode a [ClientMessage] as a JSON text frame the server's `parseClientMessage` accepts, + * BYTE-IDENTICAL to the web client. String escaping matches `JSON.stringify` (short escapes + * for `\b \t \n \f \r`, `\u00xx` lowercase-hex for the other C0 control bytes); `attach` + * always carries an explicit `sessionId` key (null when absent); UUIDs are lowercased; + * `approve.mode` is a TOP-LEVEL key (the server re-reads the raw frame for it). + */ + public fun encode(message: ClientMessage): String = when (message) { + is ClientMessage.Attach -> encodeAttach(message.sessionId, message.cwd) + is ClientMessage.Input -> """{"type":"input","data":${jsonString(message.data)}}""" + is ClientMessage.Resize -> """{"type":"resize","cols":${message.cols},"rows":${message.rows}}""" + is ClientMessage.Approve -> + message.mode?.let { """{"type":"approve","mode":"${it.wire}"}""" } ?: """{"type":"approve"}""" + ClientMessage.Reject -> """{"type":"reject"}""" + } + + private fun encodeAttach(sessionId: String?, cwd: String?): String { + // The sessionId is escaped via jsonString (like every other string field) so encode stays + // TOTAL and byte-identical to JSON.stringify — never interpolate an id raw (JSON injection). + val sid = if (sessionId != null) jsonString(sessionId.lowercase()) else "null" + val cwdPart = if (cwd != null) ",\"cwd\":${jsonString(cwd)}" else "" + return """{"type":"attach","sessionId":$sid$cwdPart}""" + } + + /** JSON string literal with `JSON.stringify`-identical escaping (iterates UTF-16 units). */ + private fun jsonString(value: String): String { + val sb = StringBuilder(value.length + 2) + sb.append('"') + var i = 0 + while (i < value.length) { + val c = value[i] + when (c) { + '"' -> sb.append("\\\"") + '\\' -> sb.append("\\\\") + '\b' -> sb.append("\\b") // 0x08 + '\t' -> sb.append("\\t") // 0x09 + '\n' -> sb.append("\\n") // 0x0A + '\u000C' -> sb.append("\\f") // 0x0C + '\r' -> sb.append("\\r") // 0x0D + else -> + when { + c.code < 0x20 -> + sb.append("\\u").append(c.code.toString(16).padStart(4, '0')) + // Valid high+low surrogate pair: emit both UTF-16 units verbatim + // (JSON.stringify keeps the astral character as-is). + c.isHighSurrogate() && i + 1 < value.length && value[i + 1].isLowSurrogate() -> { + sb.append(c).append(value[i + 1]) + i++ // consume the low surrogate too + } + // Lone surrogate (a high without a low, or a bare low): ES2019 + // well-formed JSON.stringify emits \udXXX lowercase-hex. + c.isSurrogate() -> + sb.append("\\u").append(c.code.toString(16).padStart(4, '0')) + else -> sb.append(c) + } + } + i++ + } + sb.append('"') + return sb.toString() + } + + // ─── decodeServer (server → client, untrusted) ─────────────────────────────── + + /** + * Decode an untrusted server text frame into a [ServerMessage], or null to DROP it (bad + * JSON, non-object, unknown `type`, or a missing/wrong-typed REQUIRED field). Wrong-typed + * OPTIONAL fields are tolerated as absent (mirrors the server's tolerant parsing). Unknown + * keys are ignored. Never throws. + */ + public fun decodeServer(raw: String): ServerMessage? { + val root = parseObject(raw) ?: return null + val type = root.stringField("type") ?: return null + return when (ServerMessageType.fromWire(type)) { + ServerMessageType.ATTACHED -> decodeAttached(root) + ServerMessageType.OUTPUT -> root.stringField("data")?.let { ServerMessage.Output(it) } + ServerMessageType.EXIT -> decodeExit(root) + ServerMessageType.STATUS -> decodeStatus(root) + ServerMessageType.TELEMETRY -> decodeTelemetry(root) + null -> null + } + } + + private fun decodeAttached(obj: JsonObject): ServerMessage? { + val raw = obj.stringField("sessionId") ?: return null + if (!Validation.isValidSessionId(raw)) return null + return ServerMessage.Attached(raw.lowercase()) + } + + private fun decodeExit(obj: JsonObject): ServerMessage? { + val code = obj.intField("code") ?: return null + return ServerMessage.Exit(code, obj.stringField("reason")) + } + + private fun decodeStatus(obj: JsonObject): ServerMessage? { + val status = obj.stringField("status")?.let { ClaudeStatus.fromWire(it) } ?: return null + val detail = obj.stringField("detail") + val pending = obj.boolField("pending") ?: false + val gate = obj.stringField("gate")?.let { GateKind.fromWire(it) } + return ServerMessage.Status(status, detail, pending, gate) + } + + private fun decodeTelemetry(obj: JsonObject): ServerMessage? { + val tel = obj["telemetry"] as? JsonObject ?: return null + val at = tel.longField("at") ?: return null // only required field + return ServerMessage.Telemetry( + StatusTelemetry( + contextUsedPct = tel.doubleField("contextUsedPct"), + costUsd = tel.doubleField("costUsd"), + linesAdded = tel.intField("linesAdded"), + linesRemoved = tel.intField("linesRemoved"), + model = tel.stringField("model"), + effort = tel.stringField("effort"), + pr = tel.decodeNested("pr", PrInfo.serializer()), + rate = tel.decodeNested("rate", RateInfo.serializer()), + at = at, + ), + ) + } + + // ─── decodeClient (boundary validator — server-parseClientMessage mirror) ───── + + /** + * Validate + decode a client text frame the way the server's `parseClientMessage` would + * ACCEPT/REJECT it: `type` in [ClientMessageType]; `resize` cols/rows non-string integers in + * 1..1000; `input.data` a string (passed through VERBATIM); `attach.sessionId` present as + * explicit null or a UUID-v4; `attach.cwd` (when present) an absolute path. Invalid → null. + * The exact inverse of [encode], so `decodeClient(encode(m)) == m` for canonical `m`. + */ + public fun decodeClient(raw: String): ClientMessage? { + val root = parseObject(raw) ?: return null + val type = root.stringField("type") ?: return null + return when (ClientMessageType.fromWire(type)) { + ClientMessageType.ATTACH -> decodeAttach(root) + ClientMessageType.INPUT -> root.stringField("data")?.let { ClientMessage.Input(it) } + ClientMessageType.RESIZE -> decodeResize(root) + ClientMessageType.APPROVE -> + ClientMessage.Approve(root.stringField("mode")?.let { ApproveMode.fromWire(it) }) + ClientMessageType.REJECT -> ClientMessage.Reject + null -> null + } + } + + private fun decodeResize(obj: JsonObject): ClientMessage? { + val cols = obj.intField("cols")?.takeIf { it in WireConstants.RESIZE_RANGE } ?: return null + val rows = obj.intField("rows")?.takeIf { it in WireConstants.RESIZE_RANGE } ?: return null + return ClientMessage.Resize(cols, rows) + } + + private fun decodeAttach(obj: JsonObject): ClientMessage? { + if ("sessionId" !in obj) return null // the key is REQUIRED (present as null or uuid) + + val cwd: String? = + obj["cwd"]?.let { el -> + val c = (el as? JsonPrimitive)?.takeIf { it.isString }?.content ?: return null + if (!Validation.isAbsoluteCwd(c)) return null + c + } + + val sidEl = obj.getValue("sessionId") + if (sidEl is JsonNull) return ClientMessage.Attach(null, cwd) + val sid = (sidEl as? JsonPrimitive)?.takeIf { it.isString }?.content ?: return null + if (!Validation.isValidSessionId(sid)) return null + return ClientMessage.Attach(sid.lowercase(), cwd) + } + + // ─── shared JSON helpers (tolerant, never throw) ───────────────────────────── + + private fun parseObject(raw: String): JsonObject? = + runCatching { json.parseToJsonElement(raw) }.getOrNull() as? JsonObject + + /** A JSON STRING value only (a number/bool/null/object → null → treated as absent). */ + private fun JsonObject.stringField(key: String): String? = + (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content + + /** A JSON non-string INTEGER only (`"80"`, `80.5`, null → null). */ + private fun JsonObject.intField(key: String): Int? = + (this[key] as? JsonPrimitive)?.takeIf { !it.isString }?.intOrNull + + /** A JSON non-string integral LONG only (for the millisecond `at` timestamp). */ + private fun JsonObject.longField(key: String): Long? = + (this[key] as? JsonPrimitive)?.takeIf { !it.isString }?.longOrNull + + /** A JSON non-string NUMBER as Double (a quoted number/other → null → treated as absent). */ + private fun JsonObject.doubleField(key: String): Double? = + (this[key] as? JsonPrimitive)?.takeIf { !it.isString }?.doubleOrNull + + /** A JSON BOOLEAN only (a quoted "true" / other → null → caller defaults to false). */ + private fun JsonObject.boolField(key: String): Boolean? = + (this[key] as? JsonPrimitive)?.takeIf { !it.isString }?.booleanOrNull + + /** Decode a nested @Serializable object tolerantly: any decode failure → null (absent). */ + private fun JsonObject.decodeNested( + key: String, + deserializer: kotlinx.serialization.DeserializationStrategy, + ): T? = this[key]?.let { runCatching { json.decodeFromJsonElement(deserializer, it) }.getOrNull() } +} diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ServerMessage.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ServerMessage.kt new file mode 100644 index 0000000..689efc4 --- /dev/null +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/ServerMessage.kt @@ -0,0 +1,85 @@ +package wang.yaojia.webterm.wire + +/** + * Server -> client WS frames (FROZEN contract, plan §3.1). The Android analogue of + * `src/types.ts:109-120` `ServerMessage` and iOS `WireProtocol.ServerMessage`. + * + * Every frame is UNTRUSTED input: A4's `MessageCodec.decodeServer` maps a raw text frame to + * one of these or to `null` (drop the frame) — an undecodable frame is counted and dropped, + * NEVER a crash (`src/protocol.ts` resilience). This file only freezes the SHAPES. + */ +public sealed interface ServerMessage { + /** + * Attach confirmation. ALWAYS adopt the server-issued id — attaching with an unknown UUID + * yields a fresh session id (`src/session/manager.ts`). [sessionId] is the raw string here; + * A4's decode enforces the UUID-v4 shape before constructing this. + */ + public data class Attached(val sessionId: String) : ServerMessage + + /** Opaque ANSI/UTF-8 bytes; ring-buffer replay and the live stream share this shape. */ + public data class Output(val data: String) : ServerMessage + + /** + * Shell exit (terminal). `code == WireConstants.SPAWN_FAILED_EXIT_CODE` (-1) means the + * spawn never succeeded (M4) and [reason] is required server-side in that case. + */ + public data class Exit(val code: Int, val reason: String? = null) : ServerMessage + + /** + * Claude Code activity derived from hooks (H2/H3/B4). [pending] = a tool approval is held + * server-side and the client may approve/reject; [gate] says which kind. An absent + * `pending` decodes as false; an unrecognized `gate` decodes as null (tolerated as absent + * so the pending signal is never lost). + */ + public data class Status( + val status: ClaudeStatus, + val detail: String? = null, + val pending: Boolean = false, + val gate: GateKind? = null, + ) : ServerMessage + + /** Latest statusLine telemetry broadcast (B2). */ + public data class Telemetry(val telemetry: StatusTelemetry) : ServerMessage +} + +/** + * Claude Code activity (`src/types.ts:97` `ClaudeStatus`). `unknown` = no hook signal yet; + * `stuck` (A5) = output silent past STUCK_TTL while not idle/exited. + */ +public enum class ClaudeStatus(public val wire: String) { + WORKING("working"), + WAITING("waiting"), + IDLE("idle"), + UNKNOWN("unknown"), + STUCK("stuck"); + + public companion object { + public fun fromWire(value: String): ClaudeStatus? = entries.firstOrNull { it.wire == value } + } +} + +/** + * Which held approval a `status` carries (`src/types.ts:101` `PermissionGate`). `plan` = an + * ExitPlanMode three-way gate; `tool` = an ordinary tool gate. + */ +public enum class GateKind(public val wire: String) { + TOOL("tool"), + PLAN("plan"); + + public companion object { + public fun fromWire(value: String): GateKind? = entries.firstOrNull { it.wire == value } + } +} + +/** The `type` discriminator whitelist for server frames (`src/types.ts:109-120`). */ +public enum class ServerMessageType(public val wire: String) { + ATTACHED("attached"), + OUTPUT("output"), + EXIT("exit"), + STATUS("status"), + TELEMETRY("telemetry"); + + public companion object { + public fun fromWire(value: String): ServerMessageType? = entries.firstOrNull { it.wire == value } + } +} diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/StatusTelemetry.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/StatusTelemetry.kt new file mode 100644 index 0000000..ca3c8fb --- /dev/null +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/StatusTelemetry.kt @@ -0,0 +1,42 @@ +package wang.yaojia.webterm.wire + +import kotlinx.serialization.Serializable + +/** + * Per-session statusLine telemetry (B2), broadcast in a `telemetry` frame. FROZEN shape, + * mirrors `src/types.ts:412-422` `StatusTelemetry` and iOS `WireProtocol.StatusTelemetry`. + * + * Every metric is OPTIONAL; [at] (server receive time, ms since epoch) is REQUIRED — a frame + * without it is dropped by A4's decoder. Property names match the server JSON keys exactly so + * the tolerant `Json` decode configured in A4 maps them directly. `@Serializable` here only + * freezes the SHAPE; the tolerant-decode CONFIG (ignoreUnknownKeys / wrong-typed-optional -> + * absent) lives in A4. + */ +@Serializable +public data class StatusTelemetry( + val contextUsedPct: Double? = null, + val costUsd: Double? = null, + val linesAdded: Int? = null, + val linesRemoved: Int? = null, + val model: String? = null, + val effort: String? = null, + val pr: PrInfo? = null, + val rate: RateInfo? = null, + /** Server receive timestamp (ms since epoch). REQUIRED — frames without it are dropped. */ + val at: Long, +) + +/** Pull-request summary carried in [StatusTelemetry.pr] (`src/types.ts:419`). */ +@Serializable +public data class PrInfo( + val number: Int, + val url: String, + val reviewState: String? = null, +) + +/** Rate-limit summary carried in [StatusTelemetry.rate] (`src/types.ts:420`). */ +@Serializable +public data class RateInfo( + val fiveHourPct: Double? = null, + val sevenDayPct: Double? = null, +) diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/TermTransport.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/TermTransport.kt new file mode 100644 index 0000000..a39bed7 --- /dev/null +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/TermTransport.kt @@ -0,0 +1,73 @@ +package wang.yaojia.webterm.wire + +import kotlinx.coroutines.flow.Flow + +/** + * The ONLY WS I/O boundary (FROZEN contract, plan §3.1/§3.2). The Android analogue of iOS + * `WireProtocol.TermTransport`. + * + * `:transport-okhttp` (A7) and the `FakeTransport` in `:test-support` (A3) both implement this; + * `SessionEngine` (A14) cannot tell them apart. Pure interface — NO OkHttp/Android imports. + */ +public interface TermTransport { + /** + * Open a WS connection to [HostEndpoint.wsUrl], stamping `Origin: endpoint.originHeader` on + * the upgrade (CSWSH defence, plan §5.1). Throws on connect-time failure (the underlying + * error is rethrown verbatim so the pairing probe, A9, can classify POSIX/TLS codes). + */ + public suspend fun connect(endpoint: HostEndpoint): TransportConnection +} + +/** + * One live WS connection, as an immutable capability handle (FROZEN contract). The Android + * analogue of iOS `WireProtocol.TransportConnection` (Swift's closure struct becomes an + * interface here so both the OkHttp impl and the fake can implement it). + */ +public interface TransportConnection { + /** + * Server JSON text frames, in arrival order. Normal completion of the flow = a clean close; + * an exception thrown by the flow = a transport error. The two MUST stay distinguishable + * (the engine treats clean-close vs error differently, A14). + */ + public val frames: Flow + + /** Send one client JSON text frame (produced by A4's `MessageCodec.encode`). */ + public suspend fun send(frame: String) + + /** Close the connection (client detach — the server-side PTY keeps running). */ + public suspend fun close() +} + +/** + * One live connection's ping capability (see [PingableTermTransport]). Android analogue of the + * iOS SessionCore-internal `ConnectionPinger`. + */ +public interface ConnectionPinger { + /** + * Send one WS ping; returns true iff the pong arrived in time (false = a miss, counted by + * the PingScheduler against [Tunables.PONG_MISS_LIMIT]). + */ + public suspend fun ping(): Boolean +} + +/** + * A [TermTransport] that also exposes a per-connection ping hook, so the pure `PingScheduler` + * (A5) can drive keep-alive pings only through transports that support it. Android analogue of + * the iOS `transport as? any PingableTermTransport` downcast: `SessionEngine` opens the + * connection via [connectPingable] when the transport implements this, otherwise falls back to + * plain [TermTransport.connect]. The `FakeTransport` (A3) intentionally does NOT implement this, + * so engine tests drive the PingScheduler through an injected closure instead. + */ +public interface PingableTermTransport : TermTransport { + /** + * Like [connect], but also hands back the ping hook for that same connection (for + * PingScheduler wiring). + */ + public suspend fun connectPingable(endpoint: HostEndpoint): PingableConnection +} + +/** The frozen [TransportConnection] PLUS its ping capability (iOS: a `(connection, pinger)` tuple). */ +public data class PingableConnection( + val connection: TransportConnection, + val pinger: ConnectionPinger, +) diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/TimelineEvent.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/TimelineEvent.kt new file mode 100644 index 0000000..bfbea5d --- /dev/null +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/TimelineEvent.kt @@ -0,0 +1,33 @@ +package wang.yaojia.webterm.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * One activity-timeline entry from `GET /live-sessions/:id/events` (FROZEN shape, plan §3.1; + * mirrors `src/types.ts:434-439` and iOS `WireProtocol.TimelineEvent`). + * + * The server derives [eventClass] semantically (`src/types.ts:429`: tool/waiting/done/stuck/ + * user) — the client never re-derives it from hook names. [eventClass] stays a raw String so + * the SHAPE decodes even for future/unknown classes; consumers drop unknowns via + * [hasKnownClass]. The tolerant list decode (per-element drop, non-array -> empty) is A8. + */ +@Serializable +public data class TimelineEvent( + /** Server ingest timestamp (ms since epoch). */ + val at: Long, + /** Server-derived semantic class; see [KNOWN_CLASSES]. JSON key is `class`. */ + @SerialName("class") val eventClass: String, + /** Sanitized tool name (server-side: <=200 chars, control chars stripped). */ + val toolName: String? = null, + /** Server-derived human phrase ("ran Bash", "edited 3 files"). */ + val label: String, +) { + /** True when [eventClass] is one the server currently emits. */ + public val hasKnownClass: Boolean get() = eventClass in KNOWN_CLASSES + + public companion object { + /** The server's `TimelineClass` union (`src/types.ts:429`). */ + public val KNOWN_CLASSES: Set = setOf("tool", "waiting", "done", "stuck", "user") + } +} diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/Tunables.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/Tunables.kt new file mode 100644 index 0000000..c4e0d6a --- /dev/null +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/Tunables.kt @@ -0,0 +1,62 @@ +package wang.yaojia.webterm.wire + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Client-side tuning constants (FROZEN contract, plan §3.2.1). The Android analogue of iOS + * `WireProtocol.Tunables`. Downstream modules (`:session-core` PingScheduler/AwayDigest/ + * TitleSanitizer, `:transport-okhttp`, `:api-client`) read these — adding/changing one is a + * coordination point. All named; no magic numbers anywhere downstream. + */ +public object Tunables { + /** + * WS keep-alive ping period. OkHttp's `pingInterval` could drive this, but the miss policy + * ([PONG_MISS_LIMIT]) is driven by a `PingScheduler` on an injected clock so a half-dead + * connection ("looks connected but dead") is detected deterministically in tests. + */ + public val PING_INTERVAL: Duration = 25.seconds + + /** + * Consecutive missed pongs after which the connection is declared dead: 1 miss is + * tolerated, the 2nd is a disconnect signal. Any answered ping resets the counter. + */ + public const val PONG_MISS_LIMIT: Int = 2 + + /** Foreground `/live-sessions` polling period (mirrors `public/launcher.ts` REFRESH_MS). */ + public val LIST_POLL_INTERVAL: Duration = 5.seconds + + /** + * Telemetry chips grey out when [StatusTelemetry.at] is older than this. Mirrors the server + * default STATUSLINE_TTL_MS (`src/config.ts`); the server value is env-overridable at + * runtime while the client bakes the default — accepted drift (plan §3.2.1). + */ + public const val TELEMETRY_STALE_TTL_MS: Long = 30_000L + + /** Away-digest banner auto-fade delay. */ + public val DIGEST_FADE_DELAY: Duration = 8.seconds + + /** + * Max session-title length after sanitisation; OSC titles are host/attacker-controlled + * input (consumed by `:session-core` TitleSanitizer, A6). + */ + public const val TITLE_MAX_LENGTH: Int = 256 + + /** + * Overall pairing-probe deadline: if either probe step hangs past this the probe resolves + * `timeout` (`:api-client` pairing, A9). + */ + public val PAIRING_PROBE_TIMEOUT: Duration = 10.seconds + + /** + * Client WS frame cap. The default (1 MiB on most stacks) is too small: ring-buffer replay + * arrives as ONE full-snapshot frame and JSON escaping inflates control bytes 1-6x, so the + * worst case is ~6 x SCROLLBACK_BYTES (default 2 MiB) plus envelope -> 16 MiB. + * + * COUPLING WARNING: SCROLLBACK_BYTES is a server env knob the client cannot discover at + * runtime. If the host raises it past ~2.7 MB (or replay is extremely escape-dense), the + * receive fails and MUST be classified as the non-retryable `replayTooLarge` failure — + * never fed into the backoff reconnect loop (plan §3.2 / §9 R6). + */ + public const val MAX_WS_MESSAGE_BYTES: Long = 16L * 1024 * 1024 +} diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/Validation.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/Validation.kt new file mode 100644 index 0000000..bd4f854 --- /dev/null +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/Validation.kt @@ -0,0 +1,51 @@ +package wang.yaojia.webterm.wire + +/** + * Boundary validation — the Android analogue of the server's `parseClientMessage` guards + * (`src/protocol.ts`) and iOS `WireProtocol.Validation`. Pure functions, NEVER throw. + * + * These are the primitives A4's [MessageCodec] uses at the trust boundary; they are also + * exposed so a caller can validate BEFORE sending (the server SILENTLY DISCARDS invalid + * frames — no error reply comes back, `src/protocol.ts:45-86`). + */ +public object Validation { + /** + * Byte-identical port of the server's `SESSION_ID_RE` + * (`src/protocol.ts:22-23`, exported there; housed here for the Android build): + * `^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$` case-insensitive. + * + * UUID v4 — 8-4-4-4-12 hex groups, version nibble `4`, variant nibble `8/9/a/b`, `/i` + * (M7). `crypto.randomUUID()` values pass; strings like `"abc123"` are rejected. + */ + public val SESSION_ID_RE: Regex = + Regex( + "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + RegexOption.IGNORE_CASE, + ) + + /** True when [candidate] is a valid UUID-v4 session id (whole-string match, M7). */ + public fun isValidSessionId(candidate: String): Boolean = SESSION_ID_RE.matches(candidate) + + /** + * Mirrors the server's `isValidDimension` range check for `resize`: both cols and rows + * must fall in [WireConstants.RESIZE_RANGE] (1..1000). Frames outside the range are + * silently dropped by the server — validate before sending (`src/protocol.ts:113-115`). + */ + public fun isValidResize(cols: Int, rows: Int): Boolean = + cols in WireConstants.RESIZE_RANGE && rows in WireConstants.RESIZE_RANGE + + /** + * Mirrors the server's `attach.cwd` check: must be an absolute path (leading `/`). Deeper + * normalisation stays server-side (`src/protocol.ts:142-149`, Sec L2). + */ + public fun isAbsoluteCwd(candidate: String): Boolean = candidate.startsWith("/") + + /** + * Client-frame `type` whitelist (the server's `ALLOWED_TYPES`, `src/protocol.ts:27`). + * Delegates to the frozen [ClientMessageType] enum so the whitelist has ONE source. + */ + public fun isAllowedClientType(type: String): Boolean = ClientMessageType.fromWire(type) != null + + /** Server-frame `type` whitelist (`src/types.ts:109-120`), the [ServerMessageType] enum. */ + public fun isAllowedServerType(type: String): Boolean = ServerMessageType.fromWire(type) != null +} diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/WireConstants.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/WireConstants.kt new file mode 100644 index 0000000..2b7878c --- /dev/null +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/WireConstants.kt @@ -0,0 +1,26 @@ +package wang.yaojia.webterm.wire + +/** + * Wire-level constants shared with the server (FROZEN contract, plan §3.1). The Android + * analogue of iOS `WireProtocol.WireConstants`. No magic numbers downstream. + */ +public object WireConstants { + /** WS endpoint path (`src/config.ts` DEFAULT_WS_PATH). Stamped into [HostEndpoint.wsUrl]. */ + public const val WS_PATH: String = "/term" + + /** + * Soft-reset prefix (ESC `[0m`) the server prepends to ring-buffer replay as a safety net + * (`src/types.ts:167-170` / M2). Clients may use it to recognise the replay boundary; + * NEVER strip it — it is valid ANSI the emulator must consume. + */ + public const val REPLAY_SOFT_RESET_PREFIX: String = "\u001B[0m" + + /** + * `exit.code` value meaning the PTY spawn never succeeded (M4); `exit.reason` is required + * server-side in that case. Not a retryable state. + */ + public const val SPAWN_FAILED_EXIT_CODE: Int = -1 + + /** Valid `resize` cols/rows range, inclusive (`src/protocol.ts:113-115`). */ + public val RESIZE_RANGE: IntRange = 1..1000 +} diff --git a/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/.gitkeep b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/HostEndpointTest.kt b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/HostEndpointTest.kt new file mode 100644 index 0000000..b3d23d5 --- /dev/null +++ b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/HostEndpointTest.kt @@ -0,0 +1,34 @@ +package wang.yaojia.webterm.wire + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.net.URI + +/** + * [HostEndpoint.fromBaseUrl] is the SINGLE derivation point for the stored `baseUrl` and the + * Origin/WS derivations. These vectors lock the trim fix: the stored `baseUrl` must carry no + * surrounding whitespace, so every downstream `URI(endpoint.baseUrl)` re-parse succeeds. + */ +class HostEndpointTest { + @Test + fun `fromBaseUrl stores a trimmed baseUrl so downstream URI re-parsing never sees whitespace`() { + val padded = requireNotNull(HostEndpoint.fromBaseUrl(" http://192.168.1.5:3000 ")) + val clean = requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000")) + + assertEquals("http://192.168.1.5:3000", padded.baseUrl) + assertEquals(padded.baseUrl, padded.baseUrl.trim(), "stored baseUrl has no surrounding whitespace") + // The padded input yields an endpoint identical to the trimmed-input variant. + assertEquals(clean, padded) + // And a downstream re-parse (PairingProbe.httpBaseUrl / HostClassifier.hostOf) now succeeds. + assertEquals("192.168.1.5", URI(padded.baseUrl).host) + } + + @Test + fun `trimming does not disturb the Origin and WS derivations`() { + val padded = requireNotNull(HostEndpoint.fromBaseUrl(" https://mac.tailnet.ts.net ")) + + assertEquals("https://mac.tailnet.ts.net", padded.baseUrl) + assertEquals("https://mac.tailnet.ts.net", padded.originHeader) + assertEquals("wss://mac.tailnet.ts.net/term", padded.wsUrl) + } +} diff --git a/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/MessageCodecDecodeClientTest.kt b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/MessageCodecDecodeClientTest.kt new file mode 100644 index 0000000..d0a22e4 --- /dev/null +++ b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/MessageCodecDecodeClientTest.kt @@ -0,0 +1,135 @@ +package wang.yaojia.webterm.wire + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +/** + * A4 boundary-validation vectors: [MessageCodec.decodeClient] is the Android mirror of the + * server's `parseClientMessage` accept/reject decision, and the exact inverse of + * [MessageCodec.encode]. Accept/reject vectors transcribed from `test/protocol.test.ts` / + * the iOS `ServerVectorTests` server-mirror; the round-trip block proves encode/decode symmetry. + */ +class MessageCodecDecodeClientTest { + private val validUuid = "f47ac10b-58cc-4372-a567-0e02b2c3d479" + + // ─── accept: valid client frames decode to the right message ───────────────── + + @Test + fun `decodes attach with explicit null and with a uuid`() { + assertEquals( + ClientMessage.Attach(null), + MessageCodec.decodeClient("""{"type":"attach","sessionId":null}"""), + ) + assertEquals( + ClientMessage.Attach(validUuid), + MessageCodec.decodeClient("""{"type":"attach","sessionId":"$validUuid"}"""), + ) + } + + @Test + fun `decodes attach with an absolute cwd and normalizes an uppercase sessionId`() { + assertEquals( + ClientMessage.Attach(null, "/Users/dev/proj"), + MessageCodec.decodeClient("""{"type":"attach","sessionId":null,"cwd":"/Users/dev/proj"}"""), + ) + assertEquals( + ClientMessage.Attach(validUuid), + MessageCodec.decodeClient("""{"type":"attach","sessionId":"F47AC10B-58CC-4372-A567-0E02B2C3D479"}"""), + ) + } + + @Test + fun `decodes input resize approve and reject, ignoring unknown keys`() { + assertEquals(ClientMessage.Input("ls -la"), MessageCodec.decodeClient("""{"type":"input","data":"ls -la"}""")) + assertEquals(ClientMessage.Resize(1, 1), MessageCodec.decodeClient("""{"type":"resize","cols":1,"rows":1}""")) + assertEquals( + ClientMessage.Resize(1000, 1000), + MessageCodec.decodeClient("""{"type":"resize","cols":1000,"rows":1000}"""), + ) + assertEquals( + ClientMessage.Resize(80, 40), + MessageCodec.decodeClient("""{"type":"resize","cols":80,"rows":40,"extra":"ignored"}"""), + ) + assertEquals(ClientMessage.Approve(), MessageCodec.decodeClient("""{"type":"approve"}""")) + assertEquals(ClientMessage.Reject, MessageCodec.decodeClient("""{"type":"reject"}""")) + } + + @Test + fun `recovers a known approve mode and tolerates an unknown one as absent`() { + assertEquals( + ClientMessage.Approve(ApproveMode.ACCEPT_EDITS), + MessageCodec.decodeClient("""{"type":"approve","mode":"acceptEdits"}"""), + ) + assertEquals( + ClientMessage.Approve(ApproveMode.DEFAULT), + MessageCodec.decodeClient("""{"type":"approve","mode":"default"}"""), + ) + assertEquals( + ClientMessage.Approve(null), + MessageCodec.decodeClient("""{"type":"approve","mode":"alien"}"""), + ) + } + + // ─── reject: invalid client frames -> null ─────────────────────────────────── + + @Test + fun `rejects every invalid client frame as null`() { + val invalid = listOf( + "not json at all", "", "null", "[]", "42", + """{"type":"ping"}""", """{"data":"hello"}""", """{"type":42}""", + """{"type":"resize","cols":0,"rows":40}""", + """{"type":"resize","cols":1001,"rows":40}""", + """{"type":"resize","cols":80,"rows":0}""", + """{"type":"resize","cols":80,"rows":1001}""", + """{"type":"resize","cols":80.5,"rows":40}""", + """{"type":"resize","cols":80,"rows":24.9}""", + """{"type":"resize","cols":"80","rows":40}""", + """{"type":"resize","rows":40}""", + """{"type":"resize","cols":80}""", + """{"type":"input","data":42}""", + """{"type":"input","data":null}""", + """{"type":"input"}""", + """{"type":"input","data":{}}""", + """{"type":"attach","sessionId":"abc123"}""", + """{"type":"attach","sessionId":""}""", + """{"type":"attach","sessionId":42}""", + """{"type":"attach"}""", + """{"type":"attach","sessionId":null,"cwd":"relative"}""", + """{"type":"attach","sessionId":null,"cwd":42}""", + // the removed 'blur' type must be rejected + """{"type":"blur"}""", + ) + for (frame in invalid) { + assertNull(MessageCodec.decodeClient(frame), "should reject: $frame") + } + } + + // ─── round-trip: decodeClient(encode(m)) == m ──────────────────────────────── + + @Test + fun `encode then decodeClient is the identity for canonical messages`() { + val messages = listOf( + ClientMessage.Attach(null), + ClientMessage.Attach(validUuid), + ClientMessage.Attach(null, "/Users/dev/proj"), + ClientMessage.Attach(validUuid, "/srv/app"), + ClientMessage.Input("ls -la"), + // control bytes + quote/backslash survive the round trip + ClientMessage.Input("hi" + Char(0x0D) + Char(0x1B) + "[A"), + ClientMessage.Input("a\"b\\c"), + ClientMessage.Resize(80, 24), + ClientMessage.Resize(1, 1), + ClientMessage.Resize(1000, 1000), + ClientMessage.Approve(null), + ClientMessage.Approve(ApproveMode.DEFAULT), + ClientMessage.Approve(ApproveMode.ACCEPT_EDITS), + ClientMessage.Approve(ApproveMode.PLAN), + ClientMessage.Approve(ApproveMode.AUTO), + ClientMessage.Reject, + ) + for (m in messages) { + assertEquals(m, MessageCodec.decodeClient(MessageCodec.encode(m)), "round-trip: $m") + } + } +} diff --git a/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/MessageCodecDecodeServerTest.kt b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/MessageCodecDecodeServerTest.kt new file mode 100644 index 0000000..b8b22eb --- /dev/null +++ b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/MessageCodecDecodeServerTest.kt @@ -0,0 +1,245 @@ +package wang.yaojia.webterm.wire + +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import kotlin.random.Random + +/** + * A4 decode vectors: [MessageCodec.decodeServer] maps an UNTRUSTED server text frame to a + * [ServerMessage] or null (drop). Transcribed from the iOS `ServerVectorTests` + the server's + * own `test/protocol.test.ts`. It must never throw, tolerate unknown keys, treat wrong-typed + * OPTIONAL fields as absent, and drop on any missing/wrong-typed REQUIRED field. + * + * Control characters in expected VALUES are built with `Char(code)` (never raw source bytes); + * JSON escapes inside frame literals use `\\uXXXX`. + */ +class MessageCodecDecodeServerTest { + private val validUuid = "f47ac10b-58cc-4372-a567-0e02b2c3d479" + private val esc = Char(0x1B).toString() + + // ─── attached ──────────────────────────────────────────────────────────────── + + @Test + fun `decodes attached and normalizes an uppercase uuid to lowercase`() { + // Arrange / Act / Assert + assertEquals( + ServerMessage.Attached(validUuid), + MessageCodec.decodeServer("""{"type":"attached","sessionId":"$validUuid"}"""), + ) + assertEquals( + ServerMessage.Attached(validUuid), + MessageCodec.decodeServer("""{"type":"attached","sessionId":"F47AC10B-58CC-4372-A567-0E02B2C3D479"}"""), + ) + } + + // ─── output ────────────────────────────────────────────────────────────────── + + @Test + fun `decodes output with ansi bytes verbatim into data`() { + // Arrange / Act + val decoded = MessageCodec.decodeServer("""{"type":"output","data":"\u001b[1;32mHello\u001b[0m"}""") + + // Assert + assertEquals(ServerMessage.Output(esc + "[1;32mHello" + esc + "[0m"), decoded) + } + + @Test + fun `ignores unknown top-level keys on a server frame`() { + // Arrange / Act / Assert — ignoreUnknownKeys tolerance + assertEquals( + ServerMessage.Output("hi"), + MessageCodec.decodeServer("""{"type":"output","data":"hi","extra":"ignored"}"""), + ) + } + + // ─── exit ──────────────────────────────────────────────────────────────────── + + @Test + fun `decodes exit with code only and with code plus reason`() { + // Arrange / Act / Assert + assertEquals(ServerMessage.Exit(0, null), MessageCodec.decodeServer("""{"type":"exit","code":0}""")) + + val spawnFail = MessageCodec.decodeServer("""{"type":"exit","code":-1,"reason":"spawn failed: /bin/badshell"}""") + assertEquals(ServerMessage.Exit(WireConstants.SPAWN_FAILED_EXIT_CODE, "spawn failed: /bin/badshell"), spawnFail) + } + + // ─── status ────────────────────────────────────────────────────────────────── + + @Test + fun `decodes every ClaudeStatus with safe defaults for detail pending gate`() { + for (raw in listOf("working", "waiting", "idle", "unknown", "stuck")) { + // Act + val decoded = MessageCodec.decodeServer("""{"type":"status","status":"$raw"}""") + + // Assert + val expected = ServerMessage.Status(ClaudeStatus.fromWire(raw)!!, null, false, null) + assertEquals(expected, decoded, "status=$raw") + } + } + + @Test + fun `decodes status carrying detail pending and a tool or plan gate`() { + assertEquals( + ServerMessage.Status(ClaudeStatus.WAITING, "Bash", true, GateKind.TOOL), + MessageCodec.decodeServer( + """{"type":"status","status":"waiting","detail":"Bash","pending":true,"gate":"tool"}""", + ), + ) + assertEquals( + ServerMessage.Status(ClaudeStatus.WAITING, null, true, GateKind.PLAN), + MessageCodec.decodeServer("""{"type":"status","status":"waiting","pending":true,"gate":"plan"}"""), + ) + } + + @Test + fun `tolerates an unknown gate value and a non-boolean pending`() { + // Unknown gate -> null but the pending signal is kept (frame not dropped). + assertEquals( + ServerMessage.Status(ClaudeStatus.WAITING, null, true, null), + MessageCodec.decodeServer("""{"type":"status","status":"waiting","pending":true,"gate":"alien"}"""), + ) + // Non-boolean pending -> safe default false. + assertEquals( + ServerMessage.Status(ClaudeStatus.WORKING, null, false, null), + MessageCodec.decodeServer("""{"type":"status","status":"working","pending":"yes"}"""), + ) + } + + // ─── telemetry ─────────────────────────────────────────────────────────────── + + @Test + fun `decodes a full telemetry sample field by field`() { + // Arrange + val frame = + "{\"type\":\"telemetry\",\"telemetry\":{" + + "\"contextUsedPct\":42.5,\"costUsd\":1.23," + + "\"linesAdded\":10,\"linesRemoved\":2," + + "\"model\":\"Fable 5\",\"effort\":\"high\"," + + "\"pr\":{\"number\":7,\"url\":\"https://github.com/x/y/pull/7\",\"reviewState\":\"APPROVED\"}," + + "\"rate\":{\"fiveHourPct\":12.5,\"sevenDayPct\":33},\"at\":1751600000000}}" + + // Act + val decoded = MessageCodec.decodeServer(frame) + + // Assert + val expected = ServerMessage.Telemetry( + StatusTelemetry( + contextUsedPct = 42.5, + costUsd = 1.23, + linesAdded = 10, + linesRemoved = 2, + model = "Fable 5", + effort = "high", + pr = PrInfo(7, "https://github.com/x/y/pull/7", "APPROVED"), + rate = RateInfo(12.5, 33.0), + at = 1_751_600_000_000L, + ), + ) + assertEquals(expected, decoded) + } + + @Test + fun `decodes telemetry with only the required at field`() { + assertEquals( + ServerMessage.Telemetry(StatusTelemetry(at = 123L)), + MessageCodec.decodeServer("""{"type":"telemetry","telemetry":{"at":123}}"""), + ) + } + + @Test + fun `tolerates wrong-typed and unknown optional telemetry fields`() { + // costUsd string + pr non-object are treated as absent; the frame is kept. + assertEquals( + ServerMessage.Telemetry(StatusTelemetry(at = 5L)), + MessageCodec.decodeServer("""{"type":"telemetry","telemetry":{"at":5,"costUsd":"lots","pr":42}}"""), + ) + // Unknown nested key ignored. + assertEquals( + ServerMessage.Telemetry(StatusTelemetry(at = 9L)), + MessageCodec.decodeServer("""{"type":"telemetry","telemetry":{"at":9,"mystery":true}}"""), + ) + } + + // ─── invalid frames -> null (never throws) ─────────────────────────────────── + + @Test + fun `drops every malformed server frame as null`() { + val invalid = listOf( + "not json at all", "", "null", "[]", "42", "true", + """{"type":"ping"}""", """{"data":"hello"}""", """{"type":42}""", + // attached: non-uuid / v1 / missing / wrong-typed (M7) + """{"type":"attached","sessionId":"abc123"}""", + """{"type":"attached","sessionId":"550e8400-e29b-11d4-a716-446655440000"}""", + """{"type":"attached"}""", + """{"type":"attached","sessionId":42}""", + // output: data missing / wrong-typed + """{"type":"output"}""", + """{"type":"output","data":42}""", + // exit: code missing / string / non-integer + """{"type":"exit"}""", + """{"type":"exit","code":"0"}""", + """{"type":"exit","code":1.5}""", + // status: status missing / unknown value / wrong-typed + """{"type":"status"}""", + """{"type":"status","status":"sleeping"}""", + """{"type":"status","status":42}""", + // telemetry: key missing / non-object / at missing / at wrong-typed + """{"type":"telemetry"}""", + """{"type":"telemetry","telemetry":"x"}""", + """{"type":"telemetry","telemetry":{"costUsd":1}}""", + """{"type":"telemetry","telemetry":{"at":"now"}}""", + ) + + for (frame in invalid) { + assertNull(MessageCodec.decodeServer(frame), "should drop: $frame") + } + } + + @Test + fun `treats client frame types as non-server frames`() { + val clientFrames = listOf( + ClientMessage.Attach(null), + ClientMessage.Input("x"), + ClientMessage.Resize(80, 24), + ClientMessage.Approve(ApproveMode.PLAN), + ClientMessage.Reject, + ) + for (m in clientFrames) { + assertNull(MessageCodec.decodeServer(MessageCodec.encode(m)), "client frame not a server frame: $m") + } + } + + // ─── fuzz: never throws on hostile input ───────────────────────────────────── + + @Test + fun `survives random byte fuzz without throwing`() { + val rng = Random(0xDEADBEEF) + repeat(300) { + val len = rng.nextInt(0, 81) + val bytes = ByteArray(len) { rng.nextInt(0, 256).toByte() } + val text = String(bytes, Charsets.UTF_8) + assertDoesNotThrow { MessageCodec.decodeServer(text) } + } + } + + @Test + fun `survives single-byte mutations of valid frames without throwing`() { + val rng = Random(0xFEEDFACE) + val seeds = listOf( + """{"type":"attached","sessionId":"$validUuid"}""", + """{"type":"output","data":"\u001b[1;32mHello\u001b[0m"}""", + """{"type":"exit","code":-1,"reason":"spawn failed"}""", + """{"type":"status","status":"waiting","pending":true,"gate":"plan"}""", + """{"type":"telemetry","telemetry":{"at":1751600000000,"costUsd":1.5}}""", + ) + for (seed in seeds) { + repeat(40) { + val bytes = seed.toByteArray(Charsets.UTF_8) + bytes[rng.nextInt(0, bytes.size)] = rng.nextInt(0, 256).toByte() + assertDoesNotThrow { MessageCodec.decodeServer(String(bytes, Charsets.UTF_8)) } + } + } + } +} diff --git a/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/MessageCodecEncodeTest.kt b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/MessageCodecEncodeTest.kt new file mode 100644 index 0000000..b21d0e4 --- /dev/null +++ b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/MessageCodecEncodeTest.kt @@ -0,0 +1,185 @@ +package wang.yaojia.webterm.wire + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test + +/** + * A4 golden vectors: [MessageCodec.encode] must be BYTE-IDENTICAL to the web client / + * server `JSON.stringify` for every [ClientMessage]. Expected strings transcribed from + * `src/protocol.ts` / `public/terminal-session.ts` and cross-checked against the iOS + * `CodecRoundtripTests` (same wire). Any drift here is a cross-implementation break. + * + * Control bytes are written as explicit `\u` escapes (never raw bytes) so the vectors stay + * copy-safe. + */ +class MessageCodecEncodeTest { + private val validUuid = "f47ac10b-58cc-4372-a567-0e02b2c3d479" + + @Test + fun `attach without sessionId still carries explicit sessionId null`() { + // Arrange / Act + val frame = MessageCodec.encode(ClientMessage.Attach(sessionId = null)) + + // Assert + assertEquals("""{"type":"attach","sessionId":null}""", frame) + } + + @Test + fun `attach with uuid serializes lowercased uuid string`() { + // Arrange / Act + val frame = MessageCodec.encode(ClientMessage.Attach(sessionId = validUuid)) + + // Assert + assertEquals("""{"type":"attach","sessionId":"$validUuid"}""", frame) + } + + @Test + fun `attach lowercases an uppercase uuid so the wire is canonical`() { + // Arrange / Act + val frame = MessageCodec.encode( + ClientMessage.Attach(sessionId = "F47AC10B-58CC-4372-A567-0E02B2C3D479"), + ) + + // Assert + assertEquals("""{"type":"attach","sessionId":"$validUuid"}""", frame) + } + + @Test + fun `attach cwd key appears only when present, after sessionId`() { + // Arrange / Act + val withCwd = MessageCodec.encode(ClientMessage.Attach(sessionId = null, cwd = "/Users/dev/proj")) + val withoutCwd = MessageCodec.encode(ClientMessage.Attach(sessionId = null)) + + // Assert + assertEquals("""{"type":"attach","sessionId":null,"cwd":"/Users/dev/proj"}""", withCwd) + assertFalse(withoutCwd.contains("cwd")) + } + + @Test + fun `input passes raw keyboard bytes through, escaping control bytes like JSON stringify`() { + // Arrange — Esc / Up / ^C / CR / Tab / Shift+Tab verbatim vector (test/protocol.test.ts) + val raw = "\u001B[A\u0003\r\t\u001B[Z" + + // Act + val frame = MessageCodec.encode(ClientMessage.Input(raw)) + + // Assert — ESC/^C -> \u001b / \u0003, CR -> \r, Tab -> \t + assertEquals("{\"type\":\"input\",\"data\":\"\\u001b[A\\u0003\\r\\t\\u001b[Z\"}", frame) + } + + @Test + fun `input escapes quote and backslash and control bytes exactly like JSON stringify`() { + // Arrange / Act + val frame = MessageCodec.encode(ClientMessage.Input("hi\r\u001B[A\"\\")) + + // Assert — byte-identical to the JS client's JSON.stringify output + assertEquals("{\"type\":\"input\",\"data\":\"hi\\r\\u001b[A\\\"\\\\\"}", frame) + } + + @Test + fun `input uses the JSON short escapes for backspace tab newline formfeed carriage-return`() { + // Arrange / Act + val frame = MessageCodec.encode(ClientMessage.Input("\u0008\u0009\u000A\u000C\u000D")) + + // Assert + assertEquals("{\"type\":\"input\",\"data\":\"\\b\\t\\n\\f\\r\"}", frame) + } + + @Test + fun `input long-form-escapes the other C0 control bytes as lowercase hex`() { + // Arrange — NUL / BEL / unit-separator have no short escape + val frame = MessageCodec.encode(ClientMessage.Input("\u0000\u0007\u001F")) + + // Assert + assertEquals("{\"type\":\"input\",\"data\":\"\\u0000\\u0007\\u001f\"}", frame) + } + + @Test + fun `input leaves ordinary and non-ascii characters unescaped`() { + // Arrange / Act — '/' is NOT escaped by JSON.stringify; unicode >= 0x20 passes through + val frame = MessageCodec.encode(ClientMessage.Input("a/b 日本語 é")) + + // Assert + assertEquals("""{"type":"input","data":"a/b 日本語 é"}""", frame) + } + + @Test + fun `attach escapes a sessionId containing a quote like JSON stringify`() { + // A canonical id is a UUID, but encode must stay TOTAL + byte-identical to JSON.stringify + // for ANY string — defence against JSON injection via an unescaped id (the encodeAttach fix). + val frame = MessageCodec.encode(ClientMessage.Attach(sessionId = "x\"y")) + + assertEquals("""{"type":"attach","sessionId":"x\"y"}""", frame) + } + + @Test + fun `attach escapes a sessionId containing a backslash like JSON stringify`() { + val frame = MessageCodec.encode(ClientMessage.Attach(sessionId = "a\\b")) + + // JSON output is {"type":"attach","sessionId":"a\\b"} + assertEquals("{\"type\":\"attach\",\"sessionId\":\"a\\\\b\"}", frame) + } + + @Test + fun `input escapes a lone high surrogate as lowercase u-escape like JSON stringify`() { + // node: JSON.stringify("\uD800x") === "\"\\ud800x\"" (ES2019 well-formed JSON.stringify) + val frame = MessageCodec.encode(ClientMessage.Input("\uD800x")) + + assertEquals("{\"type\":\"input\",\"data\":\"\\ud800x\"}", frame) + } + + @Test + fun `input escapes a lone low surrogate as lowercase u-escape`() { + val frame = MessageCodec.encode(ClientMessage.Input("\uDC00")) + + assertEquals("{\"type\":\"input\",\"data\":\"\\udc00\"}", frame) + } + + @Test + fun `input keeps a valid surrogate pair verbatim like JSON stringify`() { + // 😀 = U+1F600 = high D83D + low DE00; JSON.stringify emits the astral char unescaped. + val frame = MessageCodec.encode(ClientMessage.Input("😀")) + + assertEquals("{\"type\":\"input\",\"data\":\"😀\"}", frame) + } + + @Test + fun `resize emits bare integer cols and rows in order`() { + // Arrange / Act / Assert + assertEquals("""{"type":"resize","cols":120,"rows":40}""", MessageCodec.encode(ClientMessage.Resize(120, 40))) + assertEquals("""{"type":"resize","cols":1,"rows":1}""", MessageCodec.encode(ClientMessage.Resize(1, 1))) + assertEquals( + """{"type":"resize","cols":1000,"rows":1000}""", + MessageCodec.encode(ClientMessage.Resize(1000, 1000)), + ) + } + + @Test + fun `approve without mode is a bare frame, reject is a bare frame`() { + // Arrange / Act / Assert + assertEquals("""{"type":"approve"}""", MessageCodec.encode(ClientMessage.Approve())) + assertEquals("""{"type":"reject"}""", MessageCodec.encode(ClientMessage.Reject)) + } + + @Test + fun `approve mode is a top-level key with the server whitelist spelling`() { + // Arrange / Act / Assert — every ApproveMode wire value, mode at the top level + assertEquals( + """{"type":"approve","mode":"default"}""", + MessageCodec.encode(ClientMessage.Approve(ApproveMode.DEFAULT)), + ) + assertEquals( + """{"type":"approve","mode":"acceptEdits"}""", + MessageCodec.encode(ClientMessage.Approve(ApproveMode.ACCEPT_EDITS)), + ) + assertEquals( + """{"type":"approve","mode":"plan"}""", + MessageCodec.encode(ClientMessage.Approve(ApproveMode.PLAN)), + ) + assertEquals( + """{"type":"approve","mode":"auto"}""", + MessageCodec.encode(ClientMessage.Approve(ApproveMode.AUTO)), + ) + } +} diff --git a/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/ValidationTest.kt b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/ValidationTest.kt new file mode 100644 index 0000000..e80ee59 --- /dev/null +++ b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/ValidationTest.kt @@ -0,0 +1,91 @@ +package wang.yaojia.webterm.wire + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * A4 boundary-validation primitives. Session-id vectors are the M7 UUID-v4 set from + * `test/protocol.test.ts` / iOS `ServerVectorTests`; resize/cwd/type-whitelist mirror + * `src/protocol.ts`. + */ +class ValidationTest { + // ─── isValidSessionId (SESSION_ID_RE, M7) ──────────────────────────────────── + + @Test + fun `accepts valid uuid v4 including uppercase hex and variant 8`() { + assertTrue(Validation.isValidSessionId("f47ac10b-58cc-4372-a567-0e02b2c3d479")) + assertTrue(Validation.isValidSessionId("F47AC10B-58CC-4372-A567-0E02B2C3D479")) + assertTrue(Validation.isValidSessionId("550e8400-e29b-41d4-8716-446655440000")) + } + + @Test + fun `rejects non-uuid empty wrong-version wrong-variant and malformed ids`() { + val invalid = listOf( + "abc123", + "", + "550e8400-e29b-11d4-a716-446655440000", // version nibble = 1 (v1) + "f47ac10b-58cc-4372-c567-0e02b2c3d479", // variant 'c' (not 8/9/a/b) + "f47ac10b58cc4372a5670e02b2c3d479", // no separators + "f47ac10b-58cc-4372-a567-0e02b2c3d47", // one digit short + "g47ac10b-58cc-4372-a567-0e02b2c3d479", // illegal char 'g' + ) + for (candidate in invalid) { + assertFalse(Validation.isValidSessionId(candidate), "should reject: $candidate") + } + } + + @Test + fun `exposes the session-id regex for downstream reuse`() { + assertTrue(Validation.SESSION_ID_RE.matches("f47ac10b-58cc-4372-a567-0e02b2c3d479")) + assertFalse(Validation.SESSION_ID_RE.matches("nope")) + } + + // ─── isValidResize ─────────────────────────────────────────────────────────── + + @Test + fun `accepts resize dimensions on the inclusive 1 to 1000 boundary`() { + assertTrue(Validation.isValidResize(1, 1)) + assertTrue(Validation.isValidResize(1000, 1000)) + assertTrue(Validation.isValidResize(120, 40)) + } + + @Test + fun `rejects resize dimensions outside 1 to 1000`() { + assertFalse(Validation.isValidResize(0, 40)) + assertFalse(Validation.isValidResize(1001, 40)) + assertFalse(Validation.isValidResize(80, 0)) + assertFalse(Validation.isValidResize(80, 1001)) + } + + // ─── isAbsoluteCwd ─────────────────────────────────────────────────────────── + + @Test + fun `accepts absolute cwd and rejects relative or empty`() { + assertTrue(Validation.isAbsoluteCwd("/a")) + assertFalse(Validation.isAbsoluteCwd("a")) + assertFalse(Validation.isAbsoluteCwd("")) + } + + // ─── type whitelists ───────────────────────────────────────────────────────── + + @Test + fun `whitelists exactly the client frame types`() { + for (t in listOf("attach", "input", "resize", "approve", "reject")) { + assertTrue(Validation.isAllowedClientType(t), "client type: $t") + } + for (t in listOf("blur", "ping", "attached", "")) { + assertFalse(Validation.isAllowedClientType(t), "not a client type: $t") + } + } + + @Test + fun `whitelists exactly the server frame types`() { + for (t in listOf("attached", "output", "exit", "status", "telemetry")) { + assertTrue(Validation.isAllowedServerType(t), "server type: $t") + } + for (t in listOf("input", "blur", "approve", "")) { + assertFalse(Validation.isAllowedServerType(t), "not a server type: $t") + } + } +}