From d1a4ebdd6860defc13ebd7aa03fbf4ed5c2ff1fc Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 9 Nov 2026 14:45:37 +0000 Subject: [PATCH] cultmesh-kotlin: refuse a write_admitted messageId without a letter or number or with a Default_Ignorable code point Raw compare. JDK Character categories for L/N, an embedded Default_Ignorable range table (Unicode 16.0). The MessagePack string reader now refuses invalid UTF-8 instead of substituting U+FFFD. Co-Authored-By: Claude Sonnet 5.5 --- .../kotlin/org/gamecult/cultmesh/CultMesh.kt | 5 +++- .../org/gamecult/cultmesh/WriteReceipt.kt | 27 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt index 4a06d7f5..2337448d 100644 --- a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt +++ b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt @@ -19,6 +19,7 @@ import java.net.Socket import java.net.SocketTimeoutException import java.net.URI import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction import java.nio.charset.StandardCharsets import java.security.SecureRandom import java.security.MessageDigest @@ -6306,7 +6307,9 @@ class MessagePackReader(payload: ByteArray) { code == 0xdb -> input.readInt() else -> throw IOException("expected string") } - return String(readPayload(length), StandardCharsets.UTF_8) + // Strict: bytes that are not UTF-8 (a lone surrogate's ED A0 80) refuse the frame instead of becoming U+FFFD. + return StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(readPayload(length))).toString() } private fun readBinaryAfterCode(code: Int): ByteArray { diff --git a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/WriteReceipt.kt b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/WriteReceipt.kt index 4b38d942..523dfc2e 100644 --- a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/WriteReceipt.kt +++ b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/WriteReceipt.kt @@ -74,7 +74,32 @@ fun decodeWriteAdmitted(message: CultNetMessage): CultNetWriteAdmitted { closedKeys(message, setOf("messageId", "operation", "schemaId", "recordKey")) val operation = nonEmpty(message, "operation") if (operation != "put" && operation != "delete") throw IOException("operation must be put or delete") - return CultNetWriteAdmitted(nonEmpty(message, "messageId"), operation, nonEmpty(message, "schemaId"), nonEmpty(message, "recordKey")) + return CultNetWriteAdmitted(admittedMessageId(message), operation, nonEmpty(message, "schemaId"), nonEmpty(message, "recordKey")) +} + +// Default_Ignorable_Code_Point ranges, DerivedCoreProperties.txt, Unicode 16.0 (unchanged since 15.1). The JDK has no such +// property, so this runtime embeds it; the Python, C# and Rust decoders embed the same ranges, TypeScript uses its regex +// engine's. Letters and numbers come from the JDK's Character categories (L*, and Nd, Nl, No); a code point the running JDK +// does not assign (U+0378 in every version) has no such category, so it is refused either way. +private val DEFAULT_IGNORABLE: List = listOf( + 0x00AD..0x00AD, 0x034F..0x034F, 0x061C..0x061C, 0x115F..0x1160, 0x17B4..0x17B5, 0x180B..0x180F, + 0x200B..0x200F, 0x202A..0x202E, 0x2060..0x206F, 0x3164..0x3164, 0xFE00..0xFE0F, 0xFEFF..0xFEFF, + 0xFFA0..0xFFA0, 0xFFF0..0xFFF8, 0x1BCA0..0x1BCA3, 0x1D173..0x1D17A, 0xE0000..0xE0FFF, +) + +/** write-receipt.md: a letter or number somewhere, no Default_Ignorable code point anywhere, compared raw (never trimmed). */ +private fun admittedMessageId(message: CultNetMessage): String { + val id = nonEmpty(message, "messageId") + var hasLetterOrNumber = false + id.codePoints().forEach { cp -> + if (DEFAULT_IGNORABLE.any { cp in it }) throw IOException("messageId holds a Default_Ignorable code point") + when (Character.getType(cp).toByte()) { + Character.UPPERCASE_LETTER, Character.LOWERCASE_LETTER, Character.TITLECASE_LETTER, Character.MODIFIER_LETTER, + Character.OTHER_LETTER, Character.DECIMAL_DIGIT_NUMBER, Character.LETTER_NUMBER, Character.OTHER_NUMBER -> hasLetterOrNumber = true + } + } + if (!hasLetterOrNumber) throw IOException("messageId must contain a letter or number") + return id } /** Strict decode of error.v0 (no messageId, no limit_reached) or error.v1 (messageId required, string or nil, never empty). */