Skip to content

Commit 93ce1ed

Browse files
committed
all: Make IDE and CLI executions of JUnit fuzz tests more consistent
Previously, IDE executions of JUnit fuzz tests registered a `findingHandler` in `FuzzTargetRunner` whereas CLI executions did not. This lead to inconsistent behavior that was hard to reason about and a lack of feature parity between the two modes (e.g. `--keep_going` was only supported on the CLI). Instead, we now use a `findingHandler` to report the last, "fatal", finding in structured form to `FuzzTestExecutor`, with all other findings having their stack traces printed. `JUnitRunner` now handles findings from lifecycle methods correctly, including for the exit code.
1 parent b6c967c commit 93ce1ed

File tree

7 files changed

+95
-98
lines changed

7 files changed

+95
-98
lines changed

src/main/java/com/code_intelligence/jazzer/driver/FuzzTargetRunner.java

Lines changed: 47 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,9 @@
4848
import java.util.HashSet;
4949
import java.util.List;
5050
import java.util.Locale;
51+
import java.util.Objects;
5152
import java.util.Set;
52-
import java.util.function.Predicate;
53+
import java.util.function.Consumer;
5354
import java.util.stream.Stream;
5455
import sun.misc.Unsafe;
5556

@@ -118,7 +119,7 @@ public final class FuzzTargetRunner {
118119
private static final boolean useFuzzedDataProvider;
119120
private static final ArgumentsMutator mutator;
120121
private static final ReproducerTemplate reproducerTemplate;
121-
private static Predicate<Throwable> findingHandler;
122+
private static Consumer<Throwable> fatalFindingHandlerForJUnit;
122123

123124
static {
124125
FuzzTargetHolder.FuzzTarget fuzzTarget = FuzzTargetHolder.fuzzTarget;
@@ -136,7 +137,6 @@ public final class FuzzTargetRunner {
136137
if (!useFuzzedDataProvider && IS_ANDROID) {
137138
Log.error("Android fuzz targets must use " + FuzzedDataProvider.class.getName());
138139
exit(1);
139-
throw new IllegalStateException("Not reached");
140140
}
141141

142142
Class<?> fuzzTargetClass = fuzzTarget.method.getDeclaringClass();
@@ -147,9 +147,8 @@ public final class FuzzTargetRunner {
147147
try {
148148
lifecycleMethodsInvoker.beforeFirstExecution();
149149
} catch (Throwable t) {
150-
Log.finding(t);
150+
Log.finding(ExceptionUtils.preprocessThrowable(t));
151151
exit(1);
152-
throw new IllegalStateException("Not reached");
153152
}
154153

155154
if (useExperimentalMutator) {
@@ -166,11 +165,7 @@ public final class FuzzTargetRunner {
166165
CoverageRecorder.updateCoveredIdsWithCoverageMap();
167166
}
168167

169-
// When running with a custom finding handler, such as from within JUnit, we can't reason about
170-
// when the JVM shuts down and thus don't use shutdown handlers.
171-
if (findingHandler == null) {
172-
Runtime.getRuntime().addShutdownHook(new Thread(FuzzTargetRunner::shutdown));
173-
}
168+
Runtime.getRuntime().addShutdownHook(new Thread(FuzzTargetRunner::shutdown));
174169
}
175170

176171
/** A test-only convenience wrapper around {@link #runOne(long, int)}. */
@@ -215,7 +210,6 @@ private static int runOne(long dataPtr, int dataLength) {
215210
data = copyToArray(dataPtr, dataLength);
216211
argument = data;
217212
}
218-
Object fuzzTargetInstance;
219213
try {
220214
lifecycleMethodsInvoker.beforeEachExecution();
221215
} catch (Throwable uncaughtFinding) {
@@ -225,7 +219,7 @@ private static int runOne(long dataPtr, int dataLength) {
225219
// always enter the try block that calls afterEachExecution in finally.
226220
if (finding == null) {
227221
try {
228-
fuzzTargetInstance = lifecycleMethodsInvoker.getTestClassInstance();
222+
Object fuzzTargetInstance = lifecycleMethodsInvoker.getTestClassInstance();
229223
if (useExperimentalMutator) {
230224
// No need to detach as we are currently reading in the mutator state from bytes in every
231225
// iteration.
@@ -237,7 +231,7 @@ private static int runOne(long dataPtr, int dataLength) {
237231
}
238232
} catch (Throwable uncaughtFinding) {
239233
finding = uncaughtFinding;
240-
} finally{
234+
} finally {
241235
try {
242236
lifecycleMethodsInvoker.afterEachExecution();
243237
} catch (Throwable t) {
@@ -272,6 +266,12 @@ private static int runOne(long dataPtr, int dataLength) {
272266
if (finding == null || finding.getClass().getName().equals(OPENTEST4J_TEST_ABORTED_EXCEPTION)) {
273267
return LIBFUZZER_CONTINUE;
274268
}
269+
270+
// The user-provided fuzz target method has returned. Any further exits, e.g. due to uncaught
271+
// exceptions, are on us and should not result in a "fuzz target exited" warning being printed
272+
// by libFuzzer.
273+
temporarilyDisableLibfuzzerExitHook();
274+
275275
if (useHooks) {
276276
finding = ExceptionUtils.preprocessThrowable(finding);
277277
}
@@ -280,54 +280,44 @@ private static int runOne(long dataPtr, int dataLength) {
280280
if (emitDedupToken && !ignoredTokens.add(dedupToken)) {
281281
return LIBFUZZER_CONTINUE;
282282
}
283-
284-
if (findingHandler != null) {
285-
// We still print the libFuzzer crashing input information, which also dumps the crashing
286-
// input as a side effect.
287-
printCrashingInput();
288-
if (findingHandler.test(finding)) {
289-
return LIBFUZZER_CONTINUE;
290-
} else {
291-
try {
292-
// We have to call afterLastExecution here as we do not register the shutdown hook that
293-
// would otherwise call it when findingHandler != null.
294-
lifecycleMethodsInvoker.afterLastExecution();
295-
} catch (Throwable t) {
296-
// We already have a finding and do not know whether the fuzz target is in an expected
297-
// state, so report this as a warning rather than an error or finding.
298-
Log.warn("Failed to run @AfterAll or fuzzerTearDown methods", t);
299-
}
300-
return LIBFUZZER_RETURN_FROM_DRIVER;
301-
}
283+
boolean continueFuzzing =
284+
emitDedupToken && Long.compareUnsigned(ignoredTokens.size(), keepGoing) < 0;
285+
boolean isFuzzingFromCommandLine =
286+
fatalFindingHandlerForJUnit == null || Opt.isJUnitAndCommandLine.get();
287+
// In case of --keep_going, only the last finding is reported to JUnit as a Java object, all
288+
// previous ones are merely printed. When fuzzing from the command line, we always print all
289+
// findings.
290+
if (isFuzzingFromCommandLine || continueFuzzing) {
291+
Log.finding(finding);
292+
}
293+
if (fatalFindingHandlerForJUnit != null && !continueFuzzing) {
294+
fatalFindingHandlerForJUnit.accept(finding);
302295
}
303-
304-
// The user-provided fuzz target method has returned. Any further exits are on us and should not
305-
// result in a "fuzz target exited" warning being printed by libFuzzer.
306-
temporarilyDisableLibfuzzerExitHook();
307-
308-
Log.finding(finding);
309296
if (emitDedupToken) {
310297
// Has to be printed to stdout as it is parsed by libFuzzer when minimizing a crash. It does
311298
// not necessarily have to appear at the beginning of a line.
312299
// https://github.com/llvm/llvm-project/blob/4c106c93eb68f8f9f201202677cd31e326c16823/compiler-rt/lib/fuzzer/FuzzerDriver.cpp#L342
313300
Log.structuredOutput(String.format(Locale.ROOT, "DEDUP_TOKEN: %016x", dedupToken));
314301
}
315-
Log.println("== libFuzzer crashing input ==");
316-
printCrashingInput();
302+
if (isFuzzingFromCommandLine) {
303+
// We emit this line for backwards compatibility when fuzzing on the CLI only.
304+
Log.println("== libFuzzer crashing input ==");
305+
}
306+
printAndDumpCrashingInput();
307+
317308
// dumpReproducer needs to be called after libFuzzer printed its final stats as otherwise it
318309
// would report incorrect coverage - the reproducer generation involved rerunning the fuzz
319310
// target.
320311
// It doesn't support @FuzzTest fuzz targets, but these come with an integrated regression test
321312
// that satisfies the same purpose.
322313
// It also doesn't support the experimental mutator yet as that requires implementing Java code
323314
// generation for mutators.
324-
if (findingHandler == null && !useExperimentalMutator) {
315+
if (fatalFindingHandlerForJUnit == null && !useExperimentalMutator) {
325316
dumpReproducer(data);
326317
}
327318

328-
if (!emitDedupToken || Long.compareUnsigned(ignoredTokens.size(), keepGoing) >= 0) {
329-
// Reached the maximum amount of findings to keep going for, crash after shutdown.
330-
if (!Opt.autofuzz.get().isEmpty() && Opt.dedup.get()) {
319+
if (!continueFuzzing) {
320+
if (!Opt.autofuzz.get().isEmpty() && emitDedupToken) {
331321
Log.println("");
332322
Log.info(
333323
String.format(
@@ -342,8 +332,15 @@ private static int runOne(long dataPtr, int dataLength) {
342332
Opt.autofuzzIgnore.get().stream(), Stream.of(finding.getClass().getName()))
343333
.collect(joining(","))));
344334
}
345-
System.exit(JAZZER_FINDING_EXIT_CODE);
346-
throw new IllegalStateException("Not reached");
335+
if (fatalFindingHandlerForJUnit == null) {
336+
// When running a legacy fuzzerTestOneInput test, exit now with the correct exit code.
337+
// This will trigger the shutdown hook that runs fuzzerTearDown.
338+
System.exit(JAZZER_FINDING_EXIT_CODE);
339+
} else {
340+
// When running within JUnit, pass control back to FuzzTestExecutor, which has received
341+
// the finding via the handler.
342+
return LIBFUZZER_RETURN_FROM_DRIVER;
343+
}
347344
}
348345
return LIBFUZZER_CONTINUE;
349346
}
@@ -435,15 +432,8 @@ public static int startLibFuzzer(List<String> args) {
435432
args.stream().map(str -> str.getBytes(StandardCharsets.UTF_8)).toArray(byte[][]::new));
436433
}
437434

438-
/**
439-
* Registers a custom handler for findings.
440-
*
441-
* @param findingHandler a consumer for the finding that returns true if the fuzzer should
442-
* continue fuzzing and false if it should return from {@link
443-
* FuzzTargetRunner#startLibFuzzer(List)}.
444-
*/
445-
public static void registerFindingHandler(Predicate<Throwable> findingHandler) {
446-
FuzzTargetRunner.findingHandler = findingHandler;
435+
public static void registerFatalFindingHandlerForJUnit(Consumer<Throwable> findingHandler) {
436+
FuzzTargetRunner.fatalFindingHandlerForJUnit = Objects.requireNonNull(findingHandler);
447437
}
448438

449439
private static void shutdown() {
@@ -556,8 +546,8 @@ private static int startLibFuzzer(byte[][] args) {
556546
* Causes libFuzzer to write the current input to disk as a crashing input and emit some
557547
* information about it to stderr.
558548
*/
559-
public static void printCrashingInput() {
560-
FuzzTargetRunnerNatives.printCrashingInput();
549+
public static void printAndDumpCrashingInput() {
550+
FuzzTargetRunnerNatives.printAndDumpCrashingInput();
561551
}
562552

563553
/** Returns the debug string of the current mutator. If no mutator is used, returns null. */

src/main/java/com/code_intelligence/jazzer/driver/Opt.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,10 @@ public final class Opt {
216216

217217
// Internal options:
218218

219+
// Whether Jazzer is running a JUnit fuzz test from the command line.
220+
public static final OptItem<Boolean> isJUnitAndCommandLine =
221+
boolSetting("command_line", false, null);
222+
219223
// Whether this is a subprocess created by libFuzzer's `-merge` mode.
220224
public static final OptItem<Boolean> mergeInner = boolSetting("merge_inner", false, null);
221225

src/main/java/com/code_intelligence/jazzer/driver/junit/JUnitRunner.java

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
package com.code_intelligence.jazzer.driver.junit;
1818

1919
import static com.code_intelligence.jazzer.driver.Constants.JAZZER_FINDING_EXIT_CODE;
20-
import static com.code_intelligence.jazzer.driver.FuzzTargetRunner.printCrashingInput;
2120
import static org.junit.platform.engine.FilterResult.includedIf;
2221
import static org.junit.platform.engine.TestExecutionResult.Status.ABORTED;
2322
import static org.junit.platform.engine.TestExecutionResult.Status.FAILED;
@@ -30,6 +29,7 @@
3029
import java.util.List;
3130
import java.util.Map;
3231
import java.util.Optional;
32+
import java.util.concurrent.atomic.AtomicBoolean;
3333
import java.util.concurrent.atomic.AtomicReference;
3434
import java.util.stream.Collectors;
3535
import java.util.stream.IntStream;
@@ -91,7 +91,7 @@ public static Optional<JUnitRunner> create(String testClassName, List<String> li
9191
// all fuzz test invocations are combined in a single JUnit test method execution.
9292
// https://junit.org/junit5/docs/current/user-guide/#writing-tests-declarative-timeouts-mode
9393
.configurationParameter("junit.jupiter.execution.timeout.mode", "disabled")
94-
.configurationParameter("jazzer.internal.commandLine", "true")
94+
.configurationParameter("jazzer.internal.command_line", "true")
9595
.configurationParameters(indexedArgs)
9696
.selectors(selectClass(testClassName))
9797
.filters(includeTags("jazzer"));
@@ -118,28 +118,30 @@ public static Optional<JUnitRunner> create(String testClassName, List<String> li
118118
}
119119

120120
public int run() {
121-
AtomicReference<TestExecutionResult> resultHolder =
122-
new AtomicReference<>(TestExecutionResult.successful());
121+
AtomicReference<TestExecutionResult> testResultHolder = new AtomicReference<>();
122+
AtomicBoolean sawContainerFailure = new AtomicBoolean();
123123
launcher.execute(
124124
testPlan,
125125
new TestExecutionListener() {
126126
@Override
127127
public void executionFinished(
128128
TestIdentifier testIdentifier, TestExecutionResult testExecutionResult) {
129-
// Lifecycle methods can fail too, which results in failed execution results on
130-
// container
131-
// nodes. We keep the last failing one with a stack trace. For tests, we also keep the
132-
// stack
133-
// traces of aborted tests so that we can show a warning. In JUnit Jupiter, tests and
134-
// containers always fail with a throwable:
135-
// https://github.com/junit-team/junit5/blob/ac31e9a7d58973db73496244dab4defe17ae563e/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ThrowableCollector.java#LL176C37-L176C37
136-
if ((testIdentifier.isTest() && testExecutionResult.getThrowable().isPresent())
137-
|| testExecutionResult.getStatus() == FAILED) {
138-
resultHolder.set(testExecutionResult);
139-
}
140-
if (testExecutionResult.getStatus() == FAILED
141-
&& testExecutionResult.getThrowable().isPresent()) {
142-
resultHolder.set(testExecutionResult);
129+
if (testIdentifier.isTest()) {
130+
testResultHolder.set(testExecutionResult);
131+
} else {
132+
// Lifecycle methods can fail too, which results in failed execution results on
133+
// container nodes. We emit all these failures as errors, not findings, since the
134+
// lifecycle methods invoked by JUnit, which don't include @BeforeEach and
135+
// @AfterEach executed during individual fuzz test executions, usually aren't
136+
// reproducible with any given input (e.g. @AfterAll methods).
137+
testExecutionResult
138+
.getThrowable()
139+
.map(ExceptionUtils::preprocessThrowable)
140+
.ifPresent(
141+
throwable -> {
142+
sawContainerFailure.set(true);
143+
Log.error(throwable);
144+
});
143145
}
144146
}
145147

@@ -149,28 +151,37 @@ public void reportingEntryPublished(TestIdentifier testIdentifier, ReportEntry e
149151
}
150152
});
151153

152-
TestExecutionResult result = resultHolder.get();
154+
TestExecutionResult result = testResultHolder.get();
155+
if (result == null) {
156+
// This can only happen if a test container failed, in which case we will have printed a
157+
// stack trace.
158+
Log.error("Failed to run fuzz test");
159+
return 1;
160+
}
153161
if (result.getStatus() != FAILED) {
154-
// We do not generate a finding for Aborted tests (i.e. tests whose preconditions were not
162+
// We do not generate a finding for aborted tests (i.e. tests whose preconditions were not
155163
// met) as such tests also wouldn't make a test run fail.
156164
if (result.getStatus() == ABORTED) {
157165
Log.warn("Fuzz test aborted", result.getThrowable().orElse(null));
158166
}
167+
if (sawContainerFailure.get()) {
168+
// A failure in a test container indicates a setup error, so we don't return the finding
169+
// exit code in this case.
170+
return 1;
171+
}
159172
return 0;
160173
}
161174

162-
// Safe to unwrap as result is either TestExecutionResult.successful() (initial value) or has
163-
// a throwable (set in the TestExecutionListener above).
175+
// Safe to unwrap as in JUnit Jupiter, tests and containers always fail with a Throwable:
176+
// https://github.com/junit-team/junit5/blob/ac31e9a7d58973db73496244dab4defe17ae563e/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ThrowableCollector.java#LL176C37-L176C37
164177
Throwable throwable = result.getThrowable().get();
165178
if (throwable instanceof ExitCodeException) {
166-
// Jazzer found a regular finding and printed it, so just return the exit code.
179+
// libFuzzer exited with a non-zero exit code, but Jazzer didn't produce a finding. Forward
180+
// the exit code and assume that information has already been printed (e.g. a timeout).
167181
return ((ExitCodeException) throwable).exitCode;
168182
} else {
169-
// Jazzer didn't report a finding, but an afterAll-type function threw an exception. Report it
170-
// as a finding, cleaning up the stack trace.
171-
Log.finding(ExceptionUtils.preprocessThrowable(throwable));
172-
Log.println("== libFuzzer crashing input ==");
173-
printCrashingInput();
183+
// Non-fatal findings and exceptions in containers have already been printed, the fatal
184+
// finding is passed to JUnit as the test result.
174185
return JAZZER_FINDING_EXIT_CODE;
175186
}
176187
}

src/main/java/com/code_intelligence/jazzer/junit/FuzzTestExecutor.java

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -361,17 +361,9 @@ public Optional<Throwable> execute(
361361
JUnitLifecycleMethodsInvoker.of(extensionContext, lifecycle));
362362
}
363363

364-
// Only register a finding handler in case the fuzz test is executed by JUnit.
365-
// It short-circuits the handling in FuzzTargetRunner and prevents settings
366-
// like --keep_going.
367364
AtomicReference<Throwable> atomicFinding = new AtomicReference<>();
368-
if (!isRunFromCommandLine) {
369-
FuzzTargetRunner.registerFindingHandler(
370-
t -> {
371-
atomicFinding.set(t);
372-
return false;
373-
});
374-
}
365+
// Non-fatal findings (with --keep_going) are logged by FuzzTargetRunner.
366+
FuzzTargetRunner.registerFatalFindingHandlerForJUnit(atomicFinding::set);
375367

376368
int exitCode = FuzzTargetRunner.startLibFuzzer(libFuzzerArgs);
377369
javaSeedsDir.ifPresent(FuzzTestExecutor::deleteJavaSeedsDir);

src/main/java/com/code_intelligence/jazzer/junit/Utils.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ static boolean isFuzzing(ExtensionContext extensionContext) {
236236

237237
static boolean runFromCommandLine(ExtensionContext extensionContext) {
238238
return extensionContext
239-
.getConfigurationParameter("jazzer.internal.commandLine")
239+
.getConfigurationParameter("jazzer.internal.command_line")
240240
.map(Boolean::parseBoolean)
241241
.orElse(false);
242242
}

src/main/java/com/code_intelligence/jazzer/runtime/FuzzTargetRunnerNatives.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public class FuzzTargetRunnerNatives {
3535
public static native int startLibFuzzer(
3636
byte[][] args, Class<?> runner, boolean useExperimentalMutator);
3737

38-
public static native void printCrashingInput();
38+
public static native void printAndDumpCrashingInput();
3939

4040
public static native void temporarilyDisableLibfuzzerExitHook();
4141
}

src/main/native/com/code_intelligence/jazzer/driver/fuzz_target_runner.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ Java_com_code_1intelligence_jazzer_runtime_FuzzTargetRunnerNatives_startLibFuzze
192192
}
193193

194194
[[maybe_unused]] void
195-
Java_com_code_1intelligence_jazzer_runtime_FuzzTargetRunnerNatives_printCrashingInput(
195+
Java_com_code_1intelligence_jazzer_runtime_FuzzTargetRunnerNatives_printAndDumpCrashingInput(
196196
JNIEnv *, jclass) {
197197
if (gLibfuzzerPrintCrashingInput == nullptr) {
198198
std::cerr << "<not available>" << std::endl;

0 commit comments

Comments
 (0)