Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added base64 serializer for byte arrays #11

Merged
merged 7 commits into from
Oct 24, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package io.ks3.standard.base64

import kotlinx.serialization.SerializationException

class Base64DecodingException(message: String? = null, cause: Throwable? = null) : SerializationException(message, cause)
Original file line number Diff line number Diff line change
@@ -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<ByteArray> {
aSemy marked this conversation as resolved.
Show resolved Hide resolved
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())
}
}
148 changes: 148 additions & 0 deletions ks3-standard/src/commonMain/kotlin/io/ks3/standard/base64/Encoding.kt
Original file line number Diff line number Diff line change
@@ -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
aSemy marked this conversation as resolved.
Show resolved Hide resolved

/**
* @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.toByteArray(Charsets.UTF_8)
aSemy marked this conversation as resolved.
Show resolved Hide resolved

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.toString(Charsets.UTF_8)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package io.ks3.standard.base64

import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.shouldBe
import kotlinx.serialization.json.Json
import kotlin.text.Charsets.UTF_8

class ByteArrayAsBase64StringSerializerTest : FreeSpec(
aSemy marked this conversation as resolved.
Show resolved Hide resolved
{
val format = Json

fun ByteArray.serializeBase64() = format.encodeToString(ByteArrayAsBase64StringSerializer, this)
fun String.deserializeBase64() = format.decodeFromString(ByteArrayAsBase64StringSerializer, this)

"Encode to base64 string" {
"test".toByteArray(UTF_8).serializeBase64() shouldBe "\"dGVzdA==\""
}

"Decode from base64 string" {
"\"dGVzdA==\"".deserializeBase64() shouldBe "test".toByteArray(UTF_8)
}

"Non-base64 content" {
shouldThrow<Base64DecodingException> {
"\"[][]\"".deserializeBase64()
}.message shouldBe "Invalid base64 character: '[', must be one of ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
}

"Invalid base64" {
shouldThrow<Base64DecodingException> {
"\"A===\"".deserializeBase64()
}.message shouldBe "Invalid Base64 input: A==="
}
},
)