diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt index fc798da..84fe50d 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt @@ -2,10 +2,12 @@ package com.gatecontrol.android.ui.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.R import com.gatecontrol.android.data.LicenseRepository import com.gatecontrol.android.data.SetupRepository import com.gatecontrol.android.data.SettingsRepository import com.gatecontrol.android.network.ApiClientProvider +import com.gatecontrol.android.tunnel.WgConfigValidator import com.gatecontrol.android.network.UpdateCheckResponse import com.gatecontrol.android.common.Validation import org.json.JSONArray @@ -414,8 +416,12 @@ class SettingsViewModel @Inject constructor( val input = context.contentResolver.openInputStream(uri) val config = input?.bufferedReader()?.readText() ?: return@launch input.close() - if (!config.contains("[Interface]") || !config.contains("PrivateKey")) { - _uiState.update { it.copy(error = "Invalid WireGuard config file") } + val validation = WgConfigValidator.validate(config) + if (!validation.ok) { + Timber.w("importConfigFromUri rejected: %s", validation.errors.joinToString(", ")) + _uiState.update { + it.copy(error = context.getString(R.string.setup_invalid_config)) + } return@launch } setupRepository.saveWireGuardConfig(config) diff --git a/app/src/main/java/com/gatecontrol/android/ui/setup/SetupViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/setup/SetupViewModel.kt index cf05aa5..5ea65ae 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/setup/SetupViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/setup/SetupViewModel.kt @@ -2,9 +2,11 @@ package com.gatecontrol.android.ui.setup import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.R import com.gatecontrol.android.data.SetupRepository import com.gatecontrol.android.network.ApiClientProvider import com.gatecontrol.android.network.RegisterRequest +import com.gatecontrol.android.tunnel.WgConfigValidator import android.content.Context import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -184,9 +186,14 @@ class SetupViewModel @Inject constructor( fun importConfig(configText: String) { viewModelScope.launch { try { - if (configText.isBlank() || !configText.contains("[Interface]")) { + val validation = WgConfigValidator.validate(configText) + if (!validation.ok) { + Timber.w("importConfig rejected: %s", validation.errors.joinToString(", ")) _uiState.update { - it.copy(statusMessage = "Invalid WireGuard config", statusType = StatusType.ERROR) + it.copy( + statusMessage = context.getString(R.string.setup_invalid_config), + statusType = StatusType.ERROR, + ) } return@launch } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a99ed47..b9dbdab 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -182,6 +182,7 @@ Registriere… Erfolgreich registriert! Registrierung fehlgeschlagen: %1$s + Ungültige WireGuard-Konfiguration Protokolle diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7d09e40..068ea15 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -182,6 +182,7 @@ Registering… Successfully registered! Registration failed: %1$s + Invalid WireGuard config Logs diff --git a/app/src/test/java/com/gatecontrol/android/ui/setup/SetupViewModelTest.kt b/app/src/test/java/com/gatecontrol/android/ui/setup/SetupViewModelTest.kt index 01a3c84..5e5fd85 100644 --- a/app/src/test/java/com/gatecontrol/android/ui/setup/SetupViewModelTest.kt +++ b/app/src/test/java/com/gatecontrol/android/ui/setup/SetupViewModelTest.kt @@ -269,7 +269,7 @@ class SetupViewModelTest { @Test fun `importConfig saves valid WireGuard config`() = runTest { - val config = "[Interface]\nPrivateKey=abc\nAddress=10.0.0.1/32\n\n[Peer]\nPublicKey=xyz" + val config = "[Interface]\nPrivateKey = YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=\nAddress = 10.8.0.5/32\n\n[Peer]\nPublicKey = c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh5eiE=\nEndpoint = vpn.example.com:51820\nAllowedIPs = 0.0.0.0/0" viewModel.uiState.test { awaitItem() diff --git a/core/tunnel/build.gradle.kts b/core/tunnel/build.gradle.kts index ca77e14..41b45af 100644 --- a/core/tunnel/build.gradle.kts +++ b/core/tunnel/build.gradle.kts @@ -53,6 +53,10 @@ dependencies { testRuntimeOnly(libs.junit5.engine) testImplementation(libs.mockk) testImplementation(libs.coroutines.test) + // Lightweight JSON parser for loading the vendored WG-config golden fixtures + // in unit tests. No standalone JSON lib is in the version catalog and the + // org.json reference impl is dependency-free and JVM-friendly for unit tests. + testImplementation(libs.org.json) } kapt { diff --git a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelConfig.kt b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelConfig.kt index 4fdf99d..dd1a07e 100644 --- a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelConfig.kt +++ b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelConfig.kt @@ -38,6 +38,11 @@ data class TunnelConfig( fun parse(raw: String): TunnelConfig { require(raw.isNotBlank()) { "Config input must not be empty" } + val validation = WgConfigValidator.validate(raw) + require(validation.ok) { + "Invalid WireGuard config: ${validation.errors.joinToString(", ")}" + } + val lines = raw.lines() .map { it.trim() } .filter { it.isNotEmpty() && !it.startsWith("#") } diff --git a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/WgConfigValidator.kt b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/WgConfigValidator.kt new file mode 100644 index 0000000..5486583 --- /dev/null +++ b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/WgConfigValidator.kt @@ -0,0 +1,386 @@ +package com.gatecontrol.android.tunnel + +/** + * Syntactic validator for WireGuard / wg-quick configuration text. + * + * This is the Kotlin port of the canonical specification in + * `gatecontrol-config-hash/spec/wg-config/SPEC.md` and a 1:1 behavioral + * mirror of the JavaScript/TypeScript implementation + * (`src/wg-config-validator.ts`). Both implementations are exercised against + * the same golden fixtures, so they MUST behave identically: same + * normalization order, same error codes, same regexes/semantics. + * + * Pure string/regex parsing only: NO Android APIs, NO I/O, NO network. The + * validator is fail-closed and never throws on malformed input — every + * structural problem is reported through `errors`. + * + * See SPEC.md for the normative rules. Error codes are a stable contract. + */ + +/** + * Public result type returned by [WgConfigValidator.validate]. + * + * Result of validating a WireGuard config (see SPEC §1). + */ +data class WgValidationResult( + /** true iff errors.isEmpty(). Nothing else may set this. */ + val ok: Boolean, + /** Error codes (see SPEC §4); presence makes ok == false. */ + val errors: List, + /** Warning codes (see SPEC §4); NEVER affect ok. */ + val warnings: List, +) + +object WgConfigValidator { + + /** WG-base64 key material: exactly 43 base64 chars + one '=' pad. */ + private val WG_BASE64 = Regex("^[A-Za-z0-9+/]{43}=$") + + /** Optionally-signed base-10 integer string. */ + private val INT_RE = Regex("^[+-]?\\d+$") + + /** IPv4 octet shape: 1..3 decimal digits. */ + private val IPV4_OCTET = Regex("^\\d{1,3}$") + + /** IPv6 group shape: 1..4 hex digits. */ + private val IPV6_GROUP = Regex("^[0-9A-Fa-f]{1,4}$") + + /** Known [Interface] keys (canonical WireGuard spelling, case-sensitive). */ + private val INTERFACE_KEYS = setOf( + "PrivateKey", + "Address", + "DNS", + "ListenPort", + "MTU", + ) + + /** Known [Peer] keys (canonical WireGuard spelling, case-sensitive). */ + private val PEER_KEYS = setOf( + "PublicKey", + "PresharedKey", + "Endpoint", + "AllowedIPs", + "PersistentKeepalive", + ) + + /** A parsed config section. */ + private data class Section( + /** Verbatim header name without brackets, e.g. "Interface". */ + val name: String, + /** Parsed key/value pairs in order of appearance. */ + val pairs: MutableList> = mutableListOf(), + ) + + private fun isWgBase64(s: String): Boolean = WG_BASE64.matches(s) + + /** Optionally-signed base-10 integer, optional inclusive range. */ + private fun isInt(s: String, min: Long? = null, max: Long? = null): Boolean { + if (!INT_RE.matches(s)) return false + if (min == null && max == null) return true + // toLongOrNull is safe and finite; mirrors JS Number()/Number.isFinite() + // for the integer strings INT_RE admits (range checks only use small bounds). + val n = s.toLongOrNull() ?: return false + if (min != null && n < min) return false + if (max != null && n > max) return false + return true + } + + /** IPv4 dotted-quad, each octet 0..255. */ + private fun isIpv4(s: String): Boolean { + val parts = s.split(".") + if (parts.size != 4) return false + for (p in parts) { + if (!IPV4_OCTET.matches(p)) return false + val n = p.toInt() + if (n < 0 || n > 255) return false + } + return true + } + + /** IPv6 literal. Supports `::` compression and embedded IPv4 tails. */ + private fun isIpv6(s: String): Boolean { + if (s.isEmpty()) return false + // At most one '::' compression marker. + val doubleColonCount = Regex("::").findAll(s).count() + if (doubleColonCount > 1) return false + + // An embedded IPv4 tail (e.g. ::ffff:1.2.3.4) counts as two groups. + var head = s + var tailGroups = 0 + val lastColon = head.lastIndexOf(':') + if (lastColon != -1 && head.substring(lastColon + 1).contains('.')) { + val v4 = head.substring(lastColon + 1) + if (!isIpv4(v4)) return false + tailGroups = 2 + head = head.substring(0, lastColon + 1) // keep trailing ':' for split logic + } + + if (doubleColonCount == 1) { + // JS String.split('::') yields exactly [left, right] for a single occurrence. + val sepIdx = head.indexOf("::") + val left = head.substring(0, sepIdx) + val right = head.substring(sepIdx + 2) + val leftGroups = if (left == "") emptyList() else left.split(":") + val rightGroups = if (right == "") emptyList() else right.split(":") + for (g in leftGroups + rightGroups) { + if (!IPV6_GROUP.matches(g)) return false + } + val total = leftGroups.size + rightGroups.size + tailGroups + // '::' must compress at least one group, so total groups < 8. + return total <= 7 + } + + // No compression: must be exactly 8 groups total. + val trimmed = if (head.endsWith(":")) head.substring(0, head.length - 1) else head + val groups = if (trimmed == "") emptyList() else trimmed.split(":") + for (g in groups) { + if (!IPV6_GROUP.matches(g)) return false + } + return groups.size + tailGroups == 8 + } + + /** A bare IP literal (v4 or v6), with NO prefix. */ + private fun isIp(s: String): Boolean = isIpv4(s) || isIpv6(s) + + /** CIDR: `ip/prefix` (v4 0..32, v6 0..128). A bare IP is NOT a CIDR. */ + private fun isCidr(s: String): Boolean { + val slash = s.lastIndexOf('/') + if (slash == -1) return false + val ip = s.substring(0, slash) + val prefix = s.substring(slash + 1) + if (!INT_RE.matches(prefix)) return false + val p = prefix.toLongOrNull() ?: return false + if (isIpv4(ip)) return p in 0..32 + if (isIpv6(ip)) return p in 0..128 + return false + } + + /** Comma-separated list; each trimmed entry must satisfy [pred]. Non-empty. */ + private fun isList(value: String, pred: (String) -> Boolean): Boolean { + if (value.isBlank()) return false + return value.split(",").map { it.trim() }.all { it.isNotEmpty() && pred(it) } + } + + /** host:port — split on the LAST ':'; host non-empty, port int 1..65535. */ + private fun isHostPort(value: String): Boolean { + val idx = value.lastIndexOf(':') + if (idx == -1) return false + val host = value.substring(0, idx) + val port = value.substring(idx + 1) + if (host.isEmpty()) return false + return isInt(port, 1, 65535) + } + + /** + * Normalize raw input per SPEC §2 (BOM strip, line-ending normalize, split, + * comment strip, trim, drop blank lines), then parse into ordered sections. + */ + private fun parseSections(text: String?): List
{ + // 1. Strip BOM. Coerce null to empty so we never throw (SPEC §1). + var s = text ?: "" + if (s.isNotEmpty() && s[0].code == 0xFEFF) { + s = s.substring(1) + } + // 2. Normalize line endings (\r\n and lone \r → \n). + s = s.replace("\r\n", "\n").replace("\r", "\n") + // 3. Split into lines. + val rawLines = s.split("\n") + + val sections = mutableListOf
() + var current: Section? = null + + for (raw in rawLines) { + // 4. Strip comment: first '#' or ';' to end of line. + var line = raw + val hashIdx = line.indexOf('#') + val semiIdx = line.indexOf(';') + val cut = when { + hashIdx != -1 && semiIdx != -1 -> minOf(hashIdx, semiIdx) + hashIdx != -1 -> hashIdx + semiIdx != -1 -> semiIdx + else -> -1 + } + if (cut != -1) line = line.substring(0, cut) + + // 5. Trim. + line = line.trim() + + // 6. Ignore blank lines. + if (line.isEmpty()) continue + + // Section header? + if (line.startsWith("[") && line.endsWith("]")) { + val name = line.substring(1, line.length - 1).trim() + current = Section(name) + sections.add(current) + continue + } + + // Key = Value (split on first '='). + val eq = line.indexOf('=') + if (eq == -1) { + // Lines before the first header are ignored; the server emits none. + // A non-pair line inside a section is not specified — skip it. + continue + } + val key = line.substring(0, eq).trim() + val value = line.substring(eq + 1).trim() + // Pairs before the first header are ignored. + current?.pairs?.add(key to value) + } + + return sections + } + + private fun validateInterface( + section: Section, + errors: MutableList, + warnings: MutableList, + ) { + val values = LinkedHashMap() + for ((key, value) in section.pairs) { + if (key in INTERFACE_KEYS) { + values[key] = value + } else { + warnings.add("unknown_key:$key") + } + } + + // PrivateKey (required, WG-base64). + val privateKey = values["PrivateKey"] + if (privateKey == null) { + errors.add("iface_privatekey_missing") + } else if (!isWgBase64(privateKey)) { + errors.add("iface_privatekey_format") + } + + // Address (required, CIDR list). + val address = values["Address"] + if (address == null) { + errors.add("iface_address_missing") + } else if (!isList(address, ::isCidr)) { + errors.add("iface_address_cidr") + } + + // DNS (optional, IP list). + val dns = values["DNS"] + if (dns != null && !isList(dns, ::isIp)) { + errors.add("iface_dns_ip") + } + + // ListenPort / MTU (optional, integer). + val listenPort = values["ListenPort"] + if (listenPort != null && !isInt(listenPort)) { + errors.add("iface_int") + } + val mtu = values["MTU"] + if (mtu != null && !isInt(mtu)) { + errors.add("iface_int") + } + } + + private fun validatePeer( + section: Section, + errors: MutableList, + warnings: MutableList, + ) { + val values = LinkedHashMap() + for ((key, value) in section.pairs) { + if (key in PEER_KEYS) { + values[key] = value + } else { + warnings.add("unknown_key:$key") + } + } + + // PublicKey (required, WG-base64). + val publicKey = values["PublicKey"] + if (publicKey == null) { + errors.add("peer_publickey_missing") + } else if (!isWgBase64(publicKey)) { + errors.add("peer_publickey_format") + } + + // PresharedKey (optional, WG-base64). + val psk = values["PresharedKey"] + if (psk != null && !isWgBase64(psk)) { + errors.add("peer_psk_format") + } + + // Endpoint (required, host:port). + val endpoint = values["Endpoint"] + if (endpoint == null) { + errors.add("peer_endpoint_missing") + } else if (!isHostPort(endpoint)) { + errors.add("peer_endpoint_format") + } + + // AllowedIPs (required, CIDR list). + val allowedIps = values["AllowedIPs"] + if (allowedIps == null) { + errors.add("peer_allowedips_missing") + } else if (!isList(allowedIps, ::isCidr)) { + errors.add("peer_allowedips_cidr") + } + + // PersistentKeepalive (optional, integer 0..65535). + val keepalive = values["PersistentKeepalive"] + if (keepalive != null && !isInt(keepalive, 0, 65535)) { + errors.add("peer_keepalive") + } + } + + /** + * Validate WireGuard / wg-quick config text for syntactic well-formedness. + * + * Fail-closed: never throws; all problems reported via `errors`. Warnings + * never affect `ok`. There is intentionally NO `trusted` flag and no options. + * Accepts null (treated as empty config) per SPEC §1. + */ + fun validate(text: String?): WgValidationResult { + val errors = mutableListOf() + val warnings = mutableListOf() + + val sections = parseSections(text) + + var interfaceCount = 0 + var peerCount = 0 + + for (section in sections) { + when (section.name) { + "Interface" -> { + interfaceCount += 1 + validateInterface(section, errors, warnings) + } + "Peer" -> { + peerCount += 1 + validatePeer(section, errors, warnings) + } + else -> { + // SPEC §3: unknown sections use the same 'unknown_key:' prefix as unknown field keys + warnings.add("unknown_key:${section.name}") + } + } + } + + // Exactly one [Interface]. + if (interfaceCount != 1) { + errors.add("interface_count") + } + // At least one [Peer]. + if (peerCount == 0) { + errors.add("no_peer") + } + + // Deduplicate (order-preserving) so a code appears at most once; this keeps + // the JS and Kotlin ports deterministic and identical (SPEC §1). + val uniqueErrors = errors.distinct() + val uniqueWarnings = warnings.distinct() + + return WgValidationResult( + ok = uniqueErrors.isEmpty(), + errors = uniqueErrors, + warnings = uniqueWarnings, + ) + } +} diff --git a/core/tunnel/src/test/java/com/gatecontrol/android/tunnel/TunnelConfigTest.kt b/core/tunnel/src/test/java/com/gatecontrol/android/tunnel/TunnelConfigTest.kt index c5da98f..e3b5e7e 100644 --- a/core/tunnel/src/test/java/com/gatecontrol/android/tunnel/TunnelConfigTest.kt +++ b/core/tunnel/src/test/java/com/gatecontrol/android/tunnel/TunnelConfigTest.kt @@ -14,8 +14,8 @@ class TunnelConfigTest { MTU = 1420 [Peer] - PublicKey = c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh4eA== - PresharedKey = cHJlc2hhcmVka2V5YmFzZTY0ZW5jb2RlZHh4eHh4eA== + PublicKey = c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh5eiE= + PresharedKey = cHJlc2hhcmVka2V5YmFzZTY0ZW5jb2RlZHh4eHh4eCE= Endpoint = vpn.example.com:51820 AllowedIPs = 0.0.0.0/0 PersistentKeepalive = 25 @@ -35,8 +35,8 @@ class TunnelConfigTest { fun `parse extracts peer fields`() { val config = TunnelConfig.parse(validConfig) - assertEquals("c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh4eA==", config.publicKey) - assertEquals("cHJlc2hhcmVka2V5YmFzZTY0ZW5jb2RlZHh4eHh4eA==", config.presharedKey) + assertEquals("c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh5eiE=", config.publicKey) + assertEquals("cHJlc2hhcmVka2V5YmFzZTY0ZW5jb2RlZHh4eHh4eCE=", config.presharedKey) assertEquals("vpn.example.com:51820", config.endpoint) assertEquals("0.0.0.0/0", config.allowedIps) assertEquals(25, config.persistentKeepalive) @@ -50,7 +50,7 @@ class TunnelConfigTest { Address = 10.8.0.5/32 [Peer] - PublicKey = c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh4eA== + PublicKey = c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh5eiE= Endpoint = vpn.example.com:51820 AllowedIPs = 0.0.0.0/0 """.trimIndent() @@ -74,7 +74,7 @@ class TunnelConfigTest { assertTrue(output.contains("DNS = 1.1.1.1, 8.8.8.8")) assertTrue(output.contains("MTU = 1420")) assertTrue(output.contains("[Peer]")) - assertTrue(output.contains("PublicKey = c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh4eA==")) + assertTrue(output.contains("PublicKey = c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh5eiE=")) assertTrue(output.contains("Endpoint = vpn.example.com:51820")) assertTrue(output.contains("AllowedIPs = 0.0.0.0/0")) assertTrue(output.contains("PersistentKeepalive = 25")) @@ -94,7 +94,7 @@ class TunnelConfigTest { Address = 10.8.0.5/32 [Peer] - PublicKey = c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh4eA== + PublicKey = c2VydmVycHVibGlja2V5YmFzZTY0ZW5jb2RlZHh5eiE= Endpoint = vpn.example.com:51820 AllowedIPs = 0.0.0.0/0 """.trimIndent() diff --git a/core/tunnel/src/test/java/com/gatecontrol/android/tunnel/WgConfigValidatorTest.kt b/core/tunnel/src/test/java/com/gatecontrol/android/tunnel/WgConfigValidatorTest.kt new file mode 100644 index 0000000..b00adc0 --- /dev/null +++ b/core/tunnel/src/test/java/com/gatecontrol/android/tunnel/WgConfigValidatorTest.kt @@ -0,0 +1,197 @@ +package com.gatecontrol.android.tunnel + +import org.json.JSONObject +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.DynamicTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestFactory +import java.io.File +import java.security.MessageDigest + +/** + * Conformance tests for [WgConfigValidator], the Kotlin 1:1 port of the + * canonical JS validator. Both are exercised against the SAME golden fixtures, + * vendored into test resources at `wg-config-fixtures/`. + * + * Includes an integrity-hash assertion that detects local corruption of the + * vendored fixture copy. + */ +class WgConfigValidatorTest { + + private companion object { + const val FIXTURES_DIR = "wg-config-fixtures" + + /** A parsed fixture per SPEC §7. */ + data class Fixture( + val name: String, + val config: String, + val expect: String, + val errorContains: String?, + ) + + /** + * Resolve the vendored fixtures directory on disk. We need the real + * directory (not just classpath streams) so we can enumerate the + * *.json files and read their RAW bytes for the integrity hash exactly + * the way the sync script does. + */ + fun fixturesDir(): File { + val classLoader = checkNotNull(WgConfigValidatorTest::class.java.classLoader) { + "No classloader available to locate test resources" + } + val url = checkNotNull(classLoader.getResource(FIXTURES_DIR)) { + "Fixtures resource directory '$FIXTURES_DIR' not found on test classpath" + } + return File(url.toURI()) + } + + /** All *.json fixture files, sorted ascending by filename. */ + fun fixtureJsonFiles(): List = + fixturesDir() + .listFiles { f -> f.isFile && f.name.endsWith(".json") } + ?.sortedBy { it.name } + ?: error("No fixture files found in $FIXTURES_DIR") + + fun parseFixture(file: File): Fixture { + val json = JSONObject(file.readText(Charsets.UTF_8)) + return Fixture( + name = json.getString("name"), + config = json.getString("config"), + expect = json.getString("expect"), + errorContains = if (json.has("errorContains")) json.getString("errorContains") else null, + ) + } + + fun loadFixtures(): List = fixtureJsonFiles().map { parseFixture(it) } + } + + @TestFactory + fun `fixtures conform to the validator contract`(): List { + val fixtures = loadFixtures() + // Sanity: at least one fixture must load. The .fixtures-hash integrity + // test guards sync completeness; a magic count here breaks confusingly + // whenever a fixture is added. + assertTrue(fixtures.isNotEmpty(), "No fixtures loaded") + return fixtures.map { fx -> + DynamicTest.dynamicTest(fx.name) { + val result = WgConfigValidator.validate(fx.config) + val expectValid = fx.expect == "valid" + assertEquals( + expectValid, + result.ok, + "Fixture '${fx.name}': expected ok=$expectValid but was ${result.ok} " + + "(errors=${result.errors}, warnings=${result.warnings})", + ) + if (!expectValid && fx.errorContains != null) { + assertTrue( + result.errors.contains(fx.errorContains), + "Fixture '${fx.name}': expected errors to contain " + + "'${fx.errorContains}' but errors=${result.errors}", + ) + } + } + } + } + + @Test + fun `unknown key produces warning and stays ok`() { + val config = buildString { + appendLine("[Interface]") + appendLine("PrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=") + appendLine("Address = 10.8.0.2/32") + appendLine("SomeMadeUpKey = whatever") + appendLine() + appendLine("[Peer]") + appendLine("PublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=") + appendLine("Endpoint = gate.example.com:51820") + appendLine("AllowedIPs = 0.0.0.0/0") + } + val result = WgConfigValidator.validate(config) + assertTrue(result.ok, "Unknown key must not affect ok; errors=${result.errors}") + assertTrue( + result.warnings.any { it.startsWith("unknown_key:") }, + "Expected an unknown_key warning; warnings=${result.warnings}", + ) + assertTrue(result.warnings.contains("unknown_key:SomeMadeUpKey")) + } + + @Test + fun `unknown section produces warning and stays ok`() { + val config = buildString { + appendLine("[Interface]") + appendLine("PrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=") + appendLine("Address = 10.8.0.2/32") + appendLine() + appendLine("[Peer]") + appendLine("PublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=") + appendLine("Endpoint = gate.example.com:51820") + appendLine("AllowedIPs = 0.0.0.0/0") + appendLine() + appendLine("[CustomSection]") + appendLine("Foo = bar") + } + val result = WgConfigValidator.validate(config) + assertTrue(result.ok, "Unknown section must not affect ok; errors=${result.errors}") + // SPEC §4: bare section name, no brackets. + assertTrue(result.warnings.contains("unknown_key:CustomSection")) + } + + @Test + fun `null input is not ok and does not throw`() { + val result = WgConfigValidator.validate(null) + assertFalse(result.ok) + // Empty config: no interface and no peer. + assertTrue(result.errors.contains("interface_count")) + assertTrue(result.errors.contains("no_peer")) + } + + @Test + fun `blank input is not ok and does not throw`() { + val result = WgConfigValidator.validate(" \n \r\n \t ") + assertFalse(result.ok) + assertTrue(result.errors.contains("interface_count")) + assertTrue(result.errors.contains("no_peer")) + } + + @Test + fun `iface_int appears exactly once when both ListenPort and MTU are bad`() { + val fx = loadFixtures().first { it.name == "invalid_iface_int" } + val result = WgConfigValidator.validate(fx.config) + assertFalse(result.ok) + assertEquals( + 1, + result.errors.count { it == "iface_int" }, + "iface_int must be deduplicated to a single occurrence; errors=${result.errors}", + ) + } + + @Test + fun `vendored fixtures integrity hash matches`() { + // Recompute the hash exactly the way scripts/sync-wg-fixtures.sh does: + // cat $(ls -1 *.json | sort) | sha256sum + // i.e. concatenate the RAW BYTES of the *.json files (ONLY .json, + // NOT .fixtures-hash / VENDORED.md) in ascending filename order, then + // SHA-256, hex-encoded. + val jsonFiles = fixtureJsonFiles() + val digest = MessageDigest.getInstance("SHA-256") + for (f in jsonFiles) { + digest.update(f.readBytes()) + } + val computed = digest.digest().joinToString("") { "%02x".format(it) } + + val hashFile = File(fixturesDir(), ".fixtures-hash") + assertTrue(hashFile.isFile, ".fixtures-hash must exist in vendored fixtures") + val expected = hashFile.readText(Charsets.UTF_8).trim() + + assertNotNull(expected) + assertEquals( + expected, + computed, + "Vendored fixture integrity hash mismatch — the vendored copy was " + + "locally corrupted (or .fixtures-hash is stale).", + ) + } +} diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/.fixtures-hash b/core/tunnel/src/test/resources/wg-config-fixtures/.fixtures-hash new file mode 100644 index 0000000..82c247b --- /dev/null +++ b/core/tunnel/src/test/resources/wg-config-fixtures/.fixtures-hash @@ -0,0 +1 @@ +314336eb25d68050d061f9a978dae0042b5c17573c19d211d0ab8cad6e6ba18a diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/VENDORED.md b/core/tunnel/src/test/resources/wg-config-fixtures/VENDORED.md new file mode 100644 index 0000000..3606ba2 --- /dev/null +++ b/core/tunnel/src/test/resources/wg-config-fixtures/VENDORED.md @@ -0,0 +1,17 @@ +# Vendored WG-config golden fixtures + +These fixtures are a vendored copy of +`gatecontrol-config-hash/spec/wg-config/fixtures`, synced via +`scripts/sync-wg-fixtures.sh`. Do NOT edit directly. + +The `.fixtures-hash` file is asserted by `WgConfigValidatorTest`; a +mismatch means the vendored copy was locally corrupted. + +NOTE: this hash only detects local corruption of the vendored copy — it +does NOT detect a forgotten re-sync after the canonical fixtures change +(the copy + hash would both be stale-but-consistent). The only real guard +against a forgotten sync is the release checklist +(see `gatecontrol-config-hash/spec/wg-config/SYNC.md`). + +- Integrity hash: `314336eb25d68050d061f9a978dae0042b5c17573c19d211d0ab8cad6e6ba18a` +- Last synced (UTC): 2026-05-31 diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/invalid_bad_cidr.json b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_bad_cidr.json new file mode 100644 index 0000000..4008cba --- /dev/null +++ b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_bad_cidr.json @@ -0,0 +1,6 @@ +{ + "name": "invalid_bad_cidr", + "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.2/32\nDNS = 10.8.0.1\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nEndpoint = gate.example.com:51820\nAllowedIPs = 10.0.0.0\n", + "expect": "invalid", + "errorContains": "peer_allowedips_cidr" +} diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/invalid_bad_endpoint.json b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_bad_endpoint.json new file mode 100644 index 0000000..0b14e33 --- /dev/null +++ b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_bad_endpoint.json @@ -0,0 +1,6 @@ +{ + "name": "invalid_bad_endpoint", + "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.2/32\nDNS = 10.8.0.1\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nEndpoint = gate.example.com\nAllowedIPs = 0.0.0.0/0\n", + "expect": "invalid", + "errorContains": "peer_endpoint_format" +} diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/invalid_bad_key.json b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_bad_key.json new file mode 100644 index 0000000..85a5dda --- /dev/null +++ b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_bad_key.json @@ -0,0 +1,6 @@ +{ + "name": "invalid_bad_key", + "config": "[Interface]\nPrivateKey = tooshortkey\nAddress = 10.8.0.2/32\nDNS = 10.8.0.1\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nEndpoint = gate.example.com:51820\nAllowedIPs = 0.0.0.0/0\n", + "expect": "invalid", + "errorContains": "iface_privatekey_format" +} diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/invalid_iface_int.json b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_iface_int.json new file mode 100644 index 0000000..1e5749a --- /dev/null +++ b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_iface_int.json @@ -0,0 +1,6 @@ +{ + "name": "invalid_iface_int", + "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.2/32\nListenPort = notanint\nMTU = alsobad\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nEndpoint = gate.example.com:51820\nAllowedIPs = 0.0.0.0/0\n", + "expect": "invalid", + "errorContains": "iface_int" +} diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/invalid_no_interface.json b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_no_interface.json new file mode 100644 index 0000000..464df31 --- /dev/null +++ b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_no_interface.json @@ -0,0 +1,6 @@ +{ + "name": "invalid_no_interface", + "config": "[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nEndpoint = gate.example.com:51820\nAllowedIPs = 0.0.0.0/0\n", + "expect": "invalid", + "errorContains": "interface_count" +} diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/invalid_two_interface.json b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_two_interface.json new file mode 100644 index 0000000..bd50e5c --- /dev/null +++ b/core/tunnel/src/test/resources/wg-config-fixtures/invalid_two_interface.json @@ -0,0 +1,6 @@ +{ + "name": "invalid_two_interface", + "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.2/32\n\n[Interface]\nPrivateKey = T2UTKYFhz1rNVYXSguzk4cMyiW3Zkinvd3SM9hVICDg=\nAddress = 10.8.0.3/32\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nEndpoint = gate.example.com:51820\nAllowedIPs = 0.0.0.0/0\n", + "expect": "invalid", + "errorContains": "interface_count" +} diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/valid_crlf.json b/core/tunnel/src/test/resources/wg-config-fixtures/valid_crlf.json new file mode 100644 index 0000000..74c5676 --- /dev/null +++ b/core/tunnel/src/test/resources/wg-config-fixtures/valid_crlf.json @@ -0,0 +1,5 @@ +{ + "name": "valid_crlf", + "config": "[Interface]\r\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\r\nAddress = 10.8.0.2/32\r\nDNS = 10.8.0.1\r\n\r\n[Peer]\r\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\r\nEndpoint = gate.example.com:51820\r\nAllowedIPs = 0.0.0.0/0\r\n", + "expect": "valid" +} diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/valid_ipv6.json b/core/tunnel/src/test/resources/wg-config-fixtures/valid_ipv6.json new file mode 100644 index 0000000..50fede2 --- /dev/null +++ b/core/tunnel/src/test/resources/wg-config-fixtures/valid_ipv6.json @@ -0,0 +1,5 @@ +{ + "name": "valid_ipv6", + "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.2/32,fd00:8::2/128\nDNS = 10.8.0.1\nMTU = 1420\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nPresharedKey = /ai8/tDjM7oi2ITdt1ydLIWFHA5HJN8GCSzeFHCOhvc=\nEndpoint = gate.example.com:51820\nAllowedIPs = 0.0.0.0/0,::/0\nPersistentKeepalive = 25\n", + "expect": "valid" +} diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/valid_minimal.json b/core/tunnel/src/test/resources/wg-config-fixtures/valid_minimal.json new file mode 100644 index 0000000..37dce8e --- /dev/null +++ b/core/tunnel/src/test/resources/wg-config-fixtures/valid_minimal.json @@ -0,0 +1,5 @@ +{ + "name": "valid_minimal", + "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.2/32\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nEndpoint = gate.example.com:51820\nAllowedIPs = 0.0.0.0/0\n", + "expect": "valid" +} diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_basic.json b/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_basic.json new file mode 100644 index 0000000..f766df0 --- /dev/null +++ b/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_basic.json @@ -0,0 +1,5 @@ +{ + "name": "valid_server_basic", + "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.2/32\nDNS = 10.8.0.1\nMTU = 1420\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nPresharedKey = /ai8/tDjM7oi2ITdt1ydLIWFHA5HJN8GCSzeFHCOhvc=\nEndpoint = gate.example.com:51820\nAllowedIPs = 0.0.0.0/0\nPersistentKeepalive = 25\n", + "expect": "valid" +} diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_internal_dns.json b/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_internal_dns.json new file mode 100644 index 0000000..01ea902 --- /dev/null +++ b/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_internal_dns.json @@ -0,0 +1,5 @@ +{ + "name": "valid_server_internal_dns", + "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.2/32\nDNS = 10.8.0.1,1.1.1.1\nMTU = 1420\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nPresharedKey = /ai8/tDjM7oi2ITdt1ydLIWFHA5HJN8GCSzeFHCOhvc=\nEndpoint = gate.example.com:51820\nAllowedIPs = 0.0.0.0/0\nPersistentKeepalive = 25\n", + "expect": "valid" +} diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_multi_peer.json b/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_multi_peer.json new file mode 100644 index 0000000..9afd0bb --- /dev/null +++ b/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_multi_peer.json @@ -0,0 +1,5 @@ +{ + "name": "valid_server_multi_peer", + "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.2/32\nDNS = 10.8.0.1\nMTU = 1420\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nPresharedKey = /ai8/tDjM7oi2ITdt1ydLIWFHA5HJN8GCSzeFHCOhvc=\nEndpoint = gate.example.com:51820\nAllowedIPs = 10.8.0.0/24\nPersistentKeepalive = 25\n\n[Peer]\nPublicKey = wlj4+qAbkzqFiIfYX3eDL5Tf02F9DVmfapEMRrmTbVs=\nEndpoint = gate2.example.com:51820\nAllowedIPs = 10.8.0.3/32\nPersistentKeepalive = 25\n", + "expect": "valid" +} diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_rdp_gateway.json b/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_rdp_gateway.json new file mode 100644 index 0000000..944a231 --- /dev/null +++ b/core/tunnel/src/test/resources/wg-config-fixtures/valid_server_rdp_gateway.json @@ -0,0 +1,5 @@ +{ + "name": "valid_server_rdp_gateway", + "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.5/32\nDNS = 10.8.0.1\nMTU = 1420\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nPresharedKey = /ai8/tDjM7oi2ITdt1ydLIWFHA5HJN8GCSzeFHCOhvc=\nEndpoint = gate.example.com:51820\nAllowedIPs = 10.8.0.1/32\nPersistentKeepalive = 25\n", + "expect": "valid" +} diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/valid_unknown_keys.json b/core/tunnel/src/test/resources/wg-config-fixtures/valid_unknown_keys.json new file mode 100644 index 0000000..23ec359 --- /dev/null +++ b/core/tunnel/src/test/resources/wg-config-fixtures/valid_unknown_keys.json @@ -0,0 +1,5 @@ +{ + "name": "valid_unknown_keys", + "config": "[Interface]\nPrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI=\nAddress = 10.8.0.2/32\nDNS = 10.8.0.1\nSomeMadeUpKey = whatever\n\n[Peer]\nPublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nEndpoint = gate.example.com:51820\nAllowedIPs = 0.0.0.0/0\n", + "expect": "valid" +} diff --git a/core/tunnel/src/test/resources/wg-config-fixtures/valid_whitespace.json b/core/tunnel/src/test/resources/wg-config-fixtures/valid_whitespace.json new file mode 100644 index 0000000..aca1d59 --- /dev/null +++ b/core/tunnel/src/test/resources/wg-config-fixtures/valid_whitespace.json @@ -0,0 +1,5 @@ +{ + "name": "valid_whitespace", + "config": "[Interface]\n PrivateKey = CyeI87ssPVm18g0yRG9AZV0vdIe9qtkKvFKsOlTCTHI= \nAddress = 10.8.0.2/32 # the tunnel address\nDNS = 10.8.0.1\n\n[Peer]\n PublicKey = 86R0I45ZRx/P7WQdj+GkW+q0+MU0cS4Zccy+CVTTvY4=\nEndpoint = gate.example.com:51820 # server endpoint\n AllowedIPs = 0.0.0.0/0\n", + "expect": "valid" +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2dfd4f3..660da05 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ junit5 = "5.10.2" mockk = "1.13.11" turbine = "1.1.0" robolectric = "4.12.2" +org-json = "20240303" [libraries] # AndroidX Core @@ -89,6 +90,7 @@ mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +org-json = { group = "org.json", name = "json", version.ref = "org-json" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }