diff --git a/build-logic/repositories.gradle.kts b/build-logic/repositories.gradle.kts index 3bdfd9de..858b8f72 100644 --- a/build-logic/repositories.gradle.kts +++ b/build-logic/repositories.gradle.kts @@ -46,6 +46,7 @@ dependencyResolutionManagement { } } metadataSources { artifact() } + content { includeModuleByRegex(".*", "kotlin-native-prebuilt") } } sonatypeSnapshots() diff --git a/ks3-standard/src/commonMain/kotlin/io/ks3/standard/base64/Base64DecodingException.kt b/ks3-standard/src/commonMain/kotlin/io/ks3/standard/base64/Base64DecodingException.kt new file mode 100644 index 00000000..d12f8e6b --- /dev/null +++ b/ks3-standard/src/commonMain/kotlin/io/ks3/standard/base64/Base64DecodingException.kt @@ -0,0 +1,5 @@ +package io.ks3.standard.base64 + +import kotlinx.serialization.SerializationException + +class Base64DecodingException(message: String? = null, cause: Throwable? = null) : SerializationException(message, cause) diff --git a/ks3-standard/src/commonMain/kotlin/io/ks3/standard/base64/ByteArrayAsBase64StringSerializer.kt b/ks3-standard/src/commonMain/kotlin/io/ks3/standard/base64/ByteArrayAsBase64StringSerializer.kt new file mode 100644 index 00000000..90ea8daf --- /dev/null +++ b/ks3-standard/src/commonMain/kotlin/io/ks3/standard/base64/ByteArrayAsBase64StringSerializer.kt @@ -0,0 +1,26 @@ +package io.ks3.standard.base64 + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +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 + +typealias ByteArrayAsBase64String = @Serializable(with = ByteArrayAsBase64StringSerializer::class) ByteArray + +object ByteArrayAsBase64StringSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( + "io.ks3.standard.base64.ByteArrayAsBase64StringSerializer", + PrimitiveKind.STRING, + ) + + override fun deserialize(decoder: Decoder): ByteArray { + return decoder.decodeString().decodeBase64ToArray() + } + + override fun serialize(encoder: Encoder, value: ByteArray) { + encoder.encodeString(value.encodeBase64()) + } +} diff --git a/ks3-standard/src/commonMain/kotlin/io/ks3/standard/base64/Encoding.kt b/ks3-standard/src/commonMain/kotlin/io/ks3/standard/base64/Encoding.kt new file mode 100644 index 00000000..29afc9d3 --- /dev/null +++ b/ks3-standard/src/commonMain/kotlin/io/ks3/standard/base64/Encoding.kt @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 io.ks3.standard.base64 + +/** + * @author Alexander Y. Kleymenov + * https://github.com/square/okio/blob/master/okio/src/commonMain/kotlin/okio/-Base64.kt + */ + +internal const val BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" +internal val BASE64 = BASE64_CHARS.encodeToByteArray() + +internal fun String.decodeBase64ToArray(): ByteArray { + // Ignore trailing '=' padding and whitespace from the input. + var limit = length + while (limit > 0) { + val c = this[limit - 1] + if (c != '=' && c != '\n' && c != '\r' && c != ' ' && c != '\t') { + break + } + limit-- + } + + // If the input includes whitespace, this output array will be longer than necessary. + val out = ByteArray((limit * 6L / 8L).toInt()) + var outCount = 0 + var inCount = 0 + + var word = 0 + for (pos in 0 until limit) { + val c = this[pos] + + val bits: Int + if (c in 'A'..'Z') { + // char ASCII value + // A 65 0 + // Z 90 25 (ASCII - 65) + bits = c.code - 65 + } else if (c in 'a'..'z') { + // char ASCII value + // a 97 26 + // z 122 51 (ASCII - 71) + bits = c.code - 71 + } else if (c in '0'..'9') { + // char ASCII value + // 0 48 52 + // 9 57 61 (ASCII + 4) + bits = c.code + 4 + } else if (c == '+' || c == '-') { + bits = 62 + } else if (c == '/' || c == '_') { + bits = 63 + } else if (c == '\n' || c == '\r' || c == ' ' || c == '\t') { + continue + } else { + throw Base64DecodingException("Invalid base64 character: '$c', must be one of $BASE64_CHARS") + } + + // Append this char's 6 bits to the word. + word = word shl 6 or bits + + // For every 4 chars of input, we accumulate 24 bits of output. Emit 3 bytes. + inCount++ + if (inCount % 4 == 0) { + out[outCount++] = (word shr 16).toByte() + out[outCount++] = (word shr 8).toByte() + out[outCount++] = word.toByte() + } + } + + val lastWordChars = inCount % 4 + when (lastWordChars) { + 1 -> { + // We read 1 char followed by "===". But 6 bits is a truncated byte! Fail. + throw Base64DecodingException("Invalid Base64 input: $this") + } + + 2 -> { + // We read 2 chars followed by "==". Emit 1 byte with 8 of those 12 bits. + word = word shl 12 + out[outCount++] = (word shr 16).toByte() + } + + 3 -> { + // We read 3 chars, followed by "=". Emit 2 bytes for 16 of those 18 bits. + word = word shl 6 + out[outCount++] = (word shr 16).toByte() + out[outCount++] = (word shr 8).toByte() + } + } + + // If we sized our out array perfectly, we're done. + if (outCount == out.size) return out + + // Copy the decoded bytes to a new, right-sized array. + return out.copyOf(outCount) +} + +internal fun ByteArray.encodeBase64(): String { + val length = (size + 2) / 3 * 4 + val out = ByteArray(length) + var index = 0 + val end = size - size % 3 + var i = 0 + while (i < end) { + val b0 = this[i++].toInt() + val b1 = this[i++].toInt() + val b2 = this[i++].toInt() + out[index++] = BASE64[(b0 and 0xff shr 2)] + out[index++] = BASE64[(b0 and 0x03 shl 4) or (b1 and 0xff shr 4)] + out[index++] = BASE64[(b1 and 0x0f shl 2) or (b2 and 0xff shr 6)] + out[index++] = BASE64[(b2 and 0x3f)] + } + when (size - end) { + 1 -> { + val b0 = this[i].toInt() + out[index++] = BASE64[b0 and 0xff shr 2] + out[index++] = BASE64[b0 and 0x03 shl 4] + out[index++] = '='.code.toByte() + out[index] = '='.code.toByte() + } + + 2 -> { + val b0 = this[i++].toInt() + val b1 = this[i].toInt() + out[index++] = BASE64[(b0 and 0xff shr 2)] + out[index++] = BASE64[(b0 and 0x03 shl 4) or (b1 and 0xff shr 4)] + out[index++] = BASE64[(b1 and 0x0f shl 2)] + out[index] = '='.code.toByte() + } + } + return out.decodeToString() +} diff --git a/ks3-standard/src/commonTest/kotlin/io/ks3/standard/base64/ByteArrayAsBase64StringSerializerTest.kt b/ks3-standard/src/commonTest/kotlin/io/ks3/standard/base64/ByteArrayAsBase64StringSerializerTest.kt new file mode 100644 index 00000000..f3648a6c --- /dev/null +++ b/ks3-standard/src/commonTest/kotlin/io/ks3/standard/base64/ByteArrayAsBase64StringSerializerTest.kt @@ -0,0 +1,36 @@ +package io.ks3.standard.base64 + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.assertions.throwables.shouldThrowWithMessage +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.json.Json + +class ByteArrayAsBase64StringSerializerTest : FreeSpec( + { + val format = Json + + fun ByteArray.serializeBase64() = format.encodeToString(ByteArrayAsBase64StringSerializer, this) + fun String.deserializeBase64() = format.decodeFromString(ByteArrayAsBase64StringSerializer, this) + + "Encode to base64 string" { + "test".encodeToByteArray().serializeBase64() shouldBe "\"dGVzdA==\"" + } + + "Decode from base64 string" { + "\"dGVzdA==\"".deserializeBase64() shouldBe "test".encodeToByteArray() + } + + "Non-base64 content" { + shouldThrow { + "\"[][]\"".deserializeBase64() + }.message shouldBe "Invalid base64 character: '[', must be one of ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + } + + "Invalid base64" { + shouldThrowWithMessage("Invalid Base64 input: A===") { + "\"A===\"".deserializeBase64() + } + } + }, +)