Skip to content

Commit

Permalink
Merge pull request #4 from saschpe/url-safe
Browse files Browse the repository at this point in the history
Add URL-safe encoding
  • Loading branch information
saschpe authored May 19, 2022
2 parents 74bc979 + d6c2530 commit 70404be
Show file tree
Hide file tree
Showing 11 changed files with 245 additions and 54 deletions.
9 changes: 8 additions & 1 deletion .idea/runConfigurations/build.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Changelog
All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [1.0.5] - 2022-05-19
### Added
- Implement Base64 URL-safe encoding according to [RFC 4648 §5](https://datatracker.ietf.org/doc/html/rfc4648#section-5)

## [1.0.4] - 2022-04-26
### Changed
- Dependency update:
- [Kotlin-1.6.21](https://github.com/JetBrains/kotlin/releases/tag/v1.6.21)
- [Gradle-7.4.2](https://docs.gradle.org/7.4.2/release-notes.html)

## [1.0.3] - 2022-03-10
### Changed
- Gitlab: Publish Android variants, fix iOS simulator tests

## [1.0.2] - 2022-03-04
### Added
- Kotlin/Multiplatform: Enable JavaScript for NodeJS

## [1.0.1] - 2022-03-04
### Added
- GPG sign releases

## [1.0.0] - 2022-03-04
### Added
- Initial implementation of Base64 standard encoding
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ repositories {
}

dependencies {
implementation("de.peilicke.sascha:kase64:1.0.4")
implementation("de.peilicke.sascha:kase64:1.0.5")
}
```

Expand Down
2 changes: 1 addition & 1 deletion kase64/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ android {
}

group = "de.peilicke.sascha"
version = "1.0.4"
version = "1.0.5"

val javadocJar by tasks.registering(Jar::class) {
archiveClassifier.set("javadoc")
Expand Down
68 changes: 18 additions & 50 deletions kase64/src/commonMain/kotlin/saschpe/kase64/Base64.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,74 +15,42 @@
*/
package saschpe.kase64

internal const val BASE64_SET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
private val RX_BASE64_CLEANER = "[^=$BASE64_SET${"]".toRegex()}"

/**
* Base64 encode a string.
* Encode a [String] to Base64 standard encoded [String].
*
* See [RFC 4648 §4](https://datatracker.ietf.org/doc/html/rfc4648#section-4)
*/
val String.base64Encoded: String
get() {
val pad = when (length % 3) {
1 -> "=="
2 -> "="
else -> ""
}
val raw = this + 0.toChar().toString().repeat(maxOf(0, pad.length))
return raw.chunkedSequence(3) {
Triple(it[0].code, it[1].code, it[2].code)
}.map { (first, second, third) ->
(0xFF.and(first) shl 16) + (0xFF.and(second) shl 8) + 0xFF.and(third)
}.map { n ->
sequenceOf((n shr 18) and 0x3F, (n shr 12) and 0x3F, (n shr 6) and 0x3F, n and 0x3F)
}.flatten()
.map { BASE64_SET[it] }
.joinToString("")
.dropLast(pad.length) + pad
}

fun ByteArray.asCharArray(): CharArray {
val chars = CharArray(size)
for (i in chars.indices) {
chars[i] = get(i).toInt().toChar()
}
return chars
}
get() = encodeInternal(Encoding.Standard)

/**
* Base64 encode a ByteArray to String.
* Encode a [ByteArray] to Base64 standard encoded [String].
*
* See [RFC 4648 §4](https://datatracker.ietf.org/doc/html/rfc4648#section-4)
*/
val ByteArray.base64Encoded: String
get() = asCharArray().concatToString().base64Encoded

private fun String.base64DecodeInternal(): Sequence<Int> {
require(length % 4 == 0) { "The string \"$this\" does not comply with Base64 length requirement." }
return replace(RX_BASE64_CLEANER, "")
.replace("=", "A")
.chunkedSequence(4) {
(BASE64_SET.indexOf(it[0]) shl 18) +
(BASE64_SET.indexOf(it[1]) shl 12) +
(BASE64_SET.indexOf(it[2]) shl 6) +
BASE64_SET.indexOf(it[3])
}
.map { sequenceOf(0xFF.and(it shr 16), 0xFF.and(it shr 8), 0xFF.and(it)) }
.flatten()
}

/**
* Decode a Base64 string to String.
* Decode a Base64 standard encoded [String] to [String].
*
* See [RFC 4648 §4](https://datatracker.ietf.org/doc/html/rfc4648#section-4)
*/
val String.base64Decoded: String
get() = base64DecodeInternal().map { it.toChar() }.joinToString("").dropLast(count { it == '=' })
get() = decodeInternal(Encoding.Standard).map { it.toChar() }.joinToString("").dropLast(count { it == '=' })

/**
* Decode a Base64 string to ByteArray.
* Decode a Base64 standard encoded [String] to [ByteArray].
*
* See [RFC 4648 §4](https://datatracker.ietf.org/doc/html/rfc4648#section-4)
*/
val String.base64DecodedBytes: ByteArray
get() = base64DecodeInternal().map { it.toByte() }.toList().dropLast(count { it == '=' }).toByteArray()
get() = decodeInternal(Encoding.Standard).map { it.toByte() }.toList().dropLast(count { it == '=' }).toByteArray()

/**
* Decode a Base64 ByteArray to String.
* Decode a Base64 standard encoded [ByteArray] to [String].
*
* See [RFC 4648 §4](https://datatracker.ietf.org/doc/html/rfc4648#section-4)
*/
val ByteArray.base64Decoded: String
get() = asCharArray().concatToString().base64Decoded
82 changes: 82 additions & 0 deletions kase64/src/commonMain/kotlin/saschpe/kase64/Base64Internal.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright 2022 Sascha Peilicke <[email protected]>
*
* 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 saschpe.kase64

/**
* Base64 encoding scheme
*/
internal sealed interface Encoding {
val alphabet: String
val requiresPadding: Boolean

object Standard : Encoding {
override val alphabet: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
override val requiresPadding: Boolean = true
}

object UrlSafe : Encoding {
override val alphabet: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
override val requiresPadding: Boolean = false // Padding is optional
}
}

internal fun String.encodeInternal(encoding: Encoding): String {
val padLength = when (length % 3) {
1 -> 2
2 -> 1
else -> 0
}
val raw = this + 0.toChar().toString().repeat(maxOf(0, padLength))
val encoded = raw.chunkedSequence(3) {
Triple(it[0].code, it[1].code, it[2].code)
}.map { (first, second, third) ->
(0xFF.and(first) shl 16) + (0xFF.and(second) shl 8) + 0xFF.and(third)
}.map { n ->
sequenceOf((n shr 18) and 0x3F, (n shr 12) and 0x3F, (n shr 6) and 0x3F, n and 0x3F)
}.flatten()
.map { encoding.alphabet[it] }
.joinToString("")
.dropLast(padLength)
return when (encoding.requiresPadding) {
true -> encoded.padEnd(encoded.length + padLength, '=')
else -> encoded
}
}

internal fun String.decodeInternal(encoding: Encoding): Sequence<Int> {
val padLength = when (length % 4) {
1 -> 3
2 -> 2
3 -> 1
else -> 0
}
return padEnd(length + padLength, '=')
.replace("=", "A")
.chunkedSequence(4) {
(encoding.alphabet.indexOf(it[0]) shl 18) + (encoding.alphabet.indexOf(it[1]) shl 12) +
(encoding.alphabet.indexOf(it[2]) shl 6) + encoding.alphabet.indexOf(it[3])
}
.map { sequenceOf(0xFF.and(it shr 16), 0xFF.and(it shr 8), 0xFF.and(it)) }
.flatten()
}

internal fun ByteArray.asCharArray(): CharArray {
val chars = CharArray(size)
for (i in chars.indices) {
chars[i] = get(i).toInt().toChar()
}
return chars
}
Binary file not shown.
Binary file not shown.
Binary file modified kase64/src/commonTest/kotlin/saschpe/kase64/Base64Test.kt
Binary file not shown.
98 changes: 98 additions & 0 deletions kase64/src/commonTest/kotlin/saschpe/kase64/Base64UrlTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* Copyright 2022 Sascha Peilicke <[email protected]>
*
* 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 saschpe.kase64

import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals

class Base64UrlTest {
@Test
fun byteArray_base64UrlDecoded() {
assertEquals("Hello, world!", "SGVsbG8sIHdvcmxkIQ==".encodeToByteArray().base64UrlDecoded)
assertContentEquals(
byteArrayOf(
-94, 124, -26, -112, -72, -84, 16, 11, 67, -45, 107, 38, -99, 79, 62, -49, 83, 26, -85, -70, -122, 53,
67, 42, -94, -87, 61, -74, 66, 0, 80, -125, -17, -11, -125, 63, 109, -15, 56, -95, -33, 18, 110, 47,
47, -20, -72, -34, 53, -69, 49, -45, 54, 53, -21, 43, 9, -84, -125, 72, -61, 76, 31, -46
),
"onzmkLisEAtD02smnU8-z1Maq7qGNUMqoqk9tkIAUIPv9YM_bfE4od8Sbi8v7LjeNbsx0zY16ysJrINIw0wf0g==".base64UrlDecodedBytes
)
}

@Test
fun byteArray_base64UrlEncoded() {
assertEquals(
"xvrp9DBWlei2mG0ov9MN-A", // value1
byteArrayOf(-58, -6, -23, -12, 48, 86, -107, -24, -74, -104, 109, 40, -65, -45, 13, -8).base64UrlEncoded
)
assertEquals(
"IkYJxF8nIQD9RY7Yk6r26A", // value222
byteArrayOf(34, 70, 9, -60, 95, 39, 33, 0, -3, 69, -114, -40, -109, -86, -10, -24).base64UrlEncoded
)
assertEquals(
"U0GeVBi2dNcdL2IO0nJo5Q", // value555
byteArrayOf(83, 65, -98, 84, 24, -74, 116, -41, 29, 47, 98, 14, -46, 114, 104, -27).base64UrlEncoded
)
}

@Test
fun string_base64UrlDecoded() {
assertEquals("word", "d29yZA==".base64UrlDecoded)
assertEquals("Word", "V29yZA==".base64UrlDecoded)
assertEquals("Hello", "SGVsbG8=".base64UrlDecoded)
assertEquals("World!", "V29ybGQh".base64UrlDecoded)
assertEquals("Hello, world!", "SGVsbG8sIHdvcmxkIQ==".base64UrlDecoded)
assertEquals(
Encoding.Standard.alphabet,
"QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODkrLw==".base64UrlDecoded
)
assertEquals("Salt", "U2FsdA==".base64UrlDecoded)
assertEquals("Pepper", "UGVwcGVy".base64UrlDecoded)
assertEquals("abcd", "YWJjZA".base64UrlDecoded)
assertEquals(
"1234567890-=!@#\$%^&*()_+qwertyuiop[];'\\,./?><|\":}{P`~",
"MTIzNDU2Nzg5MC09IUAjJCVeJiooKV8rcXdlcnR5dWlvcFtdOydcLC4vPz48fCI6fXtQYH4".base64UrlDecoded
)
assertEquals("saschpe", "c2FzY2hwZQ".base64UrlDecoded)
}

@Test
fun string_base64UrlEncoded() {
assertEquals("d29yZA", "word".base64UrlEncoded)
assertEquals("V29yZA", "Word".base64UrlEncoded)
assertEquals("SGVsbG8", "Hello".base64UrlEncoded)
assertEquals("V29ybGQh", "World!".base64UrlEncoded)
assertEquals("SGVsbG8sIHdvcmxkIQ", "Hello, world!".base64UrlEncoded)
assertEquals("SGVsbG8sIHdvcmxkIQ", "Hello, world!".encodeToByteArray().base64UrlEncoded)
assertEquals(
"QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODkrLw",
Encoding.Standard.alphabet.base64UrlEncoded
)
assertEquals("U2FsdA", "Salt".base64UrlEncoded)
assertEquals("UGVwcGVy", "Pepper".base64UrlEncoded)
assertEquals("YWJjZA", "abcd".base64UrlEncoded)
assertEquals(
"MTIzNDU2Nzg5MC09IUAjJCVeJiooKV8rcXdlcnR5dWlvcFtdOydcLC4vPz48fCI6fXtQYH4",
"1234567890-=!@#\$%^&*()_+qwertyuiop[];'\\,./?><|\":}{P`~".base64UrlEncoded
)
assertEquals("c2FzY2hwZQ", "saschpe".base64UrlEncoded)
}

@Test
fun string_roundTrip() =
assertEquals(Encoding.UrlSafe.alphabet, Encoding.UrlSafe.alphabet.base64UrlEncoded.base64UrlDecoded)
}
5 changes: 4 additions & 1 deletion scripts/release
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# Constants
FILES=(
kase64/build.gradle.kts
README.md
)

# Functions
Expand Down Expand Up @@ -60,6 +59,10 @@ done
sed2 "s/kase64:.*\"/kase64:${version_name_new}\"/" README.md
git_commit_files="${git_commit_files} README.md"

# Update CHANGELOG.md
sed2 "s/\[Unreleased\]/\[Unreleased\]\n\n## [${version_name_new}] - $(date "+%Y-%m-%d")/" CHANGELOG.md
git_commit_files="${git_commit_files} CHANGELOG.md"

# Create a commit with appropriate tag
git_commit_message="Release version ${version_name_new}"
safe git commit --signoff ${git_commit_files} -m "${git_commit_message}"
Expand Down

0 comments on commit 70404be

Please sign in to comment.