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

  • schema

    A hashref representing the parsed YAML schema for the target function. Required.

  • target_sub

    A CODE reference to the function under test. Required.

  • iterations

    Number of fuzzing iterations to run. Optional - defaults to 100.

  • seed

    Random seed for reproducible runs. Optional - defaults to time().

  • instance

    An optional pre-built object to use as the invocant when calling the target sub as a method.

  • timeout

    Seconds to allow each target_sub call before it is aborted via alarm() 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

schema was not supplied or was falsy (e.g. undef or 0).

target_sub required

target_sub was 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

  • $path

    Path 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::MaybeXS nor JSON is 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

  • $path

    Path 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_corpus does not restore bugs

The JSON written by save_corpus contains a bugs array, but load_corpus reads only the corpus key. 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::Cover is not installed, the fuzzer falls back to random-only mode and emits a warning. Corpus entries in this mode have coverage => {} and are kept using the RANDOM_KEEP_RATIO (20%) heuristic rather than branch novelty. Install Devel::Cover (or cpanm --with-recommends App::Test::Generator) for full coverage-guided behaviour.

Using refaddr on corpus() after minimize_corpus

minimize_corpus replaces $self->{corpus} with a new arrayref. Any caller that holds a reference to the old arrayref (e.g. from a prior corpus() call) will see a stale snapshot. Always call corpus() after minimize_corpus if you need the current list.

Setting timeout => 0 on blocking targets

Setting timeout to 0 disables the per-call alarm(). 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_corpus at the end and load_corpus at the start of each subsequent run.

SEE ALSO

Devel::Cover
App::Test::Generator
bin/extract-schemas (the --minimize-corpus flag)

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