From e22c165c2d4587e9c5c59ab50c4302c5de0b2628 Mon Sep 17 00:00:00 2001 From: anschnapp Date: Sun, 26 Jul 2026 19:26:45 +0200 Subject: [PATCH] optional extra cli safe guard verification, which could be used to guarantee that mutated binaries never go to real production build --- .github/workflows/ci.yml | 5 + README.md | 41 ++++++++ scripts/mutflow-verify-jar.sh | 103 ++++++++++++++++++++ scripts/test-mutflow-verify-jar.sh | 149 +++++++++++++++++++++++++++++ 4 files changed, 298 insertions(+) create mode 100755 scripts/mutflow-verify-jar.sh create mode 100755 scripts/test-mutflow-verify-jar.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f537ce..902c9be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,3 +23,8 @@ jobs: - name: Build and test run: ./gradlew build + + # Builds a consumer project with the real plugin and checks both sides of the + # dual compilation: the production jar must be clean, the mutated one rejected. + - name: Test production jar check script + run: scripts/test-mutflow-verify-jar.sh diff --git a/README.md b/README.md index d300a6f..5f7dd6f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - [Setup](#setup) - [Quick Start](#quick-start) - [Configuration](#configuration) +- [Verifying Production Artifacts](#verifying-production-artifacts) - [Mutation Operators](#mutation-operators) - [Features](#features) - [How Mutations Work](#how-mutations-work) @@ -380,6 +381,46 @@ MutFlow.underTest(run = 1, Selection.MostLikelyStable, Shuffle.PerChange) { Selection strategies (`PureRandom`, `MostLikelyRandom`, `MostLikelyStable`) and shuffle modes (`PerRun`, `PerChange`) control how mutations are prioritized. The `@MutFlowTest` annotation uses sensible defaults automatically - these parameters are only needed for custom integrations. +## Verifying Production Artifacts + +The Gradle plugin compiles mutations into a separate `mutatedMain` source set, so your production JAR never contains them. If you want a hard guarantee in your release pipeline (for example right before `docker build`), use the shipped check script: + +```bash +scripts/mutflow-verify-jar.sh build/libs/my-app.jar +``` + +It scans every class in the archive, including nested archives such as Spring Boot's `BOOT-INF/lib/*.jar` and shadow JARs, and fails if a class references the mutflow runtime registry (`MutationRegistry`), which is what an injected mutation switch looks like in bytecode. + +What is fine in a production artifact: + +- the `mutflow-annotations` classes (`@MutationTarget`, `@SuppressMutations`) - `BINARY` retention markers with no runtime behavior +- your own classes annotated with `@MutationTarget` + +What fails the check: + +- any class carrying injected mutation switches +- bundled mutflow core/runtime classes (pass `--allow-bundled-runtime` to permit them) + +Exit codes: `0` clean, `1` findings, `2` usage error or missing `unzip`. + +Typical CI usage: + +```bash +./gradlew build +scripts/mutflow-verify-jar.sh build/libs/*.jar || exit 1 +docker build -t my-app . +``` + +Example failure output: + +``` +MUTATION build/libs/my-app.jar!/BOOT-INF/classes/com/example/PricingService.class +FAILED: found 1 class(es) containing mutflow mutations. +The artifact was built with the mutflow compiler plugin applied to production code. +``` + +The script requires `bash` and `unzip`. It is tested end-to-end by `scripts/test-mutflow-verify-jar.sh`, which runs on every pull request: it publishes mutflow to mavenLocal, builds a small consumer project with the real Gradle plugin, and asserts that the production JAR passes while the `mutatedMain` compilation of the very same sources is rejected. + ## Mutation Operators - [**Relational comparisons**](#how-relational-comparison-mutations-work) - `>`, `<`, `>=`, `<=` with 2 variants each (boundary + flip) diff --git a/scripts/mutflow-verify-jar.sh b/scripts/mutflow-verify-jar.sh new file mode 100755 index 0000000..2d35592 --- /dev/null +++ b/scripts/mutflow-verify-jar.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# +# mutflow-verify-jar.sh - fail if a production artifact contains mutflow mutations. +# +# Usage: mutflow-verify-jar.sh [--allow-bundled-runtime] [more.jar ...] +# +# Mutated classes reference io/github/anschnapp/mutflow/MutationRegistry, so that +# reference is the marker we look for. Nested archives (Spring Boot BOOT-INF/lib, +# fat jars) are scanned as well. The mutflow-annotations classes (MutationTarget, +# SuppressMutations) are fine in production, the core/runtime classes are not +# (use --allow-bundled-runtime if you ship them on purpose). +# +# Exit codes: 0 = clean, 1 = findings, 2 = usage error or missing unzip. + +MARKER="io/github/anschnapp/mutflow/MutationRegistry" +MUTFLOW_DIR="io/github/anschnapp/mutflow" + +allow_runtime=0 +mutations=0 +runtimes=0 +archives=0 + +usage() { + echo "Usage: mutflow-verify-jar.sh [--allow-bundled-runtime] [more.jar ...]" +} + +# check_archive +check_archive() { + archives=$((archives + 1)) + dir="$tmp/$archives" + mkdir "$dir" + if ! unzip -qq -o "$1" -d "$dir" >/dev/null 2>&1; then + echo "ERROR: cannot read archive: $2" >&2 + exit 2 + fi + + # classes carrying injected mutation switches (mutflow's own classes may mention it) + for f in $(grep -rlF --include='*.class' "$MARKER" "$dir"); do + entry=${f#"$dir"/} + case "$entry" in "$MUTFLOW_DIR"/*) continue ;; esac + echo "MUTATION $2!/$entry" + mutations=$((mutations + 1)) + done + + # bundled mutflow core/runtime classes + for f in $(find "$dir/$MUTFLOW_DIR" -name '*.class' 2>/dev/null); do + case "${f##*/}" in MutationTarget.class | SuppressMutations.class) continue ;; esac + echo "RUNTIME $2!/${f#"$dir"/}" + runtimes=$((runtimes + 1)) + done + + # nested archives + for f in $(find "$dir" \( -name '*.jar' -o -name '*.war' \) 2>/dev/null); do + check_archive "$f" "$2!/${f#"$dir"/}" + done +} + +if [ "$1" = "--allow-bundled-runtime" ]; then + allow_runtime=1 + shift +fi + +if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then + usage + exit 0 +fi + +if [ $# -eq 0 ]; then + usage >&2 + exit 2 +fi + +if ! command -v unzip >/dev/null 2>&1; then + echo "ERROR: 'unzip' is required but not installed" >&2 + exit 2 +fi + +tmp=$(mktemp -d "${TMPDIR:-/tmp}/mutflow-verify.XXXXXX") || exit 2 +trap 'rm -rf "$tmp"' EXIT + +for jar in "$@"; do + if [ ! -f "$jar" ]; then + echo "ERROR: no such file: $jar" >&2 + exit 2 + fi + check_archive "$jar" "$jar" +done + +if [ "$mutations" -gt 0 ]; then + echo "FAILED: found $mutations class(es) containing mutflow mutations." >&2 + echo "The artifact was built with the mutflow compiler plugin applied to production code." >&2 + exit 1 +fi + +if [ "$runtimes" -gt 0 ] && [ "$allow_runtime" -eq 0 ]; then + echo "FAILED: found $runtimes bundled mutflow runtime class(es)." >&2 + echo "Only mutflow-annotations belongs on a production classpath." >&2 + echo "Re-run with --allow-bundled-runtime if this is intentional." >&2 + exit 1 +fi + +echo "OK: no mutations found." +exit 0 diff --git a/scripts/test-mutflow-verify-jar.sh b/scripts/test-mutflow-verify-jar.sh new file mode 100755 index 0000000..a397e28 --- /dev/null +++ b/scripts/test-mutflow-verify-jar.sh @@ -0,0 +1,149 @@ +#!/bin/bash +# +# test-mutflow-verify-jar.sh - end-to-end test for mutflow-verify-jar.sh. +# +# Publishes mutflow to mavenLocal, generates a throwaway consumer project that +# applies the real Gradle plugin, and checks both sides of its dual compilation: +# +# main -> the production jar, must be clean (only @MutationTarget references) +# mutatedMain -> jarred here, must be rejected with exit code 1 +# +# The project is generated instead of using example/ because example/ is not part +# of the repository, so CI has no copy of it. +# +# Run from anywhere: scripts/test-mutflow-verify-jar.sh +# Exit codes: 0 = all cases passed, 1 = a case failed. + +root=$(cd "$(dirname "$0")/.." && pwd) +verify="$root/scripts/mutflow-verify-jar.sh" +kotlin_version=$(sed -n 's/^kotlinVersion=//p' "$root/gradle.properties") +mutflow_version="0.1.0-SNAPSHOT" # default project version, see root build.gradle.kts + +tmp=$(mktemp -d "${TMPDIR:-/tmp}/mutflow-verify-test.XXXXXX") || exit 1 +trap 'rm -rf "$tmp"' EXIT + +failures=0 + +# expect +expect() { + wanted_code=$1 + wanted_text=$2 + shift 2 + + output=$("$verify" "$@" 2>&1) + code=$? + + if [ "$code" != "$wanted_code" ]; then + echo "FAIL: $* -> exit $code, expected $wanted_code" + echo "$output" | sed 's/^/ /' + failures=$((failures + 1)) + return + fi + case "$output" in + *"$wanted_text"*) ;; + *) + echo "FAIL: $* -> output does not contain '$wanted_text'" + echo "$output" | sed 's/^/ /' + failures=$((failures + 1)) + return + ;; + esac + echo "ok: exit $code, contains '$wanted_text'" +} + +echo "== publishing mutflow $mutflow_version to mavenLocal" +(cd "$root" && ./gradlew publishToMavenLocal -q) || exit 1 + +echo "== generating a consumer project that applies the mutflow Gradle plugin" +sample="$tmp/sample" +mkdir -p "$sample/src/main/kotlin/com/example" + +cat > "$sample/settings.gradle.kts" < "$sample/build.gradle.kts" < "$sample/src/main/kotlin/com/example/Calculator.kt" <<'EOF' +package com.example + +import io.github.anschnapp.mutflow.MutationTarget + +@MutationTarget +class Calculator { + fun isPositive(x: Int): Boolean = x > 0 +} +EOF + +echo "== building the consumer project (real plugin, dual compilation)" +(cd "$root" && ./gradlew -p "$sample" jar mutatedMainClasses -q) || exit 1 + +prod_jar=$(ls "$sample"/build/libs/*.jar 2>/dev/null | head -1) +mutated_classes="$sample/build/classes/kotlin/mutatedMain" +if [ ! -f "$prod_jar" ] || [ ! -d "$mutated_classes" ]; then + echo "ERROR: consumer build output not found (jar: $prod_jar, classes: $mutated_classes)" >&2 + exit 1 +fi + +# guard against a vacuous "clean jar" result +if ! unzip -Z1 "$prod_jar" | grep -q 'com/example/Calculator.class'; then + echo "ERROR: production jar does not contain the expected class" >&2 + exit 1 +fi + +# same class as the production jar, but from the mutated compilation +(cd "$mutated_classes" && jar cf "$tmp/mutated.jar" .) || exit 1 + +# a Spring Boot style artifact with the mutated jar nested inside +mkdir -p "$tmp/boot/BOOT-INF/lib" +cp "$tmp/mutated.jar" "$tmp/boot/BOOT-INF/lib/lib.jar" +(cd "$tmp/boot" && jar cf "$tmp/boot.jar" .) || exit 1 + +echo "== production jar must be clean" +expect 0 "OK: no mutations found" "$prod_jar" + +echo "== mutated compilation must be rejected" +expect 1 "MUTATION" "$tmp/mutated.jar" +expect 1 "com/example/Calculator.class" "$tmp/mutated.jar" + +echo "== mutations inside a nested jar must be found" +expect 1 "BOOT-INF/lib/lib.jar!/com/example/Calculator.class" "$tmp/boot.jar" + +echo "== bundled mutflow core is reported, annotations are fine" +core_jar=$(ls "$root"/mutflow-core/build/libs/*.jar | grep -v -e sources -e javadoc | head -1) +annotations_jar=$(ls "$root"/mutflow-annotations/build/libs/*.jar | grep -v -e sources -e javadoc | head -1) +expect 1 "RUNTIME" "$core_jar" +expect 0 "OK: no mutations found" "$annotations_jar" + +echo "== usage errors" +expect 2 "no such file" "$tmp/does-not-exist.jar" +expect 2 "Usage:" + +if [ "$failures" -gt 0 ]; then + echo "$failures case(s) FAILED" + exit 1 +fi + +echo "all cases passed" +exit 0