diff --git a/services/diagnostics-api/wrangler.jsonc b/services/diagnostics-api/wrangler.jsonc index 34398d4..665477c 100644 --- a/services/diagnostics-api/wrangler.jsonc +++ b/services/diagnostics-api/wrangler.jsonc @@ -2,6 +2,8 @@ "$schema": "./node_modules/wrangler/config-schema.json", "name": "vnidrop-diagnostics", "main": "src/index.ts", + "workers_dev": true, + "preview_urls": false, "compatibility_date": "2026-07-14", "compatibility_flags": ["nodejs_compat"], "upload_source_maps": true, @@ -16,8 +18,7 @@ { "binding": "DB", "database_name": "vnidrop-diagnostics", - // Replace this placeholder with the ID returned by `wrangler d1 create`. - "database_id": "00000000-0000-0000-0000-000000000000", + "database_id": "b9e18b17-d9fe-477b-8ace-2b1439d1694e", "migrations_dir": "migrations", }, ], @@ -25,6 +26,7 @@ { "binding": "BLOBS", "bucket_name": "vnidrop-diagnostics", + "jurisdiction": "eu", }, ], "ratelimits": [ diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsAcknowledgement.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsAcknowledgement.kt new file mode 100644 index 0000000..d6a0854 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/DiagnosticsAcknowledgement.kt @@ -0,0 +1,200 @@ +package com.vnidrop.app.diagnostics + +private const val MaxDiagnosticsAcknowledgementBytes = 16 * 1024 +private const val MaxDiagnosticsAcknowledgementDepth = 32 + +internal fun String.isSuccessfulDiagnosticsAcknowledgement(expectedId: String): Boolean { + if (length > MaxDiagnosticsAcknowledgementBytes || encodeToByteArray().size > MaxDiagnosticsAcknowledgementBytes) return false + return runCatching { + DiagnosticsAcknowledgementParser(this).isSuccessful(expectedId.lowercase()) + }.getOrDefault(false) +} + +private class DiagnosticsAcknowledgementParser(private val source: String) { + private var index = 0 + + fun isSuccessful(expectedId: String): Boolean { + skipWhitespace() + expect('{') + skipWhitespace() + val keys = mutableSetOf() + var ok: Boolean? = null + var id: String? = null + if (!consume('}')) { + while (true) { + val key = parseString() + require(keys.add(key)) { "duplicate JSON object key" } + skipWhitespace() + expect(':') + skipWhitespace() + when (key) { + "ok" -> ok = parseBoolean() + "id" -> id = parseString() + else -> skipValue(depth = 1) + } + skipWhitespace() + when { + consume('}') -> break + consume(',') -> { + skipWhitespace() + require(peek() != '}') { "trailing JSON object comma" } + } + else -> error("expected JSON object separator") + } + } + } + skipWhitespace() + require(index == source.length) { "unexpected data after JSON object" } + return ok == true && id == expectedId + } + + private fun skipValue(depth: Int) { + require(depth <= MaxDiagnosticsAcknowledgementDepth) { "JSON nesting is too deep" } + when (peek()) { + '"' -> parseString() + '{' -> skipObject(depth) + '[' -> skipArray(depth) + 't' -> expectLiteral("true") + 'f' -> expectLiteral("false") + 'n' -> expectLiteral("null") + '-' -> skipNumber() + in '0'..'9' -> skipNumber() + else -> error("invalid JSON value") + } + } + + private fun skipObject(depth: Int) { + expect('{') + skipWhitespace() + if (consume('}')) return + while (true) { + parseString() + skipWhitespace() + expect(':') + skipWhitespace() + skipValue(depth + 1) + skipWhitespace() + when { + consume('}') -> return + consume(',') -> { + skipWhitespace() + require(peek() != '}') { "trailing JSON object comma" } + } + else -> error("expected JSON object separator") + } + } + } + + private fun skipArray(depth: Int) { + expect('[') + skipWhitespace() + if (consume(']')) return + while (true) { + skipValue(depth + 1) + skipWhitespace() + when { + consume(']') -> return + consume(',') -> { + skipWhitespace() + require(peek() != ']') { "trailing JSON array comma" } + } + else -> error("expected JSON array separator") + } + } + } + + private fun parseBoolean(): Boolean = when { + source.startsWith("true", index) -> { + index += 4 + true + } + source.startsWith("false", index) -> { + index += 5 + false + } + else -> error("expected JSON boolean") + } + + private fun parseString(): String { + expect('"') + val value = StringBuilder() + while (index < source.length) { + val char = source[index++] + when { + char == '"' -> return value.toString() + char == '\\' -> value.append(parseEscape()) + char.code < 0x20 -> error("unescaped JSON control character") + else -> value.append(char) + } + } + error("unterminated JSON string") + } + + private fun parseEscape(): Char { + require(index < source.length) { "unterminated JSON escape" } + return when (val escaped = source[index++]) { + '"', '\\', '/' -> escaped + 'b' -> '\b' + 'f' -> '\u000c' + 'n' -> '\n' + 'r' -> '\r' + 't' -> '\t' + 'u' -> parseUnicodeEscape() + else -> error("invalid JSON escape") + } + } + + private fun parseUnicodeEscape(): Char { + require(index + 4 <= source.length) { "incomplete JSON Unicode escape" } + var value = 0 + repeat(4) { + value = value * 16 + source[index++].digitToIntOrNull(16).let { digit -> + requireNotNull(digit) { "invalid JSON Unicode escape" } + } + } + return value.toChar() + } + + private fun skipNumber() { + consume('-') + when (peek()) { + '0' -> { + index += 1 + require(peek() !in '0'..'9') { "leading zero in JSON number" } + } + in '1'..'9' -> while (peek() in '0'..'9') index += 1 + else -> error("invalid JSON number") + } + if (consume('.')) { + require(peek() in '0'..'9') { "invalid JSON fraction" } + while (peek() in '0'..'9') index += 1 + } + if (peek() == 'e' || peek() == 'E') { + index += 1 + if (peek() == '+' || peek() == '-') index += 1 + require(peek() in '0'..'9') { "invalid JSON exponent" } + while (peek() in '0'..'9') index += 1 + } + } + + private fun expectLiteral(literal: String) { + require(source.startsWith(literal, index)) { "invalid JSON literal" } + index += literal.length + } + + private fun skipWhitespace() { + while (peek() == ' ' || peek() == '\t' || peek() == '\r' || peek() == '\n') index += 1 + } + + private fun expect(expected: Char) { + require(consume(expected)) { "expected '$expected'" } + } + + private fun consume(expected: Char): Boolean { + if (peek() != expected) return false + index += 1 + return true + } + + private fun peek(): Char? = source.getOrNull(index) +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/HttpDiagnosticsTransport.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/HttpDiagnosticsTransport.kt index afd7efc..193da04 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/HttpDiagnosticsTransport.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/diagnostics/HttpDiagnosticsTransport.kt @@ -118,31 +118,36 @@ fun createDiagnosticsTransport( appVersion: String, platform: String, installIdProvider: suspend () -> String, +): DiagnosticsTransport = buildDiagnosticsTransport( + endpoint = DiagnosticsBuildConfig.ENDPOINT, + ingestKey = DiagnosticsBuildConfig.INGEST_KEY, + appVersion = appVersion, + platform = platform, + installIdProvider = installIdProvider, +) + +internal fun buildDiagnosticsTransport( + endpoint: String, + ingestKey: String, + appVersion: String, + platform: String, + installIdProvider: suspend () -> String, ): DiagnosticsTransport { - val endpoint = DiagnosticsBuildConfig.ENDPOINT.trim() - val ingestKey = DiagnosticsBuildConfig.INGEST_KEY.trim() - if (endpoint.isEmpty() && ingestKey.isEmpty()) return NoOpDiagnosticsTransport() - check(endpoint.isNotEmpty() && ingestKey.isNotEmpty()) { + val normalizedEndpoint = endpoint.trim() + val normalizedIngestKey = ingestKey.trim() + if (normalizedEndpoint.isEmpty() && normalizedIngestKey.isEmpty()) return NoOpDiagnosticsTransport() + check(normalizedEndpoint.isNotEmpty() && normalizedIngestKey.isNotEmpty()) { "diagnostics endpoint and ingest key must be configured together" } return HttpDiagnosticsTransport( - baseUrl = endpoint, - ingestKey = ingestKey, + baseUrl = normalizedEndpoint, + ingestKey = normalizedIngestKey, appVersion = appVersion, platform = platform, installIdProvider = installIdProvider, ) } -private fun String.isSuccessfulDiagnosticsAcknowledgement(expectedId: String): Boolean { - val json = trim() - if (!SuccessfulAcknowledgement.matches(json)) return false - if (AcknowledgementOk.findAll(json).count() != 1) return false - val ids = AcknowledgementId.findAll(json).toList() - return ids.size == 1 && - ids.single().groupValues[1] == "\"${DiagnosticsJson.escape(expectedId.lowercase())}\"" -} - private fun String.isAllowedDiagnosticsEndpoint(): Boolean { if (any(Char::isWhitespace) || '?' in this || '#' in this) return false val schemeSeparator = indexOf("://") @@ -180,13 +185,3 @@ private fun String.isLoopbackHost(): Boolean { octets.first() == "127" && octets.all { it.toIntOrNull() in 0..255 } } - -private const val JsonStringPattern = - """"(?:[^"\\\u0000-\u001f]|\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4}))*"""" -private const val JsonNumberPattern = - """-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?""" -private val SuccessfulAcknowledgement = Regex( - """^\s*\{\s*"ok"\s*:\s*true(?:\s*,\s*$JsonStringPattern\s*:\s*(?:$JsonStringPattern|$JsonNumberPattern|true|false|null))*\s*}\s*$""", -) -private val AcknowledgementOk = Regex(""""ok"\s*:""") -private val AcknowledgementId = Regex(""""id"\s*:\s*($JsonStringPattern)""") diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt index 730a961..c83dad3 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/diagnostics/DiagnosticsTest.kt @@ -31,14 +31,6 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class DiagnosticsTest { - @Test - fun diagnosticsBuildConfigDefaultsToIncludedWithEmptyEndpoint() { - // Production default is true; builds can override with -Pvnidrop.diagnostics.included=false. - assertTrue(DiagnosticsBuildConfig.INCLUDED) - // Empty endpoint keeps shipping safe (NoOp transport) until Cloudflare is configured. - assertEquals("", DiagnosticsBuildConfig.ENDPOINT) - } - @Test fun diagnosticsJsonEscapesAndShapesPayloads() { val eventsJson = DiagnosticsJson.eventsBody( @@ -162,6 +154,22 @@ class DiagnosticsTest { assertEquals("https://diag.example/v1/bugs", calls[2].first) } + @Test + fun httpTransportParsesEscapedAcknowledgementWithNestedUnknownFields() = runTest { + val transport = HttpDiagnosticsTransport( + baseUrl = "https://diag.example", + ingestKey = "secret", + post = { _, _, _ -> + PlatformHttpResponse( + 202, + """{"\u006f\u006b":true,"id":"batch-\u0031","metadata":{"stored":1,"flags":[true,null]}}""", + ) + }, + ) + + assertTrue(transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L)))).isSuccess) + } + @Test fun httpTransportFailsOnHttpError() = runTest { val transport = HttpDiagnosticsTransport( @@ -212,6 +220,31 @@ class DiagnosticsTest { } } + @Test + fun httpTransportRejectsAmbiguousOrMalformedJsonAcknowledgement() = runTest { + val responses = ArrayDeque( + listOf( + PlatformHttpResponse(202, """{"ok":true,"\u006f\u006b":true,"id":"batch-0"}"""), + PlatformHttpResponse(202, """{"ok":true,"id":true}"""), + PlatformHttpResponse(202, """{"ok":true,"id":"batch-2","stored":01}"""), + PlatformHttpResponse(202, """{"ok":true,"id":"batch-3",}"""), + PlatformHttpResponse(202, """{"ok":true,"id":"batch-4"} trailing"""), + ), + ) + val transport = HttpDiagnosticsTransport( + baseUrl = "https://diag.example", + ingestKey = "secret", + post = { _, _, _ -> responses.removeFirst() }, + ) + + repeat(5) { index -> + val result = transport.sendEvents( + TelemetryBatch("batch-$index", listOf(TelemetryEvent("x", 1L))), + ) + assertIs(result.exceptionOrNull()) + } + } + @Test fun httpTransportRequiresHttpsExceptForLoopbackDevelopment() { assertFailsWith { @@ -229,9 +262,10 @@ class DiagnosticsTest { } @Test - fun createTransportIsNoOpWhenEndpointBlank() { - // With default generated config endpoint is empty. - val transport = createDiagnosticsTransport( + fun createTransportIsNoOpForExplicitEmptyConfiguration() { + val transport = buildDiagnosticsTransport( + endpoint = "", + ingestKey = "", appVersion = "1.0", platform = "Test", installIdProvider = { "id" },