From c9c10532d4d25685fd16e6903eaf19211f86fcf9 Mon Sep 17 00:00:00 2001 From: Simon Resch Date: Thu, 18 Dec 2025 11:30:36 +0100 Subject: [PATCH] feat: add @ElementOf annotation This allows picking from a fixed set of values during mutation. --- docs/mutation-framework.md | 43 +++--- .../jazzer/mutation/annotation/ElementOf.java | 80 ++++++++++ .../mutator/lang/ElementOfMutatorFactory.java | 139 ++++++++++++++++++ .../mutation/mutator/lang/LangMutators.java | 1 + .../jazzer/mutation/mutator/StressTest.java | 8 + .../lang/ElementOfMutatorFactoryTest.java | 98 ++++++++++++ 6 files changed, 352 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/code_intelligence/jazzer/mutation/annotation/ElementOf.java create mode 100644 src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/ElementOfMutatorFactory.java create mode 100644 src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/ElementOfMutatorFactoryTest.java diff --git a/docs/mutation-framework.md b/docs/mutation-framework.md index 59c6e4c58..4aa27fa34 100644 --- a/docs/mutation-framework.md +++ b/docs/mutation-framework.md @@ -87,22 +87,23 @@ string. This is done using annotations directly on the parameters. All annotations reside in the `com.code_intelligence.jazzer.mutation.annotation` package. -| Annotation | Applies To | Notes | -|-------------------|----------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------| -| `@Ascii` | `java.lang.String` | `String` should only contain ASCII characters | -| `@InRange` | `byte`, `Byte`, `char`, `Character`, `short`, `Short`, `int`, `Integer`, `long`, `Long` | Specifies `min` and `max` values of generated integrals | -| `@FloatInRange` | `float`, `Float` | Specifies `min` and `max` values of generated floats | -| `@DoubleInRange` | `double`, `Double` | Specifies `min` and `max` values of generated doubles | -| `@Positive` | `byte`, `Byte`, `short`, `Short`, `int`, `Integer`, `long`, `Long`, `float`, `Float`, `double`, `Double` | Specifies that only positive values are generated | -| `@Negative` | `byte`, `Byte`, `short`, `Short`, `int`, `Integer`, `long`, `Long`, `float`, `Float`, `double`, `Double` | Specifies that only negative values are generated | -| `@NonPositive` | `byte`, `Byte`, `short`, `Short`, `int`, `Integer`, `long`, `Long`, `float`, `Float`, `double`, `Double` | Specifies that only non-positive values are generated | -| `@NonNegative` | `byte`, `Byte`, `short`, `Short`, `int`, `Integer`, `long`, `Long`, `float`, `Float`, `double`, `Double` | Specifies that only non-negative values are generated | -| `@Finite` | `float`, `Float`, `double`, `Double` | Specifies that only finite values are generated | -| `@NotNull` | | Specifies that a reference type should not be `null` | -| `@WithLength` | `byte[]` | Specifies the length of the generated byte array | -| `@WithUtf8Length` | `java.lang.String` | Specifies the length of the generated string in UTF-8 bytes, see annotation Javadoc for further information | -| `@WithSize` | `java.util.List`, `java.util.Map` | Specifies the size of the generated collection | -| `@UrlSegment` | `java.lang.String` | `String` should only contain valid URL segment characters | +| Annotation | Applies To | Notes | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------| +| `@Ascii` | `java.lang.String` | `String` should only contain ASCII characters | +| `@InRange` | `byte`, `Byte`, `char`, `Character`, `short`, `Short`, `int`, `Integer`, `long`, `Long` | Specifies `min` and `max` values of generated integrals | +| `@FloatInRange` | `float`, `Float` | Specifies `min` and `max` values of generated floats | +| `@DoubleInRange` | `double`, `Double` | Specifies `min` and `max` values of generated doubles | +| `@Positive` | `byte`, `Byte`, `short`, `Short`, `int`, `Integer`, `long`, `Long`, `float`, `Float`, `double`, `Double` | Specifies that only positive values are generated | +| `@Negative` | `byte`, `Byte`, `short`, `Short`, `int`, `Integer`, `long`, `Long`, `float`, `Float`, `double`, `Double` | Specifies that only negative values are generated | +| `@NonPositive` | `byte`, `Byte`, `short`, `Short`, `int`, `Integer`, `long`, `Long`, `float`, `Float`, `double`, `Double` | Specifies that only non-positive values are generated | +| `@NonNegative` | `byte`, `Byte`, `short`, `Short`, `int`, `Integer`, `long`, `Long`, `float`, `Float`, `double`, `Double` | Specifies that only non-negative values are generated | +| `@Finite` | `float`, `Float`, `double`, `Double` | Specifies that only finite values are generated | +| `@ElementOf` | `byte`, `Byte`, `short`, `Short`, `int`, `Integer`, `long`, `Long`, `char`, `Character`, `float`, `Float`, `double`, `Double`, `String` | Restricts the value to a fixed set; populate only the array matching the parameter type with at least two entries | +| `@NotNull` | | Specifies that a reference type should not be `null` | +| `@WithLength` | `byte[]` | Specifies the length of the generated byte array | +| `@WithUtf8Length` | `java.lang.String` | Specifies the length of the generated string in UTF-8 bytes, see annotation Javadoc for further information | +| `@WithSize` | `java.util.List`, `java.util.Map` | Specifies the size of the generated collection | +| `@UrlSegment` | `java.lang.String` | `String` should only contain valid URL segment characters | The example below shows how Fuzz Test parameters can be annotated to provide additional information to the mutation framework. @@ -116,6 +117,15 @@ public void testSimpleTypeRecord(@NotNull @WithSize(min = 3, max = 100) ListPopulate exactly one of the type-specific arrays with at least two values. Only the array that + * matches the annotated parameter type is used; all others are ignored. For {@link String} and + * wrapper types, the mutator may still emit {@code null}; add {@link NotNull} (the only supported + * annotation in combination with {@code @ElementOf}) to prevent that. + * + *

Example usage: + * + *

{@code
+ * @FuzzTest
+ * void fuzz(
+ *     @ElementOf(integers = {1, 2, 3}) int option,
+ *     @ElementOf(strings = {"one", "two"}) String label) {
+ *   // option is always 1, 2, or 3; label is always "one" or "two".
+ * }
+ * }
+ */ +@Target(TYPE_USE) +@Retention(RUNTIME) +@AppliesTo({ + byte.class, + Byte.class, + short.class, + Short.class, + int.class, + Integer.class, + long.class, + Long.class, + char.class, + Character.class, + float.class, + Float.class, + double.class, + Double.class, + String.class +}) +public @interface ElementOf { + byte[] bytes() default {}; + + short[] shorts() default {}; + + int[] integers() default {}; + + long[] longs() default {}; + + char[] chars() default {}; + + float[] floats() default {}; + + double[] doubles() default {}; + + String[] strings() default {}; +} diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/ElementOfMutatorFactory.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/ElementOfMutatorFactory.java new file mode 100644 index 000000000..70d9613f2 --- /dev/null +++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/ElementOfMutatorFactory.java @@ -0,0 +1,139 @@ +/* + * Copyright 2025 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.code_intelligence.jazzer.mutation.mutator.lang; + +import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.mutateIndices; +import static com.code_intelligence.jazzer.mutation.combinator.MutatorCombinators.mutateThenMap; +import static com.code_intelligence.jazzer.mutation.support.Preconditions.require; +import static java.lang.String.format; +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.toList; + +import com.code_intelligence.jazzer.mutation.annotation.ElementOf; +import com.code_intelligence.jazzer.mutation.api.ExtendedMutatorFactory; +import com.code_intelligence.jazzer.mutation.api.MutatorFactory; +import com.code_intelligence.jazzer.mutation.api.SerializingMutator; +import java.lang.reflect.AnnotatedType; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +final class ElementOfMutatorFactory implements MutatorFactory { + @Override + public Optional> tryCreate( + AnnotatedType type, ExtendedMutatorFactory factory) { + ElementOf elementOf = type.getAnnotation(ElementOf.class); + if (elementOf == null) { + return Optional.empty(); + } + if (!(type.getType() instanceof Class)) { + return Optional.empty(); + } + Class rawType = (Class) type.getType(); + + if (rawType == byte.class || rawType == Byte.class) { + return Optional.of( + elementOfMutator(boxBytes(elementOf.bytes()), "bytes", rawType.getSimpleName())); + } else if (rawType == short.class || rawType == Short.class) { + return Optional.of( + elementOfMutator(boxShorts(elementOf.shorts()), "shorts", rawType.getSimpleName())); + } else if (rawType == int.class || rawType == Integer.class) { + return Optional.of( + elementOfMutator(boxInts(elementOf.integers()), "integers", rawType.getSimpleName())); + } else if (rawType == long.class || rawType == Long.class) { + return Optional.of( + elementOfMutator(boxLongs(elementOf.longs()), "longs", rawType.getSimpleName())); + } else if (rawType == char.class || rawType == Character.class) { + return Optional.of( + elementOfMutator(boxChars(elementOf.chars()), "chars", rawType.getSimpleName())); + } else if (rawType == float.class || rawType == Float.class) { + return Optional.of( + elementOfMutator(boxFloats(elementOf.floats()), "floats", rawType.getSimpleName())); + } else if (rawType == double.class || rawType == Double.class) { + return Optional.of( + elementOfMutator(boxDoubles(elementOf.doubles()), "doubles", rawType.getSimpleName())); + } else if (rawType == String.class) { + return Optional.of( + elementOfMutator(Arrays.asList(elementOf.strings()), "strings", rawType.getSimpleName())); + } + return Optional.empty(); + } + + private static SerializingMutator elementOfMutator( + List values, String fieldName, String targetTypeName) { + require( + values.size() > 1, + format( + "@ElementOf %s array must contain at least two values for %s", + fieldName, targetTypeName)); + Map inverse = + IntStream.range(0, values.size()).boxed().collect(Collectors.toMap(values::get, i -> i)); + return mutateThenMap( + mutateIndices(values.size()), + values::get, + v -> inverse.getOrDefault(v, 0), + isInCycle -> format("@ElementOf<%s>[%d]", targetTypeName, values.size())); + } + + private static List boxBytes(byte[] values) { + List result = new ArrayList<>(values.length); + for (byte value : values) { + result.add(value); + } + return result; + } + + private static List boxShorts(short[] values) { + List result = new ArrayList<>(values.length); + for (short value : values) { + result.add(value); + } + return result; + } + + private static List boxInts(int[] values) { + return stream(values).boxed().collect(toList()); + } + + private static List boxLongs(long[] values) { + return Arrays.stream(values).boxed().collect(toList()); + } + + private static List boxChars(char[] values) { + List result = new ArrayList<>(values.length); + for (char value : values) { + result.add(value); + } + return result; + } + + private static List boxFloats(float[] values) { + List result = new ArrayList<>(values.length); + for (float value : values) { + result.add(value); + } + return result; + } + + private static List boxDoubles(double[] values) { + return Arrays.stream(values).boxed().collect(toList()); + } +} diff --git a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/LangMutators.java b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/LangMutators.java index 7dc909ff1..64a49211e 100644 --- a/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/LangMutators.java +++ b/src/main/java/com/code_intelligence/jazzer/mutation/mutator/lang/LangMutators.java @@ -31,6 +31,7 @@ public static Stream newFactories(ValuePoolRegistry valuePoolReg return Stream.of( // DON'T EVER SORT THESE! The order is important for the mutator engine to work correctly. new NullableMutatorFactory(), + new ElementOfMutatorFactory(), new ValuePoolMutatorFactory(valuePoolRegistry), new BooleanMutatorFactory(), new FloatingPointMutatorFactory(), diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/StressTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/StressTest.java index 0f5153cb9..2a09771fa 100644 --- a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/StressTest.java +++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/StressTest.java @@ -39,6 +39,7 @@ import com.code_intelligence.jazzer.api.FuzzedDataProvider; import com.code_intelligence.jazzer.driver.FuzzedDataProviderImpl; import com.code_intelligence.jazzer.mutation.annotation.DoubleInRange; +import com.code_intelligence.jazzer.mutation.annotation.ElementOf; import com.code_intelligence.jazzer.mutation.annotation.FloatInRange; import com.code_intelligence.jazzer.mutation.annotation.InRange; import com.code_intelligence.jazzer.mutation.annotation.NotNull; @@ -868,6 +869,13 @@ void singleParam(int parameter) {} true, exactly(null, TestEnumThree.A, TestEnumThree.B, TestEnumThree.C), exactly(null, TestEnumThree.A, TestEnumThree.B, TestEnumThree.C)), + arguments( + new TypeHolder< + @ElementOf(strings = {"one", "two", "three"}) String>() {}.annotatedType(), + "Nullable<@ElementOf(strings, size=3) -> String>", + true, + exactly(null, "one", "two", "three"), + exactly(null, "one", "two", "three")), arguments( new TypeHolder<@NotNull @FloatInRange(min = 0f) Float>() {}.annotatedType(), "Float", diff --git a/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/ElementOfMutatorFactoryTest.java b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/ElementOfMutatorFactoryTest.java new file mode 100644 index 000000000..15cd2cc8f --- /dev/null +++ b/src/test/java/com/code_intelligence/jazzer/mutation/mutator/lang/ElementOfMutatorFactoryTest.java @@ -0,0 +1,98 @@ +/* + * Copyright 2025 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.code_intelligence.jazzer.mutation.mutator.lang; + +import static com.code_intelligence.jazzer.mutation.support.TestSupport.mockPseudoRandom; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.code_intelligence.jazzer.mutation.annotation.ElementOf; +import com.code_intelligence.jazzer.mutation.annotation.NotNull; +import com.code_intelligence.jazzer.mutation.api.SerializingMutator; +import com.code_intelligence.jazzer.mutation.engine.ChainedMutatorFactory; +import com.code_intelligence.jazzer.mutation.support.TestSupport.MockPseudoRandom; +import com.code_intelligence.jazzer.mutation.support.TypeHolder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("unchecked") +class ElementOfMutatorFactoryTest { + private ChainedMutatorFactory factory; + + @BeforeEach + void setUp() { + factory = ChainedMutatorFactory.of(LangMutators.newFactories()); + } + + @Test + void usesProvidedIntegers() { + SerializingMutator mutator = + (SerializingMutator) + factory.createOrThrow( + new TypeHolder< + @ElementOf(integers = {1, 2, 3}) @NotNull Integer>() {}.annotatedType()); + + int value; + try (MockPseudoRandom prng = mockPseudoRandom(0)) { + value = mutator.init(prng); + } + assertThat(value).isEqualTo(1); + + try (MockPseudoRandom prng = mockPseudoRandom(2)) { + value = mutator.mutate(value, prng); + } + assertThat(value).isEqualTo(3); + } + + @Test + void usesProvidedStrings() { + SerializingMutator mutator = + (SerializingMutator) + factory.createOrThrow( + new TypeHolder< + @ElementOf(strings = {"one", "two"}) @NotNull String>() {}.annotatedType()); + + String value; + try (MockPseudoRandom prng = mockPseudoRandom(0)) { + value = mutator.init(prng); + } + assertThat(value).isEqualTo("one"); + + try (MockPseudoRandom prng = mockPseudoRandom(1)) { + value = mutator.mutate(value, prng); + } + assertThat(value).isEqualTo("two"); + } + + @Test + void rejectsEmptyArrayForMatchingType() { + assertThrows( + IllegalArgumentException.class, + () -> + factory.createOrThrow( + new TypeHolder<@ElementOf(bytes = {0, 1, 2}) Integer>() {}.annotatedType())); + } + + @Test + void rejectsSingleValue() { + assertThrows( + IllegalArgumentException.class, + () -> + factory.createOrThrow( + new TypeHolder<@ElementOf(integers = {0}) Integer>() {}.annotatedType())); + } +}