NAME
App::Test::Generator::CoverageGuidedFuzzer - AFL-style coverage-guided fuzzing for App::Test::Generator
VERSION
Version 0.46
SYNOPSIS
use App::Test::Generator::CoverageGuidedFuzzer;
my $fuzzer = App::Test::Generator::CoverageGuidedFuzzer->new(
schema => $yaml_schema,
target_sub => \&My::Module::validate,
iterations => 200,
seed => 42,
);
my $report = $fuzzer->run();
# Optional: trim corpus to minimum branch-covering subset before saving
my $stats = $fuzzer->minimize_corpus();
printf "corpus %d -> %d entries\n", $stats->{before}, $stats->{after};
$fuzzer->save_corpus('t/corpus/validate.json');
DESCRIPTION
Implements coverage-guided fuzzing on top of App::Test::Generator's existing schema-driven input generation. Instead of purely random generation it:
- 1. Generates or mutates a structured input
- 2. Runs the target sub under Devel::Cover to capture branch hits
- 3. Keeps inputs that discover new branches in a corpus
- 4. Preferentially mutates corpus entries in future iterations
This is the Perl equivalent of what AFL/libFuzzer do at the byte level, but operating on typed, schema-validated Perl data structures.
METHODS
new
Construct a new coverage-guided fuzzer.
my $fuzzer = App::Test::Generator::CoverageGuidedFuzzer->new(
schema => $yaml_schema,
target_sub => \&My::Module::validate,
iterations => 200,
seed => 42,
instance => $obj, # optional pre-built object for method calls
);
Arguments
schemaA hashref representing the parsed YAML schema for the target function. Required.
target_subA CODE reference to the function under test. Required.
iterationsNumber of fuzzing iterations to run. Optional - defaults to 100.
seedRandom seed for reproducible runs. Optional - defaults to
time().instanceAn optional pre-built object to use as the invocant when calling the target sub as a method.
timeoutSeconds to allow each
target_subcall before it is aborted viaalarm()and recorded as a bug. Optional - defaults to 5. Set to 0 to disable the timeout (e.g. for target subs that legitimately block).
Returns
A blessed hashref. Croaks if schema or target_sub is missing.
API specification
input
{
schema => { type => HASHREF },
target_sub => { type => CODEREF },
iterations => { type => SCALAR, optional => 1 },
seed => { type => SCALAR, optional => 1 },
instance => { type => OBJECT, optional => 1 },
timeout => { type => SCALAR, optional => 1 },
}
output
{
type => OBJECT,
isa => 'App::Test::Generator::CoverageGuidedFuzzer',
}
EXAMPLE
my $fuzzer = App::Test::Generator::CoverageGuidedFuzzer->new(
schema => { name => { type => 'string' } },
target_sub => sub { length($_[0]) },
iterations => 50,
seed => 1234,
);
MESSAGES
schema required-
schemawas not supplied or was falsy (e.g.undefor0). target_sub required-
target_subwas not supplied or was falsy.
FORMAL SPECIFICATION
Pre: defined schema ∧ ref(target_sub) eq 'CODE'
Post: ref(result) eq 'App::Test::Generator::CoverageGuidedFuzzer' ∧ result->{seed} passed to srand() ∧ result->{corpus} eq []
run
Run the coverage-guided fuzzing loop and return a summary report.
my $report = $fuzzer->run();
printf "Branches covered: %d\n", $report->{branches_covered};
printf "Bugs found: %d\n", $report->{bugs_found};
Arguments
None beyond $self.
Returns
A hashref with keys total_iterations, interesting_inputs, corpus_size, branches_covered, bugs_found, and bugs.
Notes
A target_sub call that dies is only recorded in bugs when the input that triggered it is valid per schema. A die triggered by an input the schema itself marks invalid (e.g. out of the declared min/max range) is expected behaviour, not a bug, and is silently discarded.
API specification
input
{
self => { type => OBJECT, isa => 'App::Test::Generator::CoverageGuidedFuzzer' },
}
output
{
type => HASHREF,
keys => {
total_iterations => { type => SCALAR },
interesting_inputs => { type => SCALAR },
corpus_size => { type => SCALAR },
branches_covered => { type => SCALAR },
bugs_found => { type => SCALAR },
bugs => { type => ARRAYREF },
},
}
EXAMPLE
my $report = $fuzzer->run();
printf "Iterations: %d\n", $report->{total_iterations};
printf "Corpus size: %d\n", $report->{corpus_size};
printf "Bugs found: %d\n", $report->{bugs_found};
for my $bug (@{ $report->{bugs} }) {
printf " input=%s error=%s\n", $bug->{input}, $bug->{error};
}
FORMAL SPECIFICATION
Pre: self->{schema} and self->{target_sub} are set
Post: result->{total_iterations} == self->{iterations} ∧ result->{corpus_size} == scalar @{self->{corpus}} ∧ result->{bugs_found} == scalar @{self->{bugs}}
PSEUDOCODE
seed corpus with SEED_CORPUS_SIZE random inputs
for i in 1..iterations:
if corpus non-empty and rand < CORPUS_MUTATE_RATIO:
input = mutate(random corpus entry)
else:
input = generate_random()
run target_sub(input), record coverage and bugs
return report hashref
corpus
Return the accumulated corpus as an arrayref of hashrefs with keys input and coverage.
my $corpus = $fuzzer->corpus();
API specification
input
{ self => { type => OBJECT, isa => 'App::Test::Generator::CoverageGuidedFuzzer' } }
output
{ type => ARRAYREF }
EXAMPLE
my $corpus = $fuzzer->corpus();
printf "%d entries in corpus\n", scalar @{$corpus};
# Each entry: { input => ..., coverage => { 'file:line:branch' => 1, ... } }
bugs
Return bugs found as an arrayref of hashrefs with keys input and error.
my $bugs = $fuzzer->bugs();
API specification
input
{ self => { type => OBJECT, isa => 'App::Test::Generator::CoverageGuidedFuzzer' } }
output
{ type => ARRAYREF }
EXAMPLE
my $bugs = $fuzzer->bugs();
for my $b (@{$bugs}) {
printf "Bug: input=%s error=%s\n", $b->{input}, $b->{error};
}
save_corpus
Serialise the corpus to a JSON file for replay or extension on future runs.
$fuzzer->save_corpus('t/corpus/validate.json');
Arguments
$pathPath to write the JSON corpus file. Required.
Returns
Nothing. Croaks if the file cannot be written or no JSON module is available.
Side effects
Writes a JSON file to $path.
API specification
input
{
self => { type => OBJECT, isa => 'App::Test::Generator::CoverageGuidedFuzzer' },
path => { type => SCALAR },
}
output
{ type => UNDEF }
EXAMPLE
$fuzzer->run();
$fuzzer->minimize_corpus();
$fuzzer->save_corpus('t/corpus/my_func.json');
MESSAGES
path required-
No path argument was supplied.
Cannot write corpus to $path: $!-
The file could not be opened for writing (permissions, missing directory, etc.).
No JSON module available; install JSON or JSON::MaybeXS-
Neither
JSON::MaybeXSnorJSONis installed.
load_corpus
Load a previously saved corpus JSON file, pre-seeding the fuzzer so it continues from where it left off.
$fuzzer->load_corpus('t/corpus/validate.json');
Arguments
$pathPath to the JSON corpus file to load. Required.
Returns
Nothing. Croaks if the file cannot be read or no JSON module is available.
Side effects
Appends loaded entries to $self->{corpus}.
API specification
input
{
self => { type => OBJECT, isa => 'App::Test::Generator::CoverageGuidedFuzzer' },
path => { type => SCALAR },
}
output
{ type => UNDEF }
EXAMPLE
my $fuzzer2 = App::Test::Generator::CoverageGuidedFuzzer->new(
schema => $schema,
target_sub => \&My::Module::validate,
);
$fuzzer2->load_corpus('t/corpus/my_func.json');
my $report = $fuzzer2->run();
MESSAGES
path required-
No path argument was supplied.
Cannot read corpus from $path: $!-
The file could not be opened for reading (missing file, permissions, etc.).
FORMAL SPECIFICATION
Note: load_corpus does not restore bugs from the JSON file — the bugs array in the JSON is written by save_corpus but ignored on load. The loaded fuzzer starts with an empty bugs list. Seed values are also not restored; the constructor-supplied seed is retained.
minimize_corpus
Reduce the corpus to the smallest subset that still covers every branch hit by the full corpus, using a greedy set-cover algorithm.
Entries without branch data (loaded from a previous run, or kept by random sampling when Devel::Cover is unavailable) are deduplicated by input fingerprint and retained in full — they cannot be evaluated for coverage contribution without re-running them. Bug-triggering inputs are always kept regardless of coverage contribution.
my $stats = $fuzzer->minimize_corpus();
printf "Corpus: %d -> %d entries (%d branches covered)\n",
$stats->{before}, $stats->{after}, $stats->{branches};
Returns
A hashref with keys before (corpus size before), after (corpus size after), and branches (total unique branches still covered).
API specification
input
{ self => { type => OBJECT, isa => 'App::Test::Generator::CoverageGuidedFuzzer' } }
output
{
type => HASHREF,
constraint => sub { defined $_[0]{before} && defined $_[0]{after} && defined $_[0]{branches} },
}
EXAMPLE
$fuzzer->run();
my $stats = $fuzzer->minimize_corpus();
printf "Corpus: %d -> %d entries (%d branches)\n",
$stats->{before}, $stats->{after}, $stats->{branches};
$fuzzer->save_corpus('t/corpus/my_func.json');
FORMAL SPECIFICATION
Post: result->{before} == pre corpus size ∧ result->{after} ≤ result->{before} ∧ scalar @{self->{corpus}} == result->{after} ∧ every branch covered by the original corpus is still covered by the minimized corpus ∧ every bug-triggering input survives minimization
PSEUDOCODE
partition corpus into with-coverage and without-coverage entries
greedy set-cover: repeatedly pick entry covering most uncovered branches
deduplicate without-coverage entries by JSON fingerprint
unconditionally add all bug inputs not already in minimized set
replace self.corpus with minimized list
return { before, after, branches }
COMMON PITFALLS
- Forgetting that
load_corpusdoes not restore bugs -
The JSON written by
save_corpuscontains abugsarray, butload_corpusreads only thecorpuskey. A freshly loaded fuzzer always starts with an empty bugs list. If you need to carry bugs across sessions, persist them separately. - Assuming coverage guidance is always active
-
If
Devel::Coveris not installed, the fuzzer falls back to random-only mode and emits a warning. Corpus entries in this mode havecoverage => {}and are kept using theRANDOM_KEEP_RATIO(20%) heuristic rather than branch novelty. InstallDevel::Cover(orcpanm --with-recommends App::Test::Generator) for full coverage-guided behaviour. - Using
refaddroncorpus()afterminimize_corpus -
minimize_corpusreplaces$self->{corpus}with a new arrayref. Any caller that holds a reference to the old arrayref (e.g. from a priorcorpus()call) will see a stale snapshot. Always callcorpus()afterminimize_corpusif you need the current list. - Setting
timeout => 0on blocking targets -
Setting
timeoutto 0 disables the per-callalarm(). This is correct for targets that legitimately block (e.g. sleeping, waiting on I/O), but means a hung target_sub will hang the whole fuzzing run indefinitely. Use a process-level timeout (e.g.Sys::AlarmCall) if you need a safety net for blocking code.
LIMITATIONS
- Single-threaded
-
All fuzzing iterations run sequentially in the calling process. For large iteration counts, wall-clock time scales linearly. Parallelism requires splitting the iteration budget across multiple fuzzer instances and merging their corpora.
- Coverage granularity is branch-level
-
Branch coverage is the finest granularity Devel::Cover exposes via its public API. Path coverage (distinct sequences of branches) is not tracked — two inputs that cover the same branches but exercise different call orders are treated as equivalent.
- No inter-run learning without save/load
-
The corpus is entirely in memory. To carry learning across separate invocations, call
save_corpusat the end andload_corpusat the start of each subsequent run.
SEE ALSO
- Devel::Cover
- App::Test::Generator
bin/extract-schemas(the--minimize-corpusflag)
AUTHOR
Nigel Horne, <njh at nigelhorne.com>
Portions of this module's initial design and documentation were created with the assistance of AI.
LICENCE AND COPYRIGHT
Copyright 2026 Nigel Horne.
Usage is subject to GPL2 licence terms. If you use it, please let me know.
1 POD Error
The following errors were encountered while parsing the POD:
- Around line 202:
Non-ASCII character seen before =encoding in '∧'. Assuming UTF-8