From a9d4f1dc723f30f244473198de3bea92b1aabca8 Mon Sep 17 00:00:00 2001 From: Russell Cohen Date: Thu, 13 Feb 2025 08:07:32 -0500 Subject: [PATCH] Fuzz impl v2 (#3881) ## Motivation and Context This implements fuzzing for smithy-rs servers ## Description ## Testing ## Checklist - [ ] For changes to the smithy-rs codegen or runtime crates, I have created a changelog entry Markdown file in the `.changelog` directory, specifying "client," "server," or both in the `applies_to` key. - [ ] For changes to the AWS SDK, generated SDK code, or SDK runtime crates, I have created a changelog entry Markdown file in the `.changelog` directory, specifying "aws-sdk-rust" in the `applies_to` key. ---- _By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice._ --------- Co-authored-by: david-perez --- .github/workflows/ci.yml | 2 + build.gradle.kts | 4 + codegen-client/build.gradle.kts | 1 + .../smithy/rust/codegen/core/testutil/Rust.kt | 1 + .../smithy/rust/codegen/core/util/Exec.kt | 5 +- fuzzgen/build.gradle.kts | 111 ++ .../codegen/fuzz/FuzzHarnessBuildPlugin.kt | 212 ++++ .../rust/codegen/fuzz/FuzzTargetGenerator.kt | 215 ++++ ...ware.amazon.smithy.build.SmithyBuildPlugin | 5 + .../fuzz/FuzzHarnessBuildPluginTest.kt | 80 ++ rust-runtime/aws-smithy-fuzz/Cargo.toml | 44 + rust-runtime/aws-smithy-fuzz/LICENSE | 175 ++++ rust-runtime/aws-smithy-fuzz/README.md | 186 ++++ rust-runtime/aws-smithy-fuzz/src/lib.rs | 203 ++++ rust-runtime/aws-smithy-fuzz/src/main.rs | 945 ++++++++++++++++++ rust-runtime/aws-smithy-fuzz/src/types.rs | 162 +++ .../templates/smithy-build-fuzzer.jinja2 | 41 + .../templates/smithy-build-targetcrate.jinja2 | 37 + settings.gradle.kts | 1 + tools/ci-scripts/check-fuzzgen | 10 + 20 files changed, 2438 insertions(+), 2 deletions(-) create mode 100644 fuzzgen/build.gradle.kts create mode 100644 fuzzgen/src/main/kotlin/software/amazon/smithy/rust/codegen/fuzz/FuzzHarnessBuildPlugin.kt create mode 100644 fuzzgen/src/main/kotlin/software/amazon/smithy/rust/codegen/fuzz/FuzzTargetGenerator.kt create mode 100644 fuzzgen/src/main/resources/META-INF/services/software.amazon.smithy.build.SmithyBuildPlugin create mode 100644 fuzzgen/src/test/kotlin/software/amazon/smithy/rust/codegen/fuzz/FuzzHarnessBuildPluginTest.kt create mode 100644 rust-runtime/aws-smithy-fuzz/Cargo.toml create mode 100644 rust-runtime/aws-smithy-fuzz/LICENSE create mode 100644 rust-runtime/aws-smithy-fuzz/README.md create mode 100644 rust-runtime/aws-smithy-fuzz/src/lib.rs create mode 100644 rust-runtime/aws-smithy-fuzz/src/main.rs create mode 100644 rust-runtime/aws-smithy-fuzz/src/types.rs create mode 100644 rust-runtime/aws-smithy-fuzz/templates/smithy-build-fuzzer.jinja2 create mode 100644 rust-runtime/aws-smithy-fuzz/templates/smithy-build-targetcrate.jinja2 create mode 100755 tools/ci-scripts/check-fuzzgen diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0723f9c20..a6ba23718 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,8 @@ jobs: fetch-depth: 0 - action: check-sdk-codegen-unit-tests runner: ubuntu-latest + - action: check-fuzzgen + runner: ubuntu-latest - action: check-server-codegen-integration-tests runner: smithy_ubuntu-latest_8-core - action: check-server-codegen-integration-tests-python diff --git a/build.gradle.kts b/build.gradle.kts index 5e11e0ab0..0d8adb08d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ + buildscript { repositories { mavenCentral() @@ -14,6 +15,7 @@ buildscript { } } + allprojects { val allowLocalDeps: String by project repositories { @@ -23,8 +25,10 @@ allprojects { mavenCentral() google() } + } + val ktlint by configurations.creating val ktlintVersion: String by project diff --git a/codegen-client/build.gradle.kts b/codegen-client/build.gradle.kts index 3e1f1ec58..5954994ab 100644 --- a/codegen-client/build.gradle.kts +++ b/codegen-client/build.gradle.kts @@ -18,6 +18,7 @@ group = "software.amazon.smithy.rust.codegen" version = "0.1.0" val smithyVersion: String by project +val smithyRsVersion: String by project dependencies { implementation(project(":codegen-core")) diff --git a/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/testutil/Rust.kt b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/testutil/Rust.kt index 4427852d4..b1287f726 100644 --- a/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/testutil/Rust.kt +++ b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/testutil/Rust.kt @@ -360,6 +360,7 @@ fun RustCrate.testModule(block: Writable) = } fun FileManifest.printGeneratedFiles() { + println("Generated files:") this.files.forEach { path -> println("file:///$path") } diff --git a/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/util/Exec.kt b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/util/Exec.kt index 296d7bc39..399e4fd1a 100644 --- a/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/util/Exec.kt +++ b/codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/util/Exec.kt @@ -16,6 +16,7 @@ fun String.runCommand( workdir: Path? = null, environment: Map = mapOf(), timeout: Long = 3600, + redirect: ProcessBuilder.Redirect = ProcessBuilder.Redirect.PIPE, ): String { val logger = Logger.getLogger("RunCommand") logger.fine("Invoking comment $this in `$workdir` with env $environment") @@ -23,8 +24,8 @@ fun String.runCommand( val parts = this.split("\\s".toRegex()) val builder = ProcessBuilder(*parts.toTypedArray()) - .redirectOutput(ProcessBuilder.Redirect.PIPE) - .redirectError(ProcessBuilder.Redirect.PIPE) + .redirectOutput(redirect) + .redirectError(redirect) .letIf(workdir != null) { it.directory(workdir?.toFile()) } diff --git a/fuzzgen/build.gradle.kts b/fuzzgen/build.gradle.kts new file mode 100644 index 000000000..e025831a5 --- /dev/null +++ b/fuzzgen/build.gradle.kts @@ -0,0 +1,111 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import org.gradle.api.tasks.testing.logging.TestExceptionFormat + +plugins { + kotlin("jvm") + `maven-publish` +} + +description = "Plugin to generate a fuzz harness" +extra["displayName"] = "Smithy :: Rust :: Fuzzer Generation" +extra["moduleName"] = "software.amazon.smithy.rust.codegen.client" + +group = "software.amazon.smithy.rust.codegen.serde" +version = "0.1.0" + +val smithyVersion: String by project + +dependencies { + implementation(project(":codegen-core")) + implementation(project(":codegen-client")) + implementation(project(":codegen-server")) + implementation("software.amazon.smithy:smithy-protocol-test-traits:$smithyVersion") +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +tasks.compileKotlin { + kotlinOptions.jvmTarget = "11" +} + +// Reusable license copySpec +val licenseSpec = copySpec { + from("${project.rootDir}/LICENSE") + from("${project.rootDir}/NOTICE") +} + +// Configure jars to include license related info +tasks.jar { + metaInf.with(licenseSpec) + inputs.property("moduleName", project.name) + manifest { + attributes["Automatic-Module-Name"] = project.name + } +} + +val sourcesJar by tasks.creating(Jar::class) { + group = "publishing" + description = "Assembles Kotlin sources jar" + archiveClassifier.set("sources") + from(sourceSets.getByName("main").allSource) +} + +val isTestingEnabled: String by project +if (isTestingEnabled.toBoolean()) { + val kotestVersion: String by project + + dependencies { + runtimeOnly(project(":rust-runtime")) + testImplementation("org.junit.jupiter:junit-jupiter:5.6.1") + testImplementation("software.amazon.smithy:smithy-validation-model:$smithyVersion") + testImplementation("software.amazon.smithy:smithy-aws-protocol-tests:$smithyVersion") + testImplementation("io.kotest:kotest-assertions-core-jvm:$kotestVersion") + } + + tasks.compileTestKotlin { + kotlinOptions.jvmTarget = "11" + } + + tasks.register("generateClasspath") { + doLast { + // Get the runtime classpath + val runtimeClasspath = sourceSets["main"].runtimeClasspath + + // Add the 'libs' directory to the classpath + val libsDir = file(layout.buildDirectory.dir("libs")) + val fullClasspath = runtimeClasspath + files(libsDir.listFiles()) + + // Convert to classpath string + val classpath = fullClasspath.asPath + } + } + + + tasks.test { + useJUnitPlatform() + testLogging { + events("failed") + exceptionFormat = TestExceptionFormat.FULL + showCauses = true + showExceptions = true + showStackTraces = true + } + } +} + +publishing { + publications { + create("default") { + from(components["java"]) + artifact(sourcesJar) + } + } + repositories { maven { url = uri(layout.buildDirectory.dir("repository")) } } +} diff --git a/fuzzgen/src/main/kotlin/software/amazon/smithy/rust/codegen/fuzz/FuzzHarnessBuildPlugin.kt b/fuzzgen/src/main/kotlin/software/amazon/smithy/rust/codegen/fuzz/FuzzHarnessBuildPlugin.kt new file mode 100644 index 000000000..cb4ba107f --- /dev/null +++ b/fuzzgen/src/main/kotlin/software/amazon/smithy/rust/codegen/fuzz/FuzzHarnessBuildPlugin.kt @@ -0,0 +1,212 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.rust.codegen.fuzz + +import software.amazon.smithy.build.FileManifest +import software.amazon.smithy.build.PluginContext +import software.amazon.smithy.build.SmithyBuildPlugin +import software.amazon.smithy.model.Model +import software.amazon.smithy.model.knowledge.TopDownIndex +import software.amazon.smithy.model.neighbor.Walker +import software.amazon.smithy.model.node.ArrayNode +import software.amazon.smithy.model.node.Node +import software.amazon.smithy.model.node.NumberNode +import software.amazon.smithy.model.node.ObjectNode +import software.amazon.smithy.model.node.StringNode +import software.amazon.smithy.model.shapes.MemberShape +import software.amazon.smithy.model.shapes.OperationShape +import software.amazon.smithy.model.shapes.Shape +import software.amazon.smithy.model.shapes.ShapeId +import software.amazon.smithy.model.traits.HttpPrefixHeadersTrait +import software.amazon.smithy.model.traits.HttpQueryTrait +import software.amazon.smithy.model.traits.HttpTrait +import software.amazon.smithy.model.traits.JsonNameTrait +import software.amazon.smithy.model.traits.XmlNameTrait +import software.amazon.smithy.protocoltests.traits.HttpRequestTestsTrait +import software.amazon.smithy.rust.codegen.core.rustlang.RustModule +import software.amazon.smithy.rust.codegen.core.rustlang.Writable +import software.amazon.smithy.rust.codegen.core.smithy.ModuleDocProvider +import software.amazon.smithy.rust.codegen.core.smithy.RuntimeConfig +import software.amazon.smithy.rust.codegen.core.smithy.transformers.OperationNormalizer +import software.amazon.smithy.rust.codegen.core.util.getTrait +import software.amazon.smithy.rust.codegen.core.util.orNull +import software.amazon.smithy.rust.codegen.server.smithy.transformers.AttachValidationExceptionToConstrainedOperationInputsInAllowList +import java.nio.file.Path +import java.util.Base64 +import kotlin.streams.toList + +/** + * Metadata for a TargetCrate: A code generated smithy-rs server for a given model + */ +data class TargetCrate( + /** The name of the Fuzz target */ + val name: String, + /** Where the server implementation of this target is */ + val relativePath: String, +) { + companion object { + fun fromNode(node: ObjectNode): TargetCrate { + val name = node.expectStringMember("name").value + val relativePath = node.expectStringMember("relativePath").value + return TargetCrate(name, relativePath) + } + } + + /** The name of the actual `package` from Cargo's perspective. + * + * We need this to make a dependency on it + * */ + fun targetPackage(): String { + val path = Path.of(relativePath) + val cargoToml = path.resolve("Cargo.toml").toFile() + val packageSection = cargoToml.readLines().dropWhile { it.trim() != "[package]" } + return packageSection.firstOrNull { it.startsWith("name =") }?.let { it.split("=")[1].trim() }?.trim('"') + ?: throw Exception("no package name") + } +} + +data class FuzzSettings( + val targetServers: List, + val service: ShapeId, + val runtimeConfig: RuntimeConfig, +) { + companion object { + fun fromNode(node: ObjectNode): FuzzSettings { + val targetCrates = + node.expectArrayMember("targetCrates") + .map { TargetCrate.fromNode(it.expectObjectNode()) } + val service = ShapeId.fromNode(node.expectStringMember("service")) + val runtimeConfig = RuntimeConfig.fromNode(node.getObjectMember("runtimeConfig")) + return FuzzSettings(targetCrates, service, runtimeConfig) + } + } +} + +/** + * Build plugin for generating a fuzz harness and lexicon from a smithy model and a set of smithy-rs versions + * + * This is used by `aws-smithy-fuzz` which contains most of the usage docs + */ +class FuzzHarnessBuildPlugin : SmithyBuildPlugin { + override fun getName(): String = "fuzz-harness" + + override fun execute(context: PluginContext) { + val fuzzSettings = FuzzSettings.fromNode(context.settings) + + val model = + context.model.let(OperationNormalizer::transform) + .let(AttachValidationExceptionToConstrainedOperationInputsInAllowList::transform) + val targets = + fuzzSettings.targetServers.map { target -> + val targetContext = createFuzzTarget(target, context.fileManifest, fuzzSettings, model) + println("Creating a fuzz targret for $targetContext") + FuzzTargetGenerator(targetContext).generateFuzzTarget() + targetContext + } + + println("creating the driver...") + createDriver(model, context.fileManifest, fuzzSettings) + + targets.forEach { + context.fileManifest.addAllFiles(it.finalize()) + } + } +} + +/** + * Generate a corpus of words used within the model to seed the dictionary + */ +fun corpus( + model: Model, + fuzzSettings: FuzzSettings, +): ArrayNode { + val operations = TopDownIndex.of(model).getContainedOperations(fuzzSettings.service) + val protocolTests = operations.flatMap { it.getTrait()?.testCases ?: listOf() } + val out = ArrayNode.builder() + protocolTests.forEach { testCase -> + val body: List = + when (testCase.bodyMediaType.orNull()) { + "application/cbor" -> { + println("base64 decoding first (v2)") + Base64.getDecoder().decode(testCase.body.orNull())?.map { NumberNode.from(it.toUByte().toInt()) } + } + + else -> testCase.body.orNull()?.chars()?.toList()?.map { c -> NumberNode.from(c) } + } ?: listOf() + out.withValue( + ObjectNode.objectNode() + .withMember("uri", testCase.uri) + .withMember("method", testCase.method) + .withMember( + "headers", + ObjectNode.objectNode( + testCase.headers.map { (k, v) -> + (StringNode.from(k) to ArrayNode.fromStrings(v)) + }.toMap(), + ), + ) + .withMember("trailers", ObjectNode.objectNode()) + .withMember( + "body", + ArrayNode.fromNodes(body), + ), + ) + } + return out.build() +} + +fun createDriver( + model: Model, + baseManifest: FileManifest, + fuzzSettings: FuzzSettings, +) { + val fuzzLexicon = + ObjectNode.objectNode() + .withMember("corpus", corpus(model, fuzzSettings)) + .withMember("dictionary", dictionary(model, fuzzSettings)) + baseManifest.writeFile("lexicon.json", Node.prettyPrintJson(fuzzLexicon)) +} + +fun dictionary( + model: Model, + fuzzSettings: FuzzSettings, +): ArrayNode { + val operations = TopDownIndex.of(model).getContainedOperations(fuzzSettings.service) + val walker = Walker(model) + val dictionary = mutableSetOf() + operations.forEach { + walker.iterateShapes(it).forEach { shape -> + dictionary.addAll(getTraitBasedNames(shape)) + dictionary.add(shape.id.name) + when (shape) { + is MemberShape -> dictionary.add(shape.memberName) + is OperationShape -> dictionary.add(shape.id.toString()) + else -> {} + } + } + } + return ArrayNode.fromStrings(dictionary.toList().sorted()) +} + +fun getTraitBasedNames(shape: Shape): List { + return listOfNotNull( + shape.getTrait()?.value, + shape.getTrait()?.value, + shape.getTrait()?.value, + shape.getTrait()?.method, + *( + shape.getTrait()?.uri?.queryLiterals?.flatMap { (k, v) -> listOf(k, v) } + ?: listOf() + ).toTypedArray(), + shape.getTrait()?.value, + ) +} + +class NoOpDocProvider : ModuleDocProvider { + override fun docsWriter(module: RustModule.LeafModule): Writable? { + return null + } +} diff --git a/fuzzgen/src/main/kotlin/software/amazon/smithy/rust/codegen/fuzz/FuzzTargetGenerator.kt b/fuzzgen/src/main/kotlin/software/amazon/smithy/rust/codegen/fuzz/FuzzTargetGenerator.kt new file mode 100644 index 000000000..b1894ffeb --- /dev/null +++ b/fuzzgen/src/main/kotlin/software/amazon/smithy/rust/codegen/fuzz/FuzzTargetGenerator.kt @@ -0,0 +1,215 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.rust.codegen.fuzz + +import software.amazon.smithy.build.FileManifest +import software.amazon.smithy.model.Model +import software.amazon.smithy.model.knowledge.NullableIndex +import software.amazon.smithy.model.knowledge.TopDownIndex +import software.amazon.smithy.model.shapes.OperationShape +import software.amazon.smithy.model.shapes.ServiceShape +import software.amazon.smithy.rust.codegen.core.rustlang.CargoDependency +import software.amazon.smithy.rust.codegen.core.rustlang.Local +import software.amazon.smithy.rust.codegen.core.rustlang.RustReservedWords +import software.amazon.smithy.rust.codegen.core.rustlang.Writable +import software.amazon.smithy.rust.codegen.core.rustlang.rust +import software.amazon.smithy.rust.codegen.core.rustlang.rustTemplate +import software.amazon.smithy.rust.codegen.core.rustlang.writable +import software.amazon.smithy.rust.codegen.core.smithy.CoreCodegenConfig +import software.amazon.smithy.rust.codegen.core.smithy.CoreRustSettings +import software.amazon.smithy.rust.codegen.core.smithy.PublicImportSymbolProvider +import software.amazon.smithy.rust.codegen.core.smithy.RuntimeType +import software.amazon.smithy.rust.codegen.core.smithy.RuntimeType.Companion.preludeScope +import software.amazon.smithy.rust.codegen.core.smithy.RustCrate +import software.amazon.smithy.rust.codegen.core.smithy.RustSymbolProvider +import software.amazon.smithy.rust.codegen.core.smithy.RustSymbolProviderConfig +import software.amazon.smithy.rust.codegen.core.smithy.SymbolVisitor +import software.amazon.smithy.rust.codegen.core.smithy.contextName +import software.amazon.smithy.rust.codegen.core.util.hasStreamingMember +import software.amazon.smithy.rust.codegen.core.util.inputShape +import software.amazon.smithy.rust.codegen.core.util.isEventStream +import software.amazon.smithy.rust.codegen.core.util.outputShape +import software.amazon.smithy.rust.codegen.core.util.toPascalCase +import software.amazon.smithy.rust.codegen.core.util.toSnakeCase +import software.amazon.smithy.rust.codegen.server.smithy.ServerModuleProvider +import software.amazon.smithy.rust.codegen.server.smithy.isDirectlyConstrained +import java.nio.file.Path +import kotlin.io.path.name + +private fun rustSettings( + fuzzSettings: FuzzSettings, + target: TargetCrate, +) = CoreRustSettings( + fuzzSettings.service, + moduleVersion = "0.1.0", + moduleName = "fuzz-target-${target.name}", + moduleAuthors = listOf(), + codegenConfig = CoreCodegenConfig(), + license = null, + runtimeConfig = fuzzSettings.runtimeConfig, + moduleDescription = null, + moduleRepository = null, +) + +data class FuzzTargetContext( + val target: TargetCrate, + val fuzzSettings: FuzzSettings, + val rustCrate: RustCrate, + val model: Model, + val symbolProvider: RustSymbolProvider, + private val manifest: FileManifest, +) { + fun finalize(): FileManifest { + val forceWorkspace = + mapOf( + "workspace" to listOf("_ignored" to "_ignored").toMap(), + "lib" to mapOf("crate-type" to listOf("cdylib")), + ) + val rustSettings = rustSettings(fuzzSettings, target) + rustCrate.finalize(rustSettings, model, forceWorkspace, listOf(), requireDocs = false) + return manifest + } +} + +class FuzzTargetGenerator(private val context: FuzzTargetContext) { + private val model = context.model + private val serviceShape = context.model.expectShape(context.fuzzSettings.service, ServiceShape::class.java) + private val symbolProvider = PublicImportSymbolProvider(context.symbolProvider, targetCrate().name) + + private fun targetCrate(): RuntimeType { + val path = Path.of(context.target.relativePath).toAbsolutePath() + return CargoDependency( + name = path.name, + location = Local(path.parent?.toString() ?: ""), + `package` = context.target.targetPackage(), + ).toType() + } + + private val smithyFuzz = context.fuzzSettings.runtimeConfig.smithyRuntimeCrate("smithy-fuzz").toType() + private val ctx = + arrayOf( + "fuzz_harness" to smithyFuzz.resolve("fuzz_harness"), + "fuzz_service" to smithyFuzz.resolve("fuzz_service"), + "FuzzResult" to smithyFuzz.resolve("FuzzResult"), + "Body" to smithyFuzz.resolve("Body"), + "http" to CargoDependency.Http.toType(), + "target" to targetCrate(), + ) + + private val serviceName = context.fuzzSettings.service.name.toPascalCase() + + fun generateFuzzTarget() { + context.rustCrate.lib { + rustTemplate( + """ + #{fuzz_harness}!(|tx| { + let config = #{target}::${serviceName}Config::builder().build(); + #{tx_clones} + #{target}::$serviceName::builder::<#{Body}, _, _, _>(config)#{all_operations}.build_unchecked() + }); + + """, + *ctx, + "all_operations" to allOperations(), + "tx_clones" to allTxs(), + *preludeScope, + ) + } + } + + private fun operationsToImplement(): List { + val index = TopDownIndex.of(model) + return index.getContainedOperations(serviceShape).filter { operationShape -> + // TODO(fuzzing): consider if it is possible to support event streams + !operationShape.isEventStream(model) && + // TODO(fuzzing): it should be possible to support normal streaming operations + !( + operationShape.inputShape(model).hasStreamingMember(model) || + operationShape.outputShape(model) + .hasStreamingMember(model) + ) && + // TODO(fuzzing): it should be possible to work backwards from constraints to satisfy them in most cases. + !(operationShape.outputShape(model).isDirectlyConstrained(symbolProvider)) + }.toList() + } + + private fun allTxs(): Writable = + writable { + operationsToImplement().forEach { op -> + val operationName = + op.contextName(serviceShape).toSnakeCase().let { RustReservedWords.escapeIfNeeded(it) } + rust("let tx_$operationName = tx.clone();") + } + } + + private fun allOperations(): Writable = + writable { + val operations = operationsToImplement() + operations.forEach { op -> + val operationName = + op.contextName(serviceShape).toSnakeCase().let { RustReservedWords.escapeIfNeeded(it) } + val output = + writable { + val outputSymbol = symbolProvider.toSymbol(op.outputShape(model)) + if (op.errors.isEmpty()) { + rust("#T::builder().build()", outputSymbol) + } else { + rust("Ok(#T::builder().build())", outputSymbol) + } + } + rustTemplate( + """ + .$operationName(move |input: #{Input}| { + let tx = tx_$operationName.clone(); + async move { + tx.send(format!("{:?}", input)).await.unwrap(); + #{output} + } + })""", + "Input" to symbolProvider.toSymbol(op.inputShape(model)), + "output" to output, + *preludeScope, + ) + } + } +} + +fun createFuzzTarget( + target: TargetCrate, + baseManifest: FileManifest, + fuzzSettings: FuzzSettings, + model: Model, +): FuzzTargetContext { + val newManifest = FileManifest.create(baseManifest.resolvePath(Path.of(target.name))) + val codegenConfig = CoreCodegenConfig() + val symbolProvider = + SymbolVisitor( + rustSettings(fuzzSettings, target), + model, + model.expectShape(fuzzSettings.service, ServiceShape::class.java), + RustSymbolProviderConfig( + fuzzSettings.runtimeConfig, + renameExceptions = false, + NullableIndex.CheckMode.SERVER, + ServerModuleProvider, + ), + ) + val crate = + RustCrate( + newManifest, + symbolProvider, + codegenConfig, + NoOpDocProvider(), + ) + return FuzzTargetContext( + target = target, + fuzzSettings = fuzzSettings, + rustCrate = crate, + model = model, + manifest = newManifest, + symbolProvider = symbolProvider, + ) +} diff --git a/fuzzgen/src/main/resources/META-INF/services/software.amazon.smithy.build.SmithyBuildPlugin b/fuzzgen/src/main/resources/META-INF/services/software.amazon.smithy.build.SmithyBuildPlugin new file mode 100644 index 000000000..6dc899617 --- /dev/null +++ b/fuzzgen/src/main/resources/META-INF/services/software.amazon.smithy.build.SmithyBuildPlugin @@ -0,0 +1,5 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +# +software.amazon.smithy.rust.codegen.fuzz.FuzzHarnessBuildPlugin diff --git a/fuzzgen/src/test/kotlin/software/amazon/smithy/rust/codegen/fuzz/FuzzHarnessBuildPluginTest.kt b/fuzzgen/src/test/kotlin/software/amazon/smithy/rust/codegen/fuzz/FuzzHarnessBuildPluginTest.kt new file mode 100644 index 000000000..f90e801de --- /dev/null +++ b/fuzzgen/src/test/kotlin/software/amazon/smithy/rust/codegen/fuzz/FuzzHarnessBuildPluginTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.rust.codegen.fuzz + +import io.kotest.matchers.collections.shouldContain +import org.junit.jupiter.api.Test +import software.amazon.smithy.build.FileManifest +import software.amazon.smithy.build.PluginContext +import software.amazon.smithy.model.node.ArrayNode +import software.amazon.smithy.model.node.Node +import software.amazon.smithy.model.node.ObjectNode +import software.amazon.smithy.rust.codegen.core.testutil.IntegrationTestParams +import software.amazon.smithy.rust.codegen.core.testutil.TestRuntimeConfig +import software.amazon.smithy.rust.codegen.core.testutil.TestWorkspace +import software.amazon.smithy.rust.codegen.core.testutil.asSmithyModel +import software.amazon.smithy.rust.codegen.core.testutil.printGeneratedFiles +import software.amazon.smithy.rust.codegen.core.util.runCommand +import software.amazon.smithy.rust.codegen.server.smithy.testutil.serverIntegrationTest + +class FuzzHarnessBuildPluginTest() { + private val minimalModel = + """ + namespace com.example + use aws.protocols#awsJson1_0 + @awsJson1_0 + service HelloService { + operations: [SayHello], + version: "1" + } + operation SayHello { input: TestInput } + structure TestInput { + foo: String, + } + """.asSmithyModel() + + /** + * Smoke test that generates a lexicon and target crate for the trivial service above + */ + @Test + fun smokeTest() { + val testDir = TestWorkspace.subproject() + val testPath = testDir.toPath() + val manifest = FileManifest.create(testPath) + val service = "com.example#HelloService" + val generatedServer = + serverIntegrationTest( + minimalModel, + IntegrationTestParams(service = service, command = { dir -> println("generated $dir") }), + ) { _, _ -> + } + val context = + PluginContext.builder() + .model(minimalModel) + .fileManifest(manifest) + .settings( + ObjectNode.objectNode() + .withMember("service", "com.example#HelloService") + .withMember( + "targetCrates", + ArrayNode.arrayNode( + ObjectNode.objectNode().withMember("relativePath", generatedServer.toString()) + .withMember("name", "a"), + ), + ) + .withMember( + "runtimeConfig", + Node.objectNode().withMember( + "relativePath", + Node.from(((TestRuntimeConfig).runtimeCrateLocation).path), + ), + ), + ).build() + FuzzHarnessBuildPlugin().execute(context) + context.fileManifest.printGeneratedFiles() + context.fileManifest.files.map { it.fileName.toString() } shouldContain "lexicon.json" + "cargo check".runCommand(context.fileManifest.baseDir.resolve("a")) + } +} diff --git a/rust-runtime/aws-smithy-fuzz/Cargo.toml b/rust-runtime/aws-smithy-fuzz/Cargo.toml new file mode 100644 index 000000000..faec250e6 --- /dev/null +++ b/rust-runtime/aws-smithy-fuzz/Cargo.toml @@ -0,0 +1,44 @@ +[workspace] +[package] +name = "aws-smithy-fuzz" +version = "0.1.0" +authors = ["AWS Rust SDK Team "] +description = "Fuzzing utilities for smithy-rs servers" +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/smithy-lang/smithy-rs" + +[dependencies] +afl = "0.15.10" +arbitrary = { version = "1.3.2", features = ["derive"] } +bincode = "1" +bytes = "1.7.1" +cargo_toml = "0.20.4" +cbor-diag = "0.1.12" +clap = { version = "4.5.15", features = ["derive"] } +ffi-support = "0.4.4" +futures = "0.3.30" +glob = "0.3.1" +homedir = "0.3" +http = "0.2" +http-body = "0.4" +lazy_static = "1.5.0" +libloading = "0.8.5" +serde = { version = "1.0.204", features = ["derive"] } +serde_json = "1.0.124" +tera = "1.20.0" +termcolor = "1.4.1" +tokio = { version = "1.39.2", features = ["sync", "rt", "rt-multi-thread"] } +tower = { version = "0.4.13", features = ["util"] } +tracing = "0.1.40" +tracing-subscriber = "0.3.18" + +[profile.release] +debug = true + +[package.metadata.docs.rs] +all-features = true +targets = ["x86_64-unknown-linux-gnu"] +cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] +rustdoc-args = ["--cfg", "docsrs"] +# End of docs.rs metadata diff --git a/rust-runtime/aws-smithy-fuzz/LICENSE b/rust-runtime/aws-smithy-fuzz/LICENSE new file mode 100644 index 000000000..67db85882 --- /dev/null +++ b/rust-runtime/aws-smithy-fuzz/LICENSE @@ -0,0 +1,175 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/rust-runtime/aws-smithy-fuzz/README.md b/rust-runtime/aws-smithy-fuzz/README.md new file mode 100644 index 000000000..52b716bf6 --- /dev/null +++ b/rust-runtime/aws-smithy-fuzz/README.md @@ -0,0 +1,186 @@ +# aws-smithy-fuzz + +AWS Smithy fuzz contains a set of utilities for writing fuzz tests against smithy-rs servers. This is part of our tooling to perform differential fuzzing against different versions of smithy-rs servers. + +## Installation +1. Install `cargo afl`: `cargo install cargo-afl` +2. Install the AFL runtime: `cargo afl config --build` +2. Install the smithy CLI: +2. Install `aws-smithy-fuzz`: + - Locally: `cargo afl install --path .` + - From crates.io: cargo afl install aws-smithy-fuzz + > **IMPORTANT**: This package MUST be installed with `cargo afl install` (instead of `cargo install`). If you do not use `afl`, + > you will get linking errors. + +## Usage +This contains a library + a CLI tool to fuzz smithy-rs servers. The library allows setting up a given smithy-rs server implementation as a `cdylib`. This allows two different versions two by dynamically linked at runtime and executed by the fuzzer. + +Each of these components are meant to be usable independently: +1. The public APIs of `aws-smithy-fuzz` can be used to write your own fuzz targets without code generation. +2. The `lexicon.json` can be used outside of this project to seed a fuzzer from a Smithy model. +3. The fuzz driver can be used on other fuzz targets. + +### Setup +First, you'll need to generate the 1 (or more) versions of a smithy-rs server to test against. The best way to do this is by using the smithy CLI. **This process is fully automated with the `aws-smithy-fuzz setup-smithy`. The following docs are in place in case you want to alter the behavior.** + +There is nothing magic about what `setup-smithy` does, but it does save you some tedious setup. + +```bash +aws-smithy-fuzz setup-smithy --revision fix-timestamp-from-f64 --service smithy.protocoltests.rpcv2Cbor#RpcV2Protocol --workdir fuzz-workspace-cbor2 --fuzz-runner-local-path smithy-rs --dependency software.amazon.smithy:smithy-protocol-tests:1.50.0 --rebuild-local-targets +``` +
+ +Details of functionality of `setup-smithy`. This can be helpful if you need to do something slightly different. + + +```bash +# Create a workspace just to keep track of everything +mkdir workspace && cd workspace +REVISION_1=main +REVISION_2=76d5afb42d545ca2f5cbe90a089681135da935d3 +rm -rf maven-locals && mkdir maven-locals +# Build two different versions of smithy-rs and publish them to two separate local directories +git clone https://github.com/smithy-lang/smithy-rs.git smithy-rs1 && (cd smithy-rs1 && git checkout $REVISION_1 && ./gradlew publishToMavenLocal -Dmaven.repo.local=$(cd ../maven-locals && pwd)/$REVISION_1) +git clone https://github.com/smithy-lang/smithy-rs.git smithy-rs2 && (cd smithy-rs2 && git checkout $REVISION_2 && ./gradlew publishToMavenLocal -Dmaven.repo.local=$(cd ../maven-locals && pwd)/$REVISION_2) +``` + +For each of these, use the smithy CLI to generate a server implementation using something like this: +``` +{ + "version": "1.0", + "maven": { + "dependencies": [ + "software.amazon.smithy.rust.codegen.server.smithy:codegen-server:0.1.0", + "software.amazon.smithy:smithy-aws-protocol-tests:1.50.0" + ], + "repositories": [ + { + "url": "file://maven-locals/" + }, + { + "url": "https://repo1.maven.org/maven2" + } + ] + }, + "projections": { + "server": { + "imports": [ + ], + "plugins": { + "rust-server-codegen": { + "runtimeConfig": { + "relativePath": "/Users/rcoh/code/smithy-rs/rust-runtime" + }, + "codegen": {}, + // PICK YOUR SERVICE + "service": "aws.protocoltests.restjson#RestJson", + "module": "rest_json", + "moduleVersion": "0.0.1", + "moduleDescription": "test", + "moduleAuthors": [ + "protocoltest@example.com" + ] + } + } + } + } +} +``` + +Next, you'll use the `fuzzgen` target to generate two things based on your target crates: +1. A `lexicon.json` file: This uses information from the smithy model to seed the fuzzer with some initial inputs and helps it get better code coverage. +2. Fuzz target shims for your generated servers. These each implement most of the operations available in the smithy model and wire up each target crate with the correct bindings to create a cdylib crate that can be used by the fuzzer. + +The easiest way to use `fuzzgen` is with the Smithy CLI: + +```json +{ + "version": "1.0", + "maven": { + "dependencies": [ + "software.amazon.smithy.rust.codegen.serde:fuzzgen:0.1.0", + "software.amazon.smithy:smithy-aws-protocol-tests:1.50.0" + ] + }, + "projections": { + "harness": { + "imports": [], + "plugins": { + "fuzz-harness": { + "service": "aws.protocoltests.restjson#RestJson", + "runtimeConfig": { + "relativePath": "/Users/rcoh/code/smithy-rs/rust-runtime" + }, + "targetCrates": [ + { + "relativePath": "target-mainline/build/smithy/server/rust-server-codegen/", + "name": "mainline" + }, + { + "relativePath": "target-previous-release/build/smithy/server/rust-server-codegen/", + "name": "previous-release" + } + ] + } + } + } + } +} +``` +
+ +### Initialization and Fuzzing +After `setup-smithy` creates the target shims, use `aws-smithy initialize` to setup ceremony required for `AFL` to function: +``` +aws-smithy-fuzz initialize --lexicon --target-crate --target-crate +``` + +> **Important**: These are the crates generated by `fuzzgen`, not the crates you generated for the different smithy versions. + +This may take a couple of minutes as it builds each crate. + +To start the fuzz test use: +``` +aws-smithy-fuzz fuzz +``` + +The fuzz session should start (although AFL may prompt you to run some configuration commands.) + +You should see something like this: +``` + AFL ++4.21c {default} (/Users/rcoh/.cargo/bin/aws-smithy-fuzz) [explore] +┌─ process timing ────────────────────────────────────┬─ overall results ────┐ +│ run time : 0 days, 0 hrs, 36 min, 18 sec │ cycles done : 78 │ +│ last new find : 0 days, 0 hrs, 0 min, 17 sec │ corpus count : 1714 │ +│last saved crash : 0 days, 0 hrs, 19 min, 25 sec │saved crashes : 3 │ +│ last saved hang : none seen yet │ saved hangs : 0 │ +├─ cycle progress ─────────────────────┬─ map coverage┴──────────────────────┤ +│ now processing : 145.173 (8.5%) │ map density : 0.07% / 29.11% │ +│ runs timed out : 0 (0.00%) │ count coverage : 1.52 bits/tuple │ +├─ stage progress ─────────────────────┼─ findings in depth ─────────────────┤ +│ now trying : splice 12 │ favored items : 615 (35.88%) │ +│ stage execs : 11/12 (91.67%) │ new edges on : 802 (46.79%) │ +│ total execs : 38.3M │ total crashes : 6 (3 saved) │ +│ exec speed : 16.0k/sec │ total tmouts : 39 (0 saved) │ +├─ fuzzing strategy yields ────────────┴─────────────┬─ item geometry ───────┤ +│ bit flips : 4/47.6k, 1/47.5k, 2/47.5k │ levels : 27 │ +│ byte flips : 0/5945, 0/5927, 0/5891 │ pending : 2 │ +│ arithmetics : 14/415k, 0/826k, 0/821k │ pend fav : 0 │ +│ known ints : 0/53.4k, 3/224k, 0/329k │ own finds : 1572 │ +│ dictionary : 4/4.38M, 0/4.40M, 4/1.48M, 0/1.48M │ imported : 0 │ +│havoc/splice : 756/13.1M, 184/24.5M │ stability : 96.92% │ +│py/custom/rq : unused, unused, 581/381k, 1/183 ├───────────────────────┘ +│ trim/eff : 32.14%/274k, 99.65% │ [cpu: 62%] +└─ strategy: explore ────────── state: in progress ──┘ +``` +(but with more pretty colors). + +## Replaying Crashes + +Run `aws-smithy-fuzz replay`. This will rerun all the crashes in the crashes folder. Other options exist, see: `aws-smithy-fuzz replay --help`. + +**You can run replay from another terminal while fuzzing is in process.** + + +This crate is part of the [AWS SDK for Rust](https://awslabs.github.io/aws-sdk-rust/) and the [smithy-rs](https://github.com/smithy-lang/smithy-rs) code generator. In most cases, it should not be used directly. + diff --git a/rust-runtime/aws-smithy-fuzz/src/lib.rs b/rust-runtime/aws-smithy-fuzz/src/lib.rs new file mode 100644 index 000000000..4f1287df7 --- /dev/null +++ b/rust-runtime/aws-smithy-fuzz/src/lib.rs @@ -0,0 +1,203 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* Automatically managed default lints */ +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +/* End of automatically managed default lints */ +#![cfg(not(windows))] +use libloading::{os, Library, Symbol}; +use std::error::Error; +use tokio::sync::mpsc::Sender; + +use futures::task::noop_waker; +use std::future::Future; +use std::path::Path; +use std::pin::pin; +use std::sync::Mutex; +use std::task::{Context, Poll}; +use tower::ServiceExt; +mod types; +pub use lazy_static; +pub use types::{Body, FuzzResult, HttpRequest, HttpResponse}; + +#[macro_export] +/// Defines an extern `process_request` method that can be invoked as a shared library +macro_rules! fuzz_harness { + ($test_function: expr) => { + $crate::lazy_static::lazy_static! { + static ref TARGET: std::sync::Mutex<$crate::LocalFuzzTarget> = $crate::make_target($test_function); + } + + #[no_mangle] + pub extern "C" fn process_request(input: *const u8, len: usize) -> $crate::ByteBuffer { + let slice = unsafe { std::slice::from_raw_parts(input, len) }; + let request = $crate::HttpRequest::from_bytes(slice); + let response = TARGET.lock().unwrap().invoke( + request + .into_http_request_04x() + .expect("input was not a valid HTTP request. Bug in driver."), + ); + $crate::ByteBuffer::from_vec(response.into_bytes()) + } + }; +} + +pub use ::ffi_support::ByteBuffer; +use bytes::Buf; +use tokio::runtime::{Builder, Handle}; +use tower::util::BoxService; + +#[derive(Clone)] +pub struct FuzzTarget(os::unix::Symbol ByteBuffer>); +impl FuzzTarget { + pub fn from_path(path: impl AsRef) -> Self { + let path = path.as_ref(); + eprintln!("loading library from {}", path.display()); + let library = unsafe { Library::new(path).expect("could not load library") }; + let func: Symbol ByteBuffer> = + unsafe { library.get(b"process_request").unwrap() }; + // ensure we never unload the library + let func = unsafe { func.into_raw() }; + std::mem::forget(library); + + Self(func) + } + + pub fn invoke_bytes(&self, input: &[u8]) -> FuzzResult { + let buffer = unsafe { (self.0)(input.as_ptr(), input.len()) }; + let data = buffer.destroy_into_vec(); + FuzzResult::from_bytes(&data) + } + + pub fn invoke(&self, request: &HttpRequest) -> FuzzResult { + let input = request.as_bytes(); + self.invoke_bytes(&input) + } +} + +pub struct LocalFuzzTarget { + service: BoxService, http::Response>, Box>, + rx: tokio::sync::mpsc::Receiver, +} + +impl LocalFuzzTarget { + pub fn invoke(&mut self, request: http::Request) -> FuzzResult { + assert_ready(async move { + let result = ServiceExt::oneshot(&mut self.service, request) + .await + .unwrap(); + let debug = self.rx.try_recv().ok(); + if result.status().is_success() && debug.is_none() { + panic!("success but no debug data received"); + } + let (parts, body) = result.into_parts(); + FuzzResult { + input: debug, + response: HttpResponse { + status: parts.status.as_u16(), + headers: parts + .headers + .iter() + .map(|(key, value)| (key.to_string(), value.to_str().unwrap().to_string())) + .collect(), + body, + }, + } + }) + } +} + +/// Create a target from a tower service. +/// +/// A `Sender` is passed in. The service should send a deterministic string +/// based on the parsed input. +pub fn make_target< + D: Send + Buf, + B: http_body::Body + Send + 'static, + F: Send + 'static, + E: Into>, + T: tower::Service, Response = http::Response, Future = F, Error = E> + + Send + + 'static, +>( + service: impl Fn(Sender) -> T, +) -> Mutex { + let (tx, rx) = tokio::sync::mpsc::channel(1); + let service = + service(tx) + .map_err(|e| e.into()) + .and_then(|resp: http::Response| async move { + let (parts, body) = resp.into_parts(); + let body = body.collect().await.ok().unwrap().to_bytes().to_vec(); + Ok::<_, Box>(http::Response::from_parts(parts, body)) + }); + let service = BoxService::new(service); + Mutex::new(LocalFuzzTarget { service, rx }) +} + +#[allow(unused)] +fn assert_ready_tokio(future: F) -> F::Output { + match Handle::try_current() { + Ok(handle) => handle.block_on(future), + Err(_) => { + let handle = Builder::new_multi_thread().build().unwrap(); + handle.block_on(future) + } + } +} + +/// Polls a future and panics if it isn't already ready. +fn assert_ready(mut future: F) -> F::Output { + // Create a waker that does nothing. + let waker = noop_waker(); + // Create a context from the waker. + let mut cx = Context::from_waker(&waker); + let mut future = pin!(future); + match future.as_mut().poll(&mut cx) { + Poll::Ready(output) => output, + Poll::Pending => { + panic!("poll pending...") + } + } +} + +#[cfg(test)] +mod test { + use crate::{make_target, Body}; + use http::Request; + use std::error::Error; + use std::future::Future; + use std::pin::Pin; + use std::task::{Context, Poll}; + use tokio::sync::mpsc::Sender; + + fuzz_harness!(|_tx| { TestService }); + + fn make_service(_sender: Sender) -> TestService { + TestService + } + + #[test] + fn test() { + make_target(make_service); + } + + struct TestService; + + impl tower::Service> for TestService { + type Response = http::Response; + type Error = Box; + type Future = + Pin> + Send + Sync>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + todo!() + } + + fn call(&mut self, _req: Request) -> Self::Future { + todo!() + } + } +} diff --git a/rust-runtime/aws-smithy-fuzz/src/main.rs b/rust-runtime/aws-smithy-fuzz/src/main.rs new file mode 100644 index 000000000..0f2101974 --- /dev/null +++ b/rust-runtime/aws-smithy-fuzz/src/main.rs @@ -0,0 +1,945 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +#![cfg(not(windows))] + +use aws_smithy_fuzz::{FuzzResult, FuzzTarget, HttpRequest}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::fmt::Display; +use std::io::{BufRead, BufWriter}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::SystemTime; +use std::{env, fs}; + +use clap::{Parser, Subcommand}; +use tera::{Context, Tera}; +use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Invoke the fuzz driver + Fuzz(FuzzArgs), + + /// Initialize the fuzz configuration + /// + /// This command will compile cdylib shared libraries and create a + Initialize(InitializeArgs), + + /// Replay subcommand + Replay(ReplayArgs), + + /// Setup smithy-rs targets + /// + /// This does all of the schlep of setting of smithy-rs copies at different revisions for fuzz testing + /// This command is not strictly necessary—It's pretty easy to wire this up yourself. But + /// this removes a lot of boilerplate. + SetupSmithy(SetupSmithyArgs), + + /// Invoke Testcase + /// + /// Directly invoke a test case against a given shared library target. Generally, replay will + /// be easier to use. + /// + /// This exists to facilitate invoking a cdylib that may panic without crashing the main + /// process + InvokeTestCase(InvokeArgs), +} + +#[derive(Parser)] +struct InvokeArgs { + #[arg(short, long)] + shared_library_path: PathBuf, + + #[arg(short, long)] + test_case: PathBuf, +} + +#[derive(Parser)] +struct SetupSmithyArgs { + #[arg(short, long)] + revision: Vec, + #[arg(short, long)] + service: String, + #[arg(short, long)] + workdir: Option, + + #[arg(short, long)] + fuzz_runner_local_path: PathBuf, + + /// Rebuild the local clones of smithy-rs + #[arg(long)] + rebuild_local_targets: bool, + + /// Additional dependencies to use. Usually, these provide the model. + /// + /// Dependencies should be in the following format: `software.amazon.smithy:smithy-aws-protocol-tests:1.50.0` + #[arg(long)] + dependency: Vec, +} + +#[derive(Parser)] +struct FuzzArgs { + /// Custom path to the configuration file. + #[arg( + short, + long, + value_name = "PATH", + default_value = "smithy-fuzz-config.json" + )] + config_path: String, + + #[arg(long)] + enter_fuzzing_loop: bool, + + /// The number of parallel fuzzers to run + #[arg(short, long)] + num_fuzzers: Option, +} + +#[derive(Parser)] +struct InitializeArgs { + /// Path to the target crates to fuzz (repeat for multiple crates) + #[arg(short, long, value_name = "PATH")] + target_crate: Vec, + + /// Path to the `lexicon.json` defining the dictionary and corpus + /// + /// This file is typically produced by the fuzzgen smithy plugin. + #[arg(short, long, value_name = "PATH")] + lexicon: PathBuf, + + /// Custom path to the configuration file. + #[arg( + short, + long, + value_name = "PATH", + default_value = "smithy-fuzz-config.json" + )] + config_path: String, + + /// Force a rebuild of target artifacts. + /// + /// **NOTE**: You must use this if you change the target artifacts + #[arg(short, long)] + force_rebuild: bool, + + /// Compile the target artifacts in release mode + #[arg(short, long)] + release: bool, +} + +/// Replay crashes from a fuzz run +/// +/// By default, this will automatically discover all crashes during fuzzing sessions and rerun them. +/// To rerun a single crash, use `--invoke-only`. +#[derive(Parser)] +struct ReplayArgs { + /// Custom path to the configuration file. + #[arg( + short, + long, + value_name = "PATH", + default_value = "smithy-fuzz-config.json" + )] + config_path: String, + + /// Invoke only the specified path. + #[arg(short, long)] + invoke_only: Option, + + /// Output results in JSON + #[arg(short, long)] + json: bool, + + /// Run against the input corpus instead of running against crashes + /// + /// This is helpful for sanity checking that everything is working properly + #[arg(long)] + corpus: bool, +} + +#[derive(Deserialize)] +struct Lexicon { + corpus: Vec, + dictionary: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +struct FuzzConfig { + seed: PathBuf, + targets: Vec, + afl_input_dir: PathBuf, + afl_output_dir: PathBuf, + dictionaries: Vec, + lexicon: PathBuf, +} + +#[derive(Serialize, Deserialize, Debug)] +struct Target { + package_name: String, + source: PathBuf, + shared_library: Option, +} + +impl Target { + fn human_name(&self) -> String { + determine_package_name(&self.source.join("Cargo.toml")) + } +} + +fn main() { + tracing_subscriber::fmt::init(); + let cli = Cli::parse(); + match cli.command { + Commands::Fuzz(args) => fuzz(args), + Commands::Initialize(args) => initialize(args), + Commands::Replay(args) => replay(args), + Commands::SetupSmithy(args) => setup_smithy(args), + Commands::InvokeTestCase(InvokeArgs { + test_case, + shared_library_path, + }) => invoke_testcase(test_case, shared_library_path), + } +} + +fn setup_smithy(args: SetupSmithyArgs) { + let current_dir = env::current_dir().unwrap(); + let workdir = match &args.workdir { + Some(relative_workdir) => current_dir.join(relative_workdir), + None => current_dir.clone(), + }; + let maven_locals = workdir.join("maven-locals"); + fs::create_dir_all(&maven_locals).unwrap(); + + let fuzz_driver_path = maven_locals.join("fuzz-driver"); + let local_path = current_dir.join(&args.fuzz_runner_local_path); + build_revision(&fuzz_driver_path, &local_path); + + let rust_runtime_for_fuzzer = local_path.join("rust-runtime"); + + let fuzzgen_smithy_build = + generate_smithy_build_json_for_fuzzer(&args, &fuzz_driver_path, &rust_runtime_for_fuzzer); + let fuzzgen_smithy_build_path = workdir.join("smithy-build-fuzzgen.json"); + fs::write(&fuzzgen_smithy_build_path, &fuzzgen_smithy_build).unwrap(); + + for revision in &args.revision { + let revision_dir = clone_smithyrs(&workdir, &revision, args.rebuild_local_targets); + + let maven_local_subpath = maven_locals.join(&revision); + build_revision(&maven_local_subpath, &revision_dir); + let fuzzgen_smithy_build = + generate_smithy_build_for_target(&maven_local_subpath, &args, &revision, &revision_dir); + let smithy_build_path = workdir.join(format!("smithy-build-{revision}.json")); + fs::write(&smithy_build_path, assert_valid_json(&fuzzgen_smithy_build)).unwrap(); + smithy_build(&workdir, &smithy_build_path); + } + println!("running smithy build for fuzz harness"); + smithy_build(&workdir, &fuzzgen_smithy_build_path); +} + +fn generate_smithy_build_for_target( + maven_local_subpath: &Path, + args: &SetupSmithyArgs, + revision: &str, + revision_dir: &Path, +) -> String { + let mut context = Context::new(); + context.insert("maven_local", &maven_local_subpath); + context.insert("service", &args.service); + context.insert("revision", revision); + context.insert("rust_runtime", &revision_dir.join("rust-runtime")); + context.insert("dependencies", &args.dependency); + + let fuzzgen_smithy_build = Tera::one_off( + include_str!("../templates/smithy-build-targetcrate.jinja2"), + &context, + false, + ) + .unwrap(); + assert_valid_json(fuzzgen_smithy_build) +} + +fn generate_smithy_build_json_for_fuzzer( + args: &SetupSmithyArgs, + fuzz_driver_path: &Path, + rust_runtime_for_fuzzer: &Path, +) -> String { + let mut context = Context::new(); + context.insert("service", &args.service); + context.insert("revisions", &args.revision); + context.insert("maven_local", &fuzz_driver_path); + context.insert("rust_runtime", &rust_runtime_for_fuzzer); + context.insert("dependencies", &args.dependency); + + let fuzzgen_smithy_build = Tera::one_off( + include_str!("../templates/smithy-build-fuzzer.jinja2"), + &context, + false, + ) + .unwrap(); + assert_valid_json(fuzzgen_smithy_build) +} + +/// Run smithy build for a given file +/// +/// # Arguments +/// +/// * `workdir`: Path to the working directory (this is where the `build` directory) will be created / used +/// * `smithy_build_json`: Path to the smithy-build.json file +fn smithy_build(workdir: impl AsRef, smithy_build_json: impl AsRef) { + println!( + "running smithy build for {}", + smithy_build_json.as_ref().display() + ); + // Need to delete classpath.json if it exists to work around small bug in smithy CLI: + // https://github.com/smithy-lang/smithy/issues/2376 + let _ = fs::remove_file(workdir.as_ref().join("build/smithy").join("classpath.json")); + + let home_dir = homedir::my_home().unwrap().unwrap(); + exec( + Command::new("rm") + .arg("-r") + .arg(format!("{}/.m2", home_dir.display())) + .current_dir(&workdir), + ); + exec( + Command::new("smithy") + .arg("build") + .arg("-c") + .arg(smithy_build_json.as_ref()) + .current_dir(workdir), + ); +} + +/// Creates a copy of the smithy-rs repository at a specific revision. +/// +/// - If it does not already exist, it will create a local clone of the entire repo. +/// - After that, it will make a local clone of that repo to facilitate checking out a specific +/// revision +/// +/// # Arguments +/// +/// * `workdir`: Working directory to clone into +/// * `revision`: Revision to check out +/// * `maven_local`: Path to a revisione-specific maven-local directory to build into +/// +/// returns: Path to the cloned directory +fn clone_smithyrs(workdir: impl AsRef, revision: &str, recreate: bool) -> PathBuf { + let smithy_dir = workdir.as_ref().join("smithy-rs-src"); + if !smithy_dir.exists() { + exec( + Command::new("git") + .args(["clone", "https://github.com/smithy-lang/smithy-rs.git"]) + .arg(&smithy_dir) + .current_dir(&workdir), + ); + } + let copies_dir = workdir.as_ref().join("smithy-rs-copies"); + fs::create_dir_all(&copies_dir).unwrap(); + + let revision_dir = copies_dir.join(revision); + if revision_dir.exists() && !recreate { + return revision_dir; + } + exec( + Command::new("rm") + .arg("-rf") + .arg(&revision_dir) + .current_dir(&workdir), + ); + + exec( + Command::new("git") + .arg("clone") + .arg(smithy_dir) + .arg(&revision_dir) + .current_dir(&workdir), + ); + + exec( + Command::new("git") + .args(["checkout", &revision]) + .current_dir(&revision_dir), + ); + revision_dir +} + +fn exec(command: &mut Command) { + match command.get_current_dir() { + None => panic!("BUG: all commands should set a working directory"), + Some(dir) if !dir.is_absolute() => panic!("bug: absolute directory should be set"), + _ => {} + }; + let status = match command.spawn().unwrap().wait() { + Ok(status) => status, + Err(e) => { + panic!("{:?} failed: {}", command, e) + } + }; + if !status.success() { + panic!("command failed: {:?}", command); + } +} + +/// Runs `./gradlew publishToMavenLocal` on a given smithy-rs directory +/// +/// # Arguments +/// +/// * `maven_local`: mavenLocal directory to publish into +/// * `smithy_dir`: smithy-rs source directory to use +fn build_revision(maven_local: impl AsRef, smithy_dir: impl AsRef) { + tracing::info!("building revision from {}", smithy_dir.as_ref().display()); + exec( + Command::new("./gradlew") + .args([ + "publishToMavenLocal", + &format!("-Dmaven.repo.local={}", maven_local.as_ref().display()), + ]) + .current_dir(&smithy_dir), + ) +} + +fn assert_valid_json>(data: T) -> T { + match serde_json::from_str::(data.as_ref()) { + Err(e) => panic!( + "failed to generate valid JSON. this is a bug. {}\n\n{}", + e, + data.as_ref() + ), + Ok(_) => {} + }; + data +} + +fn force_load_libraries(libraries: &[Target]) -> Vec { + libraries + .iter() + .map(|t| { + t.shared_library + .as_ref() + .expect("shared library must be built! run `aws-smithy-fuzz initialize`") + }) + .map(FuzzTarget::from_path) + .collect::>() +} + +/// Starts a fuzz session +/// +/// This function is a little bit of an snake eating its tail. When it is initially run, +/// it ensures everything is set up properly, then it invokes AFL, passing through +/// all the relevant flags. AFL is actually going to come right back in here—(but with `enter_fuzzing_loop`) +/// set to true. In that case, we just prepare to start actually fuzzing the targets. +fn fuzz(args: FuzzArgs) { + let config = fs::read_to_string(&args.config_path).unwrap(); + let config: FuzzConfig = serde_json::from_str(&config).unwrap(); + if args.enter_fuzzing_loop { + let libraries = force_load_libraries(&config.targets); + enter_fuzz_loop(libraries, None) + } else { + eprintln!( + "Preparing to start fuzzing... {} targets.", + config.targets.len() + ); + let base_command = || { + let mut cmd = Command::new("cargo"); + cmd.args(["afl", "fuzz"]) + .arg("-i") + .arg(&config.lexicon) + .arg("-o") + .arg(&config.afl_output_dir); + + for dict in &config.dictionaries { + cmd.arg("-x").arg(dict); + } + cmd + }; + + let apply_target = |mut cmd: Command| { + let current_binary = + std::env::current_exe().expect("could not determine current target"); + cmd.arg(current_binary) + .arg("fuzz") + .arg("--config-path") + .arg(&args.config_path) + .arg("--enter-fuzzing-loop"); + cmd + }; + let mut main_runner = base_command(); + main_runner.arg("-M").arg("fuzzer0"); + + eprintln!( + "Switching to AFL with the following command:\n{:?}", + main_runner + ); + let mut main = apply_target(main_runner).spawn().unwrap(); + let mut children = vec![]; + for idx in 1..args.num_fuzzers.unwrap_or_default() { + let mut runner = base_command(); + runner.arg("-S").arg(format!("fuzzer{}", idx)); + runner.stderr(Stdio::null()).stdout(Stdio::null()); + children.push(KillOnDrop(apply_target(runner).spawn().unwrap())); + } + main.wait().unwrap(); + } +} +struct KillOnDrop(Child); +impl Drop for KillOnDrop { + fn drop(&mut self) { + self.0.kill().unwrap(); + } +} + +fn yellow(text: impl Display) { + let mut stdout = StandardStream::stderr(ColorChoice::Auto); + use std::io::Write; + stdout + .set_color(ColorSpec::new().set_fg(Some(Color::Yellow))) + .unwrap(); + writeln!(&mut stdout, "{}", text).unwrap(); + stdout.reset().unwrap(); +} + +fn initialize( + InitializeArgs { + target_crate, + lexicon, + config_path, + force_rebuild, + release, + }: InitializeArgs, +) { + let mode = match release { + true => Mode::Release, + false => Mode::Debug, + }; + let current_config = if Path::new(&config_path).exists() { + let config_data = fs::read_to_string(&config_path).unwrap(); + let config: FuzzConfig = serde_json::from_str(&config_data).unwrap(); + Some(config) + } else { + None + }; + let current_targets = current_config.map(|c| c.targets).unwrap_or_default(); + let targets = if current_targets + .iter() + .map(|t| &t.source) + .collect::>() + != target_crate.iter().collect::>() + { + yellow("The target crates specified in the configuration file do not match the current target crates."); + eprintln!( + "Initializing the fuzzer with {} target crates.", + target_crate.len() + ); + target_crate + .into_iter() + .map(initialize_target) + .collect::>() + } else { + current_targets + }; + if targets.is_empty() { + yellow("No target crates specified, nothing to do."); + } + + let afl_input_dir = Path::new("afl-input"); + let afl_output_dir = Path::new("afl-output"); + + let mut config = FuzzConfig { + seed: lexicon, + targets, + afl_input_dir: afl_input_dir.into(), + afl_output_dir: afl_output_dir.into(), + lexicon: afl_input_dir.join("corpus"), + dictionaries: vec![afl_input_dir.join("dictionary")], + }; + + let seed_request = fs::read_to_string(&config.seed).unwrap(); + let seed: Lexicon = serde_json::from_str(&seed_request).unwrap(); + write_seed(&config.afl_input_dir, &seed); + + for target in &mut config.targets { + if target.shared_library.is_none() || force_rebuild { + build_target(target, mode); + } + check_library_health(&FuzzTarget::from_path( + target.shared_library.as_ref().unwrap(), + )); + } + eprintln!("Writing settings to {}", config_path); + fs::write(&config_path, serde_json::to_string_pretty(&config).unwrap()).unwrap(); +} + +fn initialize_target(source: PathBuf) -> Target { + let package_id = determine_package_id(source.as_ref()); + Target { + package_name: package_id, + source, + shared_library: None, + } +} + +fn load_all_crashes(output_dir: &Path) -> Vec { + let pattern = output_dir.join("fuzzer*/crashes*"); + load_inputs_at_pattern(&pattern) +} + +fn load_corpus(input_dir: &Path) -> Vec { + let pattern = input_dir.join("corpus"); + load_inputs_at_pattern(&pattern) +} + +fn load_inputs_at_pattern(pattern: &Path) -> Vec { + eprintln!("searching for test cases in {}", pattern.display()); + let pattern = format!("{}", pattern.display()); + let mut crash_directories = glob::glob(&pattern).unwrap(); + let mut crashes = vec![]; + while let Some(Ok(crash_dir)) = crash_directories.next() { + for entry in fs::read_dir(crash_dir).unwrap() { + let entry = entry.unwrap().path(); + match entry.file_name().and_then(|f| f.to_str()) { + None | Some("README.txt") => {} + Some(_other) => crashes.push(entry), + } + } + } + crashes +} + +/// Replay crashes +fn replay( + ReplayArgs { + config_path, + invoke_only, + json, + corpus, + }: ReplayArgs, +) { + let config = fs::read_to_string(config_path).unwrap(); + let config: FuzzConfig = serde_json::from_str(&config).unwrap(); + let crashes = if let Some(path) = invoke_only { + vec![path.into()] + } else { + match corpus { + true => load_corpus(&config.afl_input_dir), + false => load_all_crashes(&config.afl_output_dir), + } + }; + eprintln!("Replaying {} crashes.", crashes.len()); + for crash in crashes { + eprintln!("{}", crash.display()); + let data = fs::read(&crash).unwrap(); + let http_request = HttpRequest::from_unknown_bytes(&data); + let mut results: HashMap = HashMap::new(); + #[derive(Debug, Serialize)] + #[serde(tag = "type")] + enum CrashResult { + Panic { message: String }, + FuzzResult { result: String }, + } + + impl Display for CrashResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CrashResult::Panic { message } => { + f.pad("The process paniced!\n")?; + for line in message.lines() { + write!(f, " {}\n", line)?; + } + Ok(()) + } + CrashResult::FuzzResult { result } => f.pad(result), + } + } + } + + for library in &config.targets { + let result = Command::new(env::current_exe().unwrap()) + .arg("invoke-test-case") + .arg("--shared-library-path") + .arg(library.shared_library.as_deref().unwrap()) + .arg("--test-case") + .arg(&crash) + .output() + .unwrap(); + let result = match serde_json::from_slice::(&result.stdout) { + Ok(result) => CrashResult::FuzzResult { + result: format!("{:?}", result), + }, + Err(_err) => CrashResult::Panic { + message: String::from_utf8_lossy(&result.stderr).to_string(), + }, + }; + results.insert(library.human_name(), result); + } + #[derive(Serialize)] + struct Results { + test_case: String, + results: HashMap, + } + impl Display for Results { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let test_case = &self.test_case; + write!(f, "Test case: {test_case}\n")?; + for (target, result) in &self.results { + write!(f, " target: {target}\n{:>2}", result)?; + } + Ok(()) + } + } + let results = Results { + test_case: format!("{:#?}", http_request.unwrap()), + results, + }; + if json { + println!("{}", serde_json::to_string(&results).unwrap()); + } else { + println!("{}\n----", results); + } + } +} + +fn invoke_testcase(test_case: impl AsRef, shared_library_path: impl AsRef) { + let data = fs::read(test_case).unwrap(); + let library = FuzzTarget::from_path(shared_library_path); + let data = data.clone(); + let result = library.invoke_bytes(&data.clone()); + println!("{}", serde_json::to_string(&result).unwrap()); +} + +/// Enters the fuzzing loop. This method should only be entered when `afl` is driving the binary +fn enter_fuzz_loop(libraries: Vec, mut log: Option>) { + afl::fuzz(true, |data: &[u8]| { + use std::io::Write; + #[allow(clippy::disallowed_methods)] + let start = SystemTime::now(); + + let http_request = HttpRequest::from_unknown_bytes(data); + if let Some(request) = http_request { + if request.into_http_request_04x().is_some() { + let mut results = vec![]; + for library in &libraries { + results.push(library.invoke_bytes(data)); + } + log.iter_mut().for_each(|log| { + log.write_all( + #[allow(clippy::disallowed_methods)] + format!( + "[{:?}ms] {:?} {:?}\n", + start.elapsed().unwrap().as_millis(), + request, + &results[0] + ) + .as_bytes(), + ) + .unwrap(); + }); + for result in &results { + if result.response != results[0].response { + if check_for_nondeterminism(data, &libraries) { + break; + } + panic!("inconsistent results: {:#?}", results); + } + } + } + } + }); +} + +fn check_for_nondeterminism(data: &[u8], libraries: &[FuzzTarget]) -> bool { + for lib in libraries { + let sample = (0..10) + .map(|_idx| lib.invoke_bytes(&data)) + .collect::>(); + if sample.iter().any(|result| result != &sample[0]) { + return true; + } + } + return false; +} + +/// Converts a JSON formatted seed to the format expected by AFL +/// +/// - Dictionary items are written out into a file +/// - Corpus items are bincode serialized so that the format matches +fn write_seed(target_directory: &Path, seed: &Lexicon) { + fs::create_dir_all(target_directory.join("corpus")).unwrap(); + for (id, request) in seed.corpus.iter().enumerate() { + std::fs::write( + target_directory.join("corpus").join(&format!("{}", id)), + request.as_bytes(), + ) + .unwrap(); + } + use std::fmt::Write; + let mut dictionary = String::new(); + for word in &seed.dictionary { + writeln!(dictionary, "\"{word}\"").unwrap(); + } + std::fs::write(target_directory.join("dictionary"), dictionary.as_bytes()).unwrap(); +} + +fn check_library_health(library: &FuzzTarget) { + let input = HttpRequest { + uri: "/NoInputAndNoOutput".to_string(), + method: "POST".to_string(), + headers: [("Accept".to_string(), vec!["application/json".to_string()])] + .into_iter() + .collect::>(), + trailers: Default::default(), + body: "{}".into(), + }; + library.invoke(&input); +} + +#[derive(Debug, Eq, PartialEq, Clone, Copy)] +enum Mode { + Release, + Debug, +} + +impl AsRef for Mode { + fn as_ref(&self) -> &Path { + match self { + Mode::Release => Path::new("release"), + Mode::Debug => Path::new("debug"), + } + } +} + +fn determine_package_name(path: &Path) -> String { + let cargo_toml_file = cargo_toml::Manifest::from_path(path).expect("invalid manifest"); + cargo_toml_file.package.unwrap().name +} + +fn determine_package_id(path: &Path) -> String { + let metadata = Command::new("cargo") + .args(["metadata", "--format-version", "1"]) + .current_dir(path) + .output() + .unwrap(); + let metadata: Value = match serde_json::from_slice(&metadata.stdout) { + Ok(v) => v, + Err(e) => panic!( + "failed to parse metadata: {}\n{}", + e, + String::from_utf8_lossy(&metadata.stderr) + ), + }; + let package_name = &metadata["workspace_members"][0].as_str().unwrap(); + package_name.to_string() +} + +fn build_target(target: &mut Target, mode: Mode) { + let mut cmd = Command::new("cargo"); + cmd.env( + "CARGO_TARGET_DIR", + env::current_dir() + .unwrap() + .join("target/fuzz-target-target"), + ); + cmd.args(["afl", "build", "--message-format", "json"]); + cmd.stdout(Stdio::piped()); + if mode == Mode::Release { + cmd.arg("--release"); + } + let json_output = cmd + .current_dir(&target.source) + .spawn() + .unwrap() + .wait_with_output() + .unwrap(); + + let shared_library = json_output + .stdout + .lines() + .filter_map(|line| cargo_output::find_shared_library(&line.unwrap(), target)) + .next() + .expect("failed to find shared library"); + target.shared_library = Some(PathBuf::from(shared_library)) +} + +mod cargo_output { + use crate::Target; + use serde::Deserialize; + + #[derive(Deserialize, Debug)] + struct DylibOutput { + reason: String, + package_id: String, + target: DyLibTarget, + filenames: Vec, + } + + #[derive(Deserialize, Debug)] + struct DyLibTarget { + kind: Vec, + } + + /// Reads a line of cargo output and look for the dylib + pub(super) fn find_shared_library(line: &str, target: &Target) -> Option { + tracing::trace!("{}", line); + let output: DylibOutput = match serde_json::from_str(line) { + Ok(output) => output, + Err(_e) => { + tracing::debug!("line does not match: {}", line); + return None; + } + }; + if output.reason != "compiler-artifact" { + return None; + } + if output.package_id != target.package_name { + tracing::debug!(expected = %target.package_name, actual = %output.package_id, "ignoring line—package was wrong"); + return None; + } + if output.target.kind != ["cdylib"] { + return None; + } + Some( + output + .filenames + .into_iter() + .next() + .expect("should be one dylib target"), + ) + } +} + +#[cfg(test)] +mod test { + use std::{env::temp_dir, path::PathBuf}; + + use crate::{ + generate_smithy_build_for_target, generate_smithy_build_json_for_fuzzer, setup_smithy, + SetupSmithyArgs, + }; + + #[test] + fn does_this_work() { + let path = PathBuf::new(); + let args = SetupSmithyArgs { + revision: vec!["main".into()], + service: "test-service".to_string(), + workdir: Some(temp_dir()), + fuzz_runner_local_path: "../".into(), + rebuild_local_targets: true, + dependency: vec![], + }; + generate_smithy_build_json_for_fuzzer(&args, &path, &path); + generate_smithy_build_for_target(&path, &args, "revision", &path); + } +} diff --git a/rust-runtime/aws-smithy-fuzz/src/types.rs b/rust-runtime/aws-smithy-fuzz/src/types.rs new file mode 100644 index 000000000..220a6e1b6 --- /dev/null +++ b/rust-runtime/aws-smithy-fuzz/src/types.rs @@ -0,0 +1,162 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use arbitrary::Arbitrary; +use std::convert::Infallible; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::{collections::HashMap, fmt::Debug}; + +use bytes::Bytes; +use http::{HeaderMap, HeaderName, Method}; +use serde::{Deserialize, Serialize}; + +pub struct Body { + body: Option>, + trailers: Option, +} + +impl Body { + pub fn from_bytes(bytes: Vec) -> Self { + Self { + body: Some(bytes), + trailers: None, + } + } + pub fn from_static(bytes: &'static [u8]) -> Self { + Self { + body: Some(bytes.into()), + trailers: None, + } + } +} + +impl http_body::Body for Body { + type Data = Bytes; + type Error = Infallible; + + fn poll_data( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll>> { + match self.as_mut().body.take() { + Some(data) => Poll::Ready(Some(Ok(data.into()))), + None => Poll::Ready(None), + } + } + + fn poll_trailers( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll, Self::Error>> { + match self.as_mut().trailers.take() { + Some(trailers) => Poll::Ready(Ok(Some(trailers))), + None => Poll::Ready(Ok(None)), + } + } +} + +#[derive(Serialize, Deserialize, Default, Clone, PartialEq, Eq, Arbitrary)] +pub struct HttpRequest { + pub uri: String, + pub method: String, + pub headers: HashMap>, + pub trailers: HashMap>, + pub body: Vec, +} + +#[derive(Serialize, Deserialize, Default, Clone, PartialEq, Eq)] +pub struct HttpResponse { + pub status: u16, + pub headers: HashMap, + pub body: Vec, +} + +impl Debug for HttpResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HttpResponse") + .field("status", &self.status) + .field("headers", &self.headers) + .field("body", &TryString(&self.body)) + .finish() + } +} + +impl Debug for HttpRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HttpRequest") + .field("uri", &self.uri) + .field("method", &self.method) + .field("headers", &self.headers) + .field("body", &TryString(&self.body)) + .finish() + } +} + +struct TryString<'a>(&'a [u8]); +impl Debug for TryString<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let try_cbor = cbor_diag::parse_bytes(self.0); + let str_rep = match try_cbor { + Ok(repr) => repr.to_diag_pretty(), + Err(_e) => String::from_utf8_lossy(self.0).to_string(), + }; + write!(f, "\"{}\"", str_rep) + } +} + +#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] +pub struct FuzzResult { + pub response: HttpResponse, + pub input: Option, +} + +impl FuzzResult { + pub fn into_bytes(self) -> Vec { + bincode::serialize(&self).unwrap() + } + + pub fn from_bytes(bytes: &[u8]) -> Self { + bincode::deserialize(bytes).unwrap() + } +} + +impl HttpRequest { + pub fn into_http_request_04x(&self) -> Option> { + let mut builder = http::Request::builder() + .uri(&self.uri) + .method(Method::from_bytes(self.method.as_bytes()).ok()?); + for (key, values) in &self.headers { + for value in values { + builder = builder.header(key, value); + } + } + let mut trailers = HeaderMap::new(); + for (k, v) in &self.trailers { + let header_name: HeaderName = k.parse().ok()?; + for v in v { + trailers.append(header_name.clone(), v.parse().ok()?); + } + } + builder + .body(Body { + body: Some(self.body.clone()), + trailers: Some(trailers), + }) + .ok() + } + + pub fn as_bytes(&self) -> Vec { + bincode::serialize(self).unwrap() + } + + pub fn from_bytes(bytes: &[u8]) -> Self { + bincode::deserialize(bytes).unwrap() + } + + pub fn from_unknown_bytes(bytes: &[u8]) -> Option { + bincode::deserialize(bytes).ok() + } +} diff --git a/rust-runtime/aws-smithy-fuzz/templates/smithy-build-fuzzer.jinja2 b/rust-runtime/aws-smithy-fuzz/templates/smithy-build-fuzzer.jinja2 new file mode 100644 index 000000000..e355acfd6 --- /dev/null +++ b/rust-runtime/aws-smithy-fuzz/templates/smithy-build-fuzzer.jinja2 @@ -0,0 +1,41 @@ +{ + "version": "1.0", + "maven": { + "dependencies": [ + {% for dependency in dependencies -%} + "{{ dependency }}", + {% endfor %} + "software.amazon.smithy.rust.codegen.serde:fuzzgen:0.1.0" + ], + "repositories": [ + { + "url": "file://{{ maven_local }}" + }, + { + "url": "https://repo1.maven.org/maven2" + } + ] + }, + "projections": { + "harness": { + "imports": [ ], + "plugins": { + "fuzz-harness": { + "service": "{{ service }}", + "runtimeConfig": { + "relativePath": "{{ rust_runtime }}" + }, + "targetCrates": [ + {% for revision in revisions -%} + { + "relativePath": "build/smithy/{{ revision }}/rust-server-codegen/", + "name": "{{ revision }}" + } + {% if not loop.last %}, {% endif %} + {% endfor %} + ] + } + } + } + } +} diff --git a/rust-runtime/aws-smithy-fuzz/templates/smithy-build-targetcrate.jinja2 b/rust-runtime/aws-smithy-fuzz/templates/smithy-build-targetcrate.jinja2 new file mode 100644 index 000000000..01e4daa01 --- /dev/null +++ b/rust-runtime/aws-smithy-fuzz/templates/smithy-build-targetcrate.jinja2 @@ -0,0 +1,37 @@ +{ + "version": "1.0", + "maven": { + "dependencies": [ + {% for dependency in dependencies -%} "{{ dependency }}", {% endfor %} + "software.amazon.smithy.rust.codegen.server.smithy:codegen-server:0.1.0" + ], + "repositories": [ + { + "url": "file://{{ maven_local }}" + }, + { + "url": "https://repo1.maven.org/maven2" + } + ] + }, + "projections": { + "{{ revision }}": { + "imports": [ ], + "plugins": { + "rust-server-codegen": { + "runtimeConfig": { + "relativePath": "{{ rust_runtime }}" + }, + "codegen": {}, + "service": "{{ service }}", + "module": "test_target", + "moduleVersion": "0.0.1", + "moduleDescription": "test-{{ revision }}", + "moduleAuthors": [ + "protocoltest@example.com" + ] + } + } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index ef481244b..bf89c0cfc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,6 +20,7 @@ include(":aws:rust-runtime") include(":aws:sdk") include(":aws:sdk-adhoc-test") include(":aws:sdk-codegen") +include(":fuzzgen") pluginManagement { val smithyGradlePluginVersion: String by settings diff --git a/tools/ci-scripts/check-fuzzgen b/tools/ci-scripts/check-fuzzgen new file mode 100755 index 000000000..3a4a16760 --- /dev/null +++ b/tools/ci-scripts/check-fuzzgen @@ -0,0 +1,10 @@ +#!/bin/bash +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +# + +set -eux +cd smithy-rs + +./gradlew fuzzgen:test -- GitLab