From 80465d2fab1f2512285a397363d209a929d7858e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janek=20G=C3=B3ral?= Date: Wed, 12 May 2021 13:43:07 +0200 Subject: [PATCH] Add :corellium:shard:dump module --- corellium/shard/dump/README.md | 66 ++++++++ corellium/shard/dump/build.gradle.kts | 21 +++ .../main/kotlin/flank/corellium/shard/Dump.kt | 25 +++ .../shard/internal/GsonPrettyObfuscate.kt | 61 ++++++++ .../shard/internal/GsonPrettyStandard.kt | 6 + .../corellium/shard/internal/Obfuscation.kt | 63 ++++++++ .../kotlin/flank/corellium/shard/DumpTest.kt | 142 ++++++++++++++++++ .../kotlin/flank/corellium/shard/Shard.kt | 2 +- settings.gradle.kts | 1 + 9 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 corellium/shard/dump/README.md create mode 100644 corellium/shard/dump/build.gradle.kts create mode 100644 corellium/shard/dump/src/main/kotlin/flank/corellium/shard/Dump.kt create mode 100644 corellium/shard/dump/src/main/kotlin/flank/corellium/shard/internal/GsonPrettyObfuscate.kt create mode 100644 corellium/shard/dump/src/main/kotlin/flank/corellium/shard/internal/GsonPrettyStandard.kt create mode 100644 corellium/shard/dump/src/main/kotlin/flank/corellium/shard/internal/Obfuscation.kt create mode 100644 corellium/shard/dump/src/test/kotlin/flank/corellium/shard/DumpTest.kt diff --git a/corellium/shard/dump/README.md b/corellium/shard/dump/README.md new file mode 100644 index 0000000000..3338f5ea2a --- /dev/null +++ b/corellium/shard/dump/README.md @@ -0,0 +1,66 @@ +# Shard dump + +Allows dumping shards as formatted json file. + +## Example + +The example output file structure: +```json +[ + [ + { + "name": "path/to/app1.apk", + "tests": [ + { + "name": "path/to/app1-test1.apk", + "cases": [ + { + "name": "app1.test1.Test#case1", + "duration": 10000 + } + ] + } + ] + } + ], + [ + { + "name": "path/to/app1.apk", + "tests": [ + { + "name": "app1-test1.apk", + "cases": [ + { + "name": "app1.test1.Test#case2", + "duration": 2000 + } + ] + } + ] + }, + { + "name": "path/to/app2.apk", + "tests": [ + { + "name": "path/to/app2-test1.apk", + "cases": [ + { + "name": "app2.test1.Test2#case1", + "duration": 1000 + } + ] + }, + { + "name": "path/to/app2-test1.apk", + "cases": [ + { + "name": "app2.test1.Test2#case2", + "duration": 120 + } + ] + } + ] + } + ] +] +``` diff --git a/corellium/shard/dump/build.gradle.kts b/corellium/shard/dump/build.gradle.kts new file mode 100644 index 0000000000..4adfd7b76f --- /dev/null +++ b/corellium/shard/dump/build.gradle.kts @@ -0,0 +1,21 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin(Plugins.Kotlin.PLUGIN_JVM) +} + +repositories { + jcenter() + mavenCentral() + maven(url = "https://kotlin.bintray.com/kotlinx") +} + +tasks.withType { kotlinOptions.jvmTarget = "1.8" } + +dependencies { + implementation(Dependencies.KOTLIN_COROUTINES_CORE) + implementation(Dependencies.GSON) + api(project(":corellium:shard")) + + testImplementation(Dependencies.JUNIT) +} diff --git a/corellium/shard/dump/src/main/kotlin/flank/corellium/shard/Dump.kt b/corellium/shard/dump/src/main/kotlin/flank/corellium/shard/Dump.kt new file mode 100644 index 0000000000..3205773d54 --- /dev/null +++ b/corellium/shard/dump/src/main/kotlin/flank/corellium/shard/Dump.kt @@ -0,0 +1,25 @@ +package flank.corellium.shard + +import flank.corellium.shard.internal.obfuscatePrettyGson +import flank.corellium.shard.internal.prettyGson +import java.nio.file.Files.newBufferedWriter +import java.nio.file.Paths.get + +/** + * Dump shards as formatted json file. + * + * @receiver List of shards to dump. + * @param filePath Relative or absolut path the file. + * @param obfuscate Hash the test cases names. Use for security reasons. + */ +fun List.dumpToFile( + filePath: String, + obfuscate: Boolean = false +) { + val writer = newBufferedWriter(get(filePath)) + when (obfuscate) { + true -> obfuscatePrettyGson + false -> prettyGson + }.toJson(this, writer) + writer.flush() +} diff --git a/corellium/shard/dump/src/main/kotlin/flank/corellium/shard/internal/GsonPrettyObfuscate.kt b/corellium/shard/dump/src/main/kotlin/flank/corellium/shard/internal/GsonPrettyObfuscate.kt new file mode 100644 index 0000000000..87d884b0f1 --- /dev/null +++ b/corellium/shard/dump/src/main/kotlin/flank/corellium/shard/internal/GsonPrettyObfuscate.kt @@ -0,0 +1,61 @@ +package flank.corellium.shard.internal + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer +import com.google.gson.reflect.TypeToken +import java.lang.reflect.Type + +internal val obfuscatePrettyGson: Gson = GsonBuilder() + .registerTypeAdapter(ListOfStringTypeToken.rawType, ObfuscatedAndroidJsonSerializer) + .registerTypeHierarchyAdapter(ListOfStringListTypeToken.rawType, ObfuscatedIosJsonSerializer) + .setPrettyPrinting() + .create() + +private object ListOfStringTypeToken : TypeToken>() + +private object ObfuscatedAndroidJsonSerializer : JsonSerializer> { + private val obfuscationContext by lazy { mutableMapOf>() } + + override fun serialize( + src: List, + typeOfSrc: Type, + context: JsonSerializationContext + ) = JsonArray().also { jsonArray -> + src.forEach { + val items = it.split(" ") // split for class and test name + jsonArray.add(items.first() + " " + obfuscationContext.obfuscateAndroidTestName(items.last())) + } + } +} + +private object ListOfStringListTypeToken : TypeToken() +private typealias CustomShardChunks = List>> + +private object ObfuscatedIosJsonSerializer : JsonSerializer { + private val obfuscationContext by lazy { mutableMapOf>() } + + override fun serialize( + src: CustomShardChunks, + typeOfSrc: Type, + context: JsonSerializationContext + ) = JsonArray().also { jsonArray -> + src.forEach { xcTestMethodList -> + val xcTestMethodJson = JsonObject() + xcTestMethodList.forEach { entry -> + xcTestMethodJson.add( + obfuscationContext.obfuscateIosTestName(entry.key), + JsonArray().also { nestedJsonArray -> + entry.value.forEach { it -> + nestedJsonArray.add(obfuscationContext.obfuscateIosTestName(it)) + } + } + ) + } + jsonArray.add(xcTestMethodJson) + } + } +} diff --git a/corellium/shard/dump/src/main/kotlin/flank/corellium/shard/internal/GsonPrettyStandard.kt b/corellium/shard/dump/src/main/kotlin/flank/corellium/shard/internal/GsonPrettyStandard.kt new file mode 100644 index 0000000000..24b3954e86 --- /dev/null +++ b/corellium/shard/dump/src/main/kotlin/flank/corellium/shard/internal/GsonPrettyStandard.kt @@ -0,0 +1,6 @@ +package flank.corellium.shard.internal + +import com.google.gson.Gson +import com.google.gson.GsonBuilder + +internal val prettyGson: Gson = GsonBuilder().setPrettyPrinting().create() diff --git a/corellium/shard/dump/src/main/kotlin/flank/corellium/shard/internal/Obfuscation.kt b/corellium/shard/dump/src/main/kotlin/flank/corellium/shard/internal/Obfuscation.kt new file mode 100644 index 0000000000..65494bf7e0 --- /dev/null +++ b/corellium/shard/dump/src/main/kotlin/flank/corellium/shard/internal/Obfuscation.kt @@ -0,0 +1,63 @@ +package flank.corellium.shard.internal + +private const val LOWER_CASE_CHARS = "abcdefghijklmnopqrstuvwxyz" +private const val UPPER_CASE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +private const val ANDROID_TEST_METHOD_SEPARATOR = '#' +private const val ANDROID_PACKAGE_SEPARATOR = '.' +private const val IOS_TEST_METHOD_SEPARATOR = '/' + +internal typealias ObfuscationContext = MutableMap> + +internal fun ObfuscationContext.obfuscateAndroidTestName(input: String): String { + val obfuscatedPackageNameWithClass = + obfuscateAndroidPackageAndClass(input.split(ANDROID_TEST_METHOD_SEPARATOR).first()) + + return obfuscatedPackageNameWithClass + obfuscateAndroidMethodIfPresent(input, obfuscatedPackageNameWithClass) +} + +private fun ObfuscationContext.obfuscateAndroidPackageAndClass(packageNameWithClass: String) = + packageNameWithClass + .split(ANDROID_PACKAGE_SEPARATOR) + .fold("") { previous, next -> + val classChunk = getOrPut(previous) { linkedMapOf() } + val obfuscatedPart = classChunk.getOrPut(next) { nextSymbol(next, classChunk) } + if (previous.isBlank()) obfuscatedPart else "$previous$ANDROID_PACKAGE_SEPARATOR$obfuscatedPart" + } + +private fun ObfuscationContext.obfuscateAndroidMethodIfPresent( + input: String, + obfuscatedPackageNameWithClass: String +) = if (input.contains(ANDROID_TEST_METHOD_SEPARATOR)) + ANDROID_TEST_METHOD_SEPARATOR + obfuscateMethodName( + methodName = input.split(ANDROID_TEST_METHOD_SEPARATOR).last(), + context = getOrPut(obfuscatedPackageNameWithClass) { mutableMapOf() } + ) +else "" + +internal fun ObfuscationContext.obfuscateIosTestName(input: String): String { + val className = input.split(IOS_TEST_METHOD_SEPARATOR).first() + val obfuscatedClassName = getOrPut("") { mutableMapOf() }.run { + getOrPut(className) { nextSymbol(className, this) } + } + return obfuscatedClassName + + IOS_TEST_METHOD_SEPARATOR + + obfuscateMethodName( + methodName = input.split(IOS_TEST_METHOD_SEPARATOR).last(), + context = getOrPut(obfuscatedClassName) { linkedMapOf() } + ) +} + +private fun nextSymbol(key: String, context: Map): String { + val isLowerCaseKey = key.first().isLowerCase() + val possibleSymbols = if (isLowerCaseKey) LOWER_CASE_CHARS else UPPER_CASE_CHARS + + val currentContextItemCount = context.values.count { + if (isLowerCaseKey) it.first().isLowerCase() else it.first().isUpperCase() + } + val repeatSymbol = currentContextItemCount / possibleSymbols.length + 1 + + return possibleSymbols[currentContextItemCount % possibleSymbols.length].toString().repeat(repeatSymbol) +} + +private fun obfuscateMethodName(methodName: String, context: MutableMap) = + context.getOrPut(methodName) { nextSymbol(methodName, context) } diff --git a/corellium/shard/dump/src/test/kotlin/flank/corellium/shard/DumpTest.kt b/corellium/shard/dump/src/test/kotlin/flank/corellium/shard/DumpTest.kt new file mode 100644 index 0000000000..4be64b5828 --- /dev/null +++ b/corellium/shard/dump/src/test/kotlin/flank/corellium/shard/DumpTest.kt @@ -0,0 +1,142 @@ +package flank.corellium.shard + +import org.junit.After +import org.junit.Assert +import org.junit.Test +import java.io.File + +class DumpTest { + + private val filePath = "dump-shards.json" + private val file = File(filePath) + + private val shards = listOf( + listOf( + Shard.App( + name = "app1", + tests = listOf( + Shard.Test( + name = "app1-test1", + cases = listOf( + Shard.Test.Case( + name = "app1-test1#case1", + duration = 10_000, + ), + ) + ), + ) + ), + ), + listOf( + Shard.App( + name = "app1", + tests = listOf( + Shard.Test( + name = "app1-test1", + cases = listOf( + Shard.Test.Case( + name = "app1-test1#case2", + duration = 2_000, + ), + ) + ), + ) + ), + Shard.App( + name = "app2", + tests = listOf( + Shard.Test( + name = "app2-test1", + cases = listOf( + Shard.Test.Case( + name = "app2-test1#case1", + duration = 1_000, + ), + ) + ), + Shard.Test( + name = "app2-test1", + cases = listOf( + Shard.Test.Case( + name = "app2-test1#case2", + ), + ) + ), + ) + ), + ) + ) + + private val expected = """ +[ + [ + { + "name": "app1", + "tests": [ + { + "name": "app1-test1", + "cases": [ + { + "name": "app1-test1#case1", + "duration": 10000 + } + ] + } + ] + } + ], + [ + { + "name": "app1", + "tests": [ + { + "name": "app1-test1", + "cases": [ + { + "name": "app1-test1#case2", + "duration": 2000 + } + ] + } + ] + }, + { + "name": "app2", + "tests": [ + { + "name": "app2-test1", + "cases": [ + { + "name": "app2-test1#case1", + "duration": 1000 + } + ] + }, + { + "name": "app2-test1", + "cases": [ + { + "name": "app2-test1#case2", + "duration": 120 + } + ] + } + ] + } + ] +] +""".trimIndent() + + @Test + fun testDumpToFile() { + shards.dumpToFile(filePath) + + Assert.assertTrue(file.exists()) + Assert.assertEquals(expected, file.readText().trim()) + } + + @After + fun cleanUp () { + file.delete() + } +} diff --git a/corellium/shard/src/main/kotlin/flank/corellium/shard/Shard.kt b/corellium/shard/src/main/kotlin/flank/corellium/shard/Shard.kt index f4fd0fb012..602b138e02 100644 --- a/corellium/shard/src/main/kotlin/flank/corellium/shard/Shard.kt +++ b/corellium/shard/src/main/kotlin/flank/corellium/shard/Shard.kt @@ -27,7 +27,7 @@ object Shard { /** * Abstract representation for test case (test method). * @property name An abstract name for identifying test case. - * @property duration The duration of the test case run. Use default if no previous duration was recorded. + * @property duration The duration of the test case run in milliseconds. Use default if no previous duration was recorded. */ class Case( val name: String, diff --git a/settings.gradle.kts b/settings.gradle.kts index ee503ee6e0..6e8f6a6daf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,6 +17,7 @@ include( ":corellium:api", ":corellium:shard", ":corellium:shard:calculate", + ":corellium:shard:dump", ":corellium:adapter", )