From 71b49767933213513e08a9453831e612d96bf049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Tue, 5 May 2026 21:00:59 +0200 Subject: [PATCH] fix(bson-multiplatform): Correctly escape special characters when rendering in JSON --- .../src/commonMain/kotlin/BsonValue.kt | 8 ++-- .../kotlin/impl/read/JsonEncoding.kt | 47 +++++++++++++++++++ .../impl/read/MultiplatformBsonDocumentMap.kt | 2 +- .../src/commonMain/kotlin/types/StringTest.kt | 27 +++++++++-- 4 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 bson-multiplatform/src/commonMain/kotlin/impl/read/JsonEncoding.kt diff --git a/bson-multiplatform/src/commonMain/kotlin/BsonValue.kt b/bson-multiplatform/src/commonMain/kotlin/BsonValue.kt index 6f80a6d0..1697f572 100644 --- a/bson-multiplatform/src/commonMain/kotlin/BsonValue.kt +++ b/bson-multiplatform/src/commonMain/kotlin/BsonValue.kt @@ -28,6 +28,8 @@ import opensavvy.ktmongo.bson.* import opensavvy.ktmongo.bson.BsonArray import opensavvy.ktmongo.bson.BsonValue import opensavvy.ktmongo.bson.multiplatform.BsonValue.Serializer +import opensavvy.ktmongo.bson.multiplatform.impl.read.encodeRegexToJsonString +import opensavvy.ktmongo.bson.multiplatform.impl.read.encodeToJsonString import opensavvy.ktmongo.bson.multiplatform.serialization.BsonDecoder import opensavvy.ktmongo.bson.types.ObjectId import opensavvy.ktmongo.bson.types.Timestamp @@ -242,12 +244,12 @@ class BsonValue internal constructor( BsonType.Int32 -> decodeInt32().toString() BsonType.Int64 -> decodeInt64().toString() BsonType.Double -> commonDoubleToString(decodeDouble()) - BsonType.String -> '"' + decodeString() + '"' + BsonType.String -> '"' + decodeString().encodeToJsonString() + '"' BsonType.Null -> "null" BsonType.Undefined -> """{"${'$'}undefined": true}""" BsonType.Document -> decodeDocument().toString() BsonType.Array -> decodeArray().toString() - BsonType.JavaScript -> """{"${'$'}code": "${decodeJavaScript()}"}""" + BsonType.JavaScript -> """{"${'$'}code": "${decodeJavaScript().encodeToJsonString()}"}""" BsonType.Datetime -> { val time = decodeDateTime() if (time in 0..253402300799999) // Start of the year 1970 … End of the year 9999 @@ -273,7 +275,7 @@ class BsonValue internal constructor( val escapedPattern = pattern .replace("\\", "\\\\") .replace("\"", "\\\"") - """{"${'$'}regularExpression": {"pattern": "$escapedPattern", "options": "$options"}}""" + """{"${'$'}regularExpression": {"pattern": "${escapedPattern.encodeRegexToJsonString()}", "options": "${options.encodeToJsonString()}"}}""" } BsonType.Timestamp -> { diff --git a/bson-multiplatform/src/commonMain/kotlin/impl/read/JsonEncoding.kt b/bson-multiplatform/src/commonMain/kotlin/impl/read/JsonEncoding.kt new file mode 100644 index 00000000..9116c195 --- /dev/null +++ b/bson-multiplatform/src/commonMain/kotlin/impl/read/JsonEncoding.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, OpenSavvy and contributors. + * + * 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 + * + * http://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. + */ + +package opensavvy.ktmongo.bson.multiplatform.impl.read + +internal fun String.encodeToJsonString(): String = buildString { + for (c in this@encodeToJsonString) { + when (c) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + '\b' -> append("\\b") + '\t' -> append("\\t") + '\n' -> append("\\n") + ' ' -> append("\\f") + '\r' -> append("\\r") + else if (c.code < 0x20) -> append("\\u" + c.code.toString(16).padStart(4, '0')) + else -> append(c) + } + } +} + +internal fun String.encodeRegexToJsonString(): String = buildString { + for (c in this@encodeRegexToJsonString) { + when (c) { + '\b' -> append("\\b") + '\t' -> append("\\t") + '\n' -> append("\\n") + ' ' -> append("\\f") + '\r' -> append("\\r") + else if (c.code < 0x20) -> append("\\u" + c.code.toString(16).padStart(4, '0')) + else -> append(c) + } + } +} diff --git a/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformBsonDocumentMap.kt b/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformBsonDocumentMap.kt index 3081b07a..5d1baf42 100644 --- a/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformBsonDocumentMap.kt +++ b/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformBsonDocumentMap.kt @@ -223,7 +223,7 @@ internal class MultiplatformBsonDocumentMap( append(", ") append('"') - append(key) + append(key.encodeToJsonString()) append("\": ") append(value) diff --git a/bson-tests/src/commonMain/kotlin/types/StringTest.kt b/bson-tests/src/commonMain/kotlin/types/StringTest.kt index e781c01d..8d888d2c 100644 --- a/bson-tests/src/commonMain/kotlin/types/StringTest.kt +++ b/bson-tests/src/commonMain/kotlin/types/StringTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, OpenSavvy and contributors. + * Copyright (c) 2025-2026, OpenSavvy and contributors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -121,16 +121,35 @@ fun SuiteDsl.verifyStrings(factory: Prepared) = suite("String") { } ) + val specialCharacters = "ab\\\"\u0001\u0002\u0003\u0004\u0005\u0006\u0007\b\t\n\u000b\u000c\r\u000e\u000f\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001a\u001b\u001c\u001d\u001e\u001fab" + + val escapedJson = buildString { + for (c in specialCharacters) { + when (c) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + '\b' -> append("\\b") + '\t' -> append("\\t") + '\n' -> append("\\n") + ' ' -> append("\\f") + '\r' -> append("\\r") + else if (c.code < 0x20) -> append("\\u" + c.code.toString(16).padStart(4, '0')) + else -> append(c) + } + } + } + testBson( factory, "Required escapes", document { - writeString("a", "ab\\\"\u0001\u0002\u0003\u0004\u0005\u0006\u0007\b\t\n\u000b\u000c\r\u000e\u000f\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001a\u001b\u001c\u001d\u001e\u001fab") + writeString("a", specialCharacters) }, - serialize(A("ab\\\"\u0001\u0002\u0003\u0004\u0005\u0006\u0007\b\t\n\u000b\u000c\r\u000e\u000f\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001a\u001b\u001c\u001d\u001e\u001fab")), + serialize(A(specialCharacters)), hex("320000000261002600000061625C220102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F61620000"), + json("{\"a\": \"$escapedJson\"}"), verify("Read value") { - check(this["a"]?.decodeString() == "ab\\\"\u0001\u0002\u0003\u0004\u0005\u0006\u0007\b\t\n\u000b\u000c\r\u000e\u000f\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001a\u001b\u001c\u001d\u001e\u001fab") + check(this["a"]?.decodeString() == specialCharacters) } ) } -- 2.51.2