File Coverage

File:blib/lib/App/Test/Generator/CoverageGuidedFuzzer.pm
Coverage:86.7%

linestmtbrancondsubtimecode
1package App::Test::Generator::CoverageGuidedFuzzer;
2
3
15
15
15
229143
16
185
use strict;
4
15
15
15
22
16
366
use warnings;
5
15
15
15
25
13
369
use Carp    qw(croak);
6
15
15
15
28
14
931
use feature 'state';
7
15
15
15
481
3149
35977
use Readonly;
8
9# --------------------------------------------------
10# Fuzzing loop parameters
11# --------------------------------------------------
12Readonly my $DEFAULT_ITERATIONS   => 100;
13Readonly my $DEFAULT_TIMEOUT_SECS => 5;     # per-call alarm() timeout; 0 disables
14Readonly my $CORPUS_MUTATE_RATIO  => 0.70;  # 70% mutate, 30% explore
15Readonly my $RANDOM_KEEP_RATIO    => 0.20;  # keep 20% random when no coverage
16Readonly my $EDGE_CASE_RATIO      => 0.40;  # 40% chance to use declared edge case
17Readonly my $INT_BOUNDARY_RATIO   => 0.30;  # 30% chance to use boundary int
18Readonly my $STR_BOUNDARY_RATIO   => 0.30;  # 30% chance to use boundary length
19Readonly my $SEED_CORPUS_SIZE     => 5;     # initial random inputs to seed corpus
20Readonly my $DEFAULT_MAX_STR_LEN  => 64;
21Readonly my $MATCHES_REGEX_TIMEOUT_SECS => 1; # ReDoS guard for schema 'matches' patterns
22Readonly my $DEFAULT_MAX_ARRAY    => 4;     # max elements in random array (0..N)
23Readonly my $INT32_MAX            => 2**31 - 1;
24Readonly my $INT32_MIN            => -(2**31);
25
26# --------------------------------------------------
27# Character set and interesting-string constants —
28# computed once at load time rather than rebuilt on
29# every _rand_string / _mutate_string call.
30# --------------------------------------------------
31Readonly my @RAND_CHARS => ('a'..'z', 'A'..'Z', '0'..'9', ' ', "\t", "\n", "\0");
32Readonly my @INTERESTING_STRINGS => (
33        '', ' ', "\0", "\n", "\t",
34        'a' x 256,
35        'null', 'undefined',
36        "'; DROP TABLE foo; --",
37        '<script>alert(1)</script>',
38);
39
40# --------------------------------------------------
41# Type name constants — used in schema dispatch
42# --------------------------------------------------
43Readonly my $TYPE_INTEGER => 'integer';
44Readonly my $TYPE_NUMBER  => 'number';
45Readonly my $TYPE_BOOLEAN => 'boolean';
46Readonly my $TYPE_ARRAY   => 'arrayref';
47Readonly my $TYPE_HASH    => 'hashref';
48Readonly my $TYPE_STRING  => 'string';
49
50# --------------------------------------------------
51# JSON module preference order
52# --------------------------------------------------
53Readonly my @JSON_MODULES => qw(JSON::MaybeXS JSON);
54
55our $VERSION = '0.46';
56
57 - 208
=head1 NAME

App::Test::Generator::CoverageGuidedFuzzer - AFL-style coverage-guided fuzzing for App::Test::Generator

=head1 VERSION

Version 0.46

=head1 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');

=head1 DESCRIPTION

Implements coverage-guided fuzzing on top of App::Test::Generator's
existing schema-driven input generation. Instead of purely random
generation it:

=over 4

=item 1. Generates or mutates a structured input

=item 2. Runs the target sub under Devel::Cover to capture branch hits

=item 3. Keeps inputs that discover new branches in a corpus

=item 4. Preferentially mutates corpus entries in future iterations

=back

This is the Perl equivalent of what AFL/libFuzzer do at the byte level,
but operating on typed, schema-validated Perl data structures.

=head1 METHODS

=head2 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
    );

=head3 Arguments

=over 4

=item * C<schema>

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

=item * C<target_sub>

A CODE reference to the function under test. Required.

=item * C<iterations>

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

=item * C<seed>

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

=item * C<instance>

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

=item * C<timeout>

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

=back

=head3 Returns

A blessed hashref. Croaks if C<schema> or C<target_sub> is missing.

=head3 API specification

=head4 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 },
    }

=head4 output

    {
        type => OBJECT,
        isa  => 'App::Test::Generator::CoverageGuidedFuzzer',
    }

=head3 EXAMPLE

    my $fuzzer = App::Test::Generator::CoverageGuidedFuzzer->new(
        schema     => { name => { type => 'string' } },
        target_sub => sub { length($_[0]) },
        iterations => 50,
        seed       => 1234,
    );

=head3 MESSAGES

=over 4

=item C<schema required>

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

=item C<target_sub required>

C<target_sub> was not supplied or was falsy.

=back

=head3 FORMAL SPECIFICATION

Pre:  C<defined schema ∧ ref(target_sub) eq 'CODE'>

Post: C<ref(result) eq 'App::Test::Generator::CoverageGuidedFuzzer'>
      âˆ§ C<result-E<gt>{seed}> passed to C<srand()>
      âˆ§ C<result-E<gt>{corpus} eq []>

=cut
209
210sub new {
211
209
857889
        my ($class, %args) = @_;
212
213
209
486
        croak 'schema required'     unless $args{schema};
214
204
245
        croak 'target_sub required' unless $args{target_sub};
215
216        my $self = bless {
217                schema     => $args{schema},
218                target_sub => $args{target_sub},
219                instance   => $args{instance},
220                iterations => $args{iterations} // $DEFAULT_ITERATIONS,
221                seed       => $args{seed}       // time(),
222
199
757
                timeout    => $args{timeout}    // $DEFAULT_TIMEOUT_SECS,
223                corpus     => [],   # [{input => ..., coverage => {...}}]
224                covered    => {},   # "file:line:branch" => 1
225                bugs       => [],   # [{input => ..., error => ...}]
226                stats      => {
227                        total       => 0,
228                        interesting => 0,
229                        bugs        => 0,
230                        coverage    => 0,
231                },
232                _cover_available => undef,
233        }, $class;
234
235
199
1338
        srand($self->{seed});
236
237        # Probe for Devel::Cover availability once at construction time
238
199
199
199
160
424
268
        $self->{_cover_available} = eval { require Devel::Cover; 1 } ? 1 : 0;
239
240        # Warn once per process if coverage guidance is unavailable
241
199
164
        state $cover_warned = 0;
242
199
238
        if(!$self->{_cover_available} && !$cover_warned++) {
243
0
0
                warn 'Devel::Cover not available; fuzzing without coverage guidance.';
244        }
245
246
199
280
        return $self;
247}
248
249 - 325
=head2 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};

=head3 Arguments

None beyond C<$self>.

=head3 Returns

A hashref with keys C<total_iterations>, C<interesting_inputs>,
C<corpus_size>, C<branches_covered>, C<bugs_found>, and C<bugs>.

=head3 Notes

A C<target_sub> call that dies is only recorded in C<bugs> when the
input that triggered it is valid per C<schema>. A die triggered by an
input the schema itself marks invalid (e.g. out of the declared
C<min>/C<max> range) is expected behaviour, not a bug, and is silently
discarded.

=head3 API specification

=head4 input

    {
        self => { type => OBJECT, isa => 'App::Test::Generator::CoverageGuidedFuzzer' },
    }

=head4 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 },
        },
    }

=head3 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};
    }

=head3 FORMAL SPECIFICATION

Pre:  C<self-E<gt>{schema}> and C<self-E<gt>{target_sub}> are set

Post: C<result-E<gt>{total_iterations} == self-E<gt>{iterations}>
      âˆ§ C<result-E<gt>{corpus_size} == scalar @{self-E<gt>{corpus}}>
      âˆ§ C<result-E<gt>{bugs_found} == scalar @{self-E<gt>{bugs}}>

=head3 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

=cut
326
327sub run {
328
67
440
        my ($self) = @_;
329
330        # Phase 1: seed the corpus with a small set of random inputs
331
67
121
        $self->_seed_corpus();
332
333        # Phase 2: main fuzzing loop — alternate between mutation and exploration
334
67
511
        for my $i (1 .. $self->{iterations}) {
335
643
416
                my $input;
336
337
643
643
351
765
                if(@{ $self->{corpus} } && rand() < $CORPUS_MUTATE_RATIO) {
338                        # Mutate a randomly chosen corpus entry
339
470
470
963
411
                        my $parent = $self->{corpus}[ int(rand(@{ $self->{corpus} })) ];
340
470
439
                        $input = $self->_mutate($parent->{input});
341                } else {
342                        # Fresh random generation for exploration
343
173
380
                        $input = $self->_generate_random();
344                }
345
346
643
1809
                $self->_run_one($input);
347
643
1356
                $self->{stats}{total}++;
348        }
349
350
67
67
54
84
        $self->{stats}{coverage} = scalar keys %{ $self->{covered} };
351
67
91
        return $self->_build_report();
352}
353
354 - 377
=head2 corpus

Return the accumulated corpus as an arrayref of hashrefs with keys
C<input> and C<coverage>.

    my $corpus = $fuzzer->corpus();

=head3 API specification

=head4 input

    { self => { type => OBJECT, isa => 'App::Test::Generator::CoverageGuidedFuzzer' } }

=head4 output

    { type => ARRAYREF }

=head3 EXAMPLE

    my $corpus = $fuzzer->corpus();
    printf "%d entries in corpus\n", scalar @{$corpus};
    # Each entry: { input => ..., coverage => { 'file:line:branch' => 1, ... } }

=cut
378
379
46
4960
sub corpus { $_[0]->{corpus} }
380
381 - 405
=head2 bugs

Return bugs found as an arrayref of hashrefs with keys C<input> and
C<error>.

    my $bugs = $fuzzer->bugs();

=head3 API specification

=head4 input

    { self => { type => OBJECT, isa => 'App::Test::Generator::CoverageGuidedFuzzer' } }

=head4 output

    { type => ARRAYREF }

=head3 EXAMPLE

    my $bugs = $fuzzer->bugs();
    for my $b (@{$bugs}) {
        printf "Bug: input=%s  error=%s\n", $b->{input}, $b->{error};
    }

=cut
406
407
15
771
sub bugs { $_[0]->{bugs} }
408
409 - 472
=head2 save_corpus

Serialise the corpus to a JSON file for replay or extension on future
runs.

    $fuzzer->save_corpus('t/corpus/validate.json');

=head3 Arguments

=over 4

=item * C<$path>

Path to write the JSON corpus file. Required.

=back

=head3 Returns

Nothing. Croaks if the file cannot be written or no JSON module is
available.

=head3 Side effects

Writes a JSON file to C<$path>.

=head3 API specification

=head4 input

    {
        self => { type => OBJECT, isa => 'App::Test::Generator::CoverageGuidedFuzzer' },
        path => { type => SCALAR },
    }

=head4 output

    { type => UNDEF }

=head3 EXAMPLE

    $fuzzer->run();
    $fuzzer->minimize_corpus();
    $fuzzer->save_corpus('t/corpus/my_func.json');

=head3 MESSAGES

=over 4

=item C<path required>

No path argument was supplied.

=item C<Cannot write corpus to $path: $!>

The file could not be opened for writing (permissions, missing directory, etc.).

=item C<No JSON module available; install JSON or JSON::MaybeXS>

Neither C<JSON::MaybeXS> nor C<JSON> is installed.

=back

=cut
473
474sub save_corpus {
475
25
3455
        my ($self, $path) = @_;
476
477
25
73
        croak 'path required' unless defined $path;
478
479
22
34
        my $json = _load_json_module();
480
481
22
863
        open my $fh, '>', $path
482                or croak "Cannot write corpus to $path: $!";
483
484        print $fh $json->new->pretty->encode({
485                seed   => $self->{seed},
486
132
18
436
52
                corpus => [ map { { input => $_->{input} } } @{ $self->{corpus} } ],
487                bugs   => $self->{bugs},
488
18
221
        });
489
490
18
730
        close $fh;
491}
492
493 - 562
=head2 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');

=head3 Arguments

=over 4

=item * C<$path>

Path to the JSON corpus file to load. Required.

=back

=head3 Returns

Nothing. Croaks if the file cannot be read or no JSON module is
available.

=head3 Side effects

Appends loaded entries to C<< $self->{corpus} >>.

=head3 API specification

=head4 input

    {
        self => { type => OBJECT, isa => 'App::Test::Generator::CoverageGuidedFuzzer' },
        path => { type => SCALAR },
    }

=head4 output

    { type => UNDEF }

=head3 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();

=head3 MESSAGES

=over 4

=item C<path required>

No path argument was supplied.

=item C<Cannot read corpus from $path: $!>

The file could not be opened for reading (missing file, permissions, etc.).

=back

=head3 FORMAL SPECIFICATION

Note: C<load_corpus> does B<not> restore C<bugs> from the JSON file —
the C<bugs> array in the JSON is written by C<save_corpus> but ignored on load.
The loaded fuzzer starts with an empty C<bugs> list.  Seed values are also not
restored; the constructor-supplied seed is retained.

=cut
563
564sub load_corpus {
565
20
777
        my ($self, $path) = @_;
566
567
20
41
        croak 'path required' unless defined $path;
568
569
17
22
        my $json = _load_json_module();
570
571
17
276
        open my $fh, '<', $path
572                or croak "Cannot read corpus from $path: $!";
573
574
12
12
12
89
25
350
        my $data = $json->new->decode(do { local $/; <$fh> });
575
11
100
        close $fh;
576
577        # Load corpus entries with empty coverage — coverage state from a
578        # previous process cannot be restored, only the inputs themselves
579
11
11
14
26
        for my $entry (@{ $data->{corpus} // [] }) {
580
87
164
                push @{ $self->{corpus} }, {
581                        input    => $entry->{input},
582
87
61
                        coverage => {},
583                };
584        }
585}
586
587 - 646
=head2 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};

=head3 Returns

A hashref with keys C<before> (corpus size before), C<after> (corpus
size after), and C<branches> (total unique branches still covered).

=head3 API specification

=head4 input

    { self => { type => OBJECT, isa => 'App::Test::Generator::CoverageGuidedFuzzer' } }

=head4 output

    {
        type       => HASHREF,
        constraint => sub { defined $_[0]{before} && defined $_[0]{after} && defined $_[0]{branches} },
    }

=head3 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');

=head3 FORMAL SPECIFICATION

Post: C<result-E<gt>{before}> == pre corpus size
      âˆ§ C<result-E<gt>{after}> ≤ C<result-E<gt>{before}>
      âˆ§ C<scalar @{self-E<gt>{corpus}}> == C<result-E<gt>{after}>
      âˆ§ every branch covered by the original corpus is still covered
        by the minimized corpus
      âˆ§ every bug-triggering input survives minimization

=head3 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 }

=cut
647
648sub minimize_corpus {
649
24
9571
        my ($self) = @_;
650
651
24
24
20
33
        my @all    = @{ $self->{corpus} };
652
24
23
        my $before = scalar @all;
653
654
24
111
111
25
59
94
        my @with_cov    = grep {  %{ $_->{coverage} } } @all;
655
24
111
111
24
71
87
        my @without_cov = grep { !%{ $_->{coverage} } } @all;
656
657        # Greedy set-cover: find the smallest subset of with-coverage entries
658        # that still covers every branch seen across the whole corpus.
659
24
20
        my %uncovered;
660
24
17
17
36
11
27
        $uncovered{$_} = 1 for map { keys %{ $_->{coverage} } } @with_cov;
661
24
32
        my $total_branches = scalar keys %uncovered;
662
663
24
16
        my @selected;
664
24
47
        while (%uncovered && @with_cov) {
665
13
13
                my ($best, $best_idx, $best_n) = (undef, -1, 0);
666
13
16
                for my $i (0 .. $#with_cov) {
667
26
33
26
14
31
22
                        my $n = grep { $uncovered{$_} } keys %{ $with_cov[$i]{coverage} };
668
26
25
                        if ($n > $best_n) {
669
13
9
                                $best_n   = $n;
670
13
9
                                $best_idx = $i;
671
13
12
                                $best     = $with_cov[$i];
672                        }
673                }
674
13
13
                last unless $best_n;
675
676
13
11
                push @selected, $best;
677
13
12
                splice @with_cov, $best_idx, 1;
678
13
13
9
24
                delete $uncovered{$_} for keys %{ $best->{coverage} };
679        }
680
681        # Deduplicate no-coverage entries by input fingerprint so that repeated
682        # loads of the same corpus file do not cause the entry count to grow.
683
24
18
        my %seen;
684        my @deduped;
685
24
32
        for my $entry (@without_cov) {
686
94
81
                my $key = _fingerprint($entry->{input});
687
94
122
                next if $seen{$key}++;
688
83
70
                push @deduped, $entry;
689        }
690
691
24
26
        my @minimized = (@selected, @deduped);
692
693        # Bug-triggering inputs are always kept — they are the most valuable
694        # findings regardless of whether they add unique branch coverage.
695
24
96
24
75
        my %kept = map { _fingerprint($_->{input}) => 1 } @minimized;
696
24
24
26
27
        for my $bug (@{ $self->{bugs} }) {
697
61
48
                my $key = _fingerprint($bug->{input});
698
61
69
                unless ($kept{$key}++) {
699
33
39
                        push @minimized, { input => $bug->{input}, coverage => {} };
700                }
701        }
702
703
24
31
        $self->{corpus} = \@minimized;
704
705        return {
706
24
83
                before   => $before,
707                after    => scalar @minimized,
708                branches => $total_branches,
709        };
710}
711
712# --------------------------------------------------
713# _fingerprint
714#
715# Purpose:    Produce a stable, canonical string key
716#             for an arbitrary Perl value, for use as
717#             a deduplication key.
718#
719# Entry:      $val - any Perl value (scalar, ref, undef).
720# Exit:       Returns a deterministic string.
721# Side effects: None.
722#
723# Notes:      Uses the JSON module in canonical mode so
724#             hash keys are always sorted.  Avoids
725#             Data::Dumper whose output includes blessed
726#             class names that vary across Perl versions.
727# --------------------------------------------------
728sub _fingerprint {
729
251
157
        my ($val) = @_;
730
251
202
        return 'null' unless defined $val;
731
240
150
        state $encoder;
732
240
188
        unless ($encoder) {
733
5
5
                my $mod = _load_json_module();
734
5
5
5
89
                $encoder = eval { $mod->new->canonical(1) };
735        }
736
240
240
159
542
        return eval { $encoder->encode($val) } // "$val";
737}
738
739# --------------------------------------------------
740# _load_json_module
741#
742# Find and load the first available JSON
743#     module from the preference list.
744#
745# Entry:      None.
746# Exit:       Returns the name of the loaded module.
747#             Croaks if none are available.
748#
749# Side effects: Loads a JSON module into the process.
750#
751# Notes:      Uses explicit require rather than string
752#             eval for safety. JSON::MaybeXS is
753#             preferred over JSON.
754# --------------------------------------------------
755sub _load_json_module {
756
46
2416
        state $cached;
757
46
78
        return $cached if defined $cached;
758
11
34
        for my $mod (@JSON_MODULES) {
759                # Convert package name to file path — require $var does not
760                # do the :: -> / conversion that bareword require does
761
11
45
                (my $file = $mod) =~ s{::}{/}g;
762
11
64
                $file .= '.pm';
763
11
11
11
12
33
44
                if (eval { require $file; 1 }) {
764
11
35
                        return $cached = $mod;
765                }
766        }
767
0
0
        croak 'No JSON module available; install JSON or JSON::MaybeXS';
768}
769
770# --------------------------------------------------
771# _run_one
772#
773# Run the target sub with a single input,
774#     record coverage, detect bugs, and update
775#     the corpus if the input is interesting.
776#
777# Entry:      $input - the value to pass to target_sub.
778#
779# Exit:       Returns nothing. Updates $self->{corpus},
780#             $self->{bugs}, and $self->{covered}.
781#
782# Side effects: Calls target_sub. May update corpus
783#               and covered hashes.
784#
785# Notes:      When Devel::Cover is available, coverage
786#             is captured via _run_with_cover.
787#             Unexpected warnings are treated as soft
788#             bugs if they match known warning patterns.
789# --------------------------------------------------
790sub _run_one {
791
643
486
        my ($self, $input) = @_;
792
793
643
430
        my ($result, $error, $coverage);
794
795
643
520
        if($self->{_cover_available}) {
796
643
561
                $coverage = $self->_run_with_cover($input, \$result, \$error);
797        } else {
798
0
0
                $coverage = {};
799
800                # Include instance as invocant for method calls
801                my @call_args = defined($self->{instance})
802
0
0
                        ? ($self->{instance}, $input)
803                        : ($input);
804
805
0
0
                my @warnings;
806
0
0
                eval {
807
0
0
0
0
                        local $SIG{__WARN__} = sub { push @warnings, @_ };
808
0
0
                        local $SIG{__DIE__};
809                        # A hanging target_sub call would otherwise hang the
810                        # whole fuzzing run — alarm() bounds it and surfaces
811                        # the timeout as a recorded bug instead.
812
0
0
0
0
                        local $SIG{ALRM} = sub { die "target_sub timed out after $self->{timeout}s\n" };
813
0
0
                        alarm($self->{timeout}) if $self->{timeout};
814
0
0
                        $result = $self->{target_sub}->(@call_args);
815                };
816
0
0
                alarm(0) if $self->{timeout};
817
0
0
                $error = $@ if $@;
818
819                # Treat unexpected warnings matching known bad patterns as soft bugs
820
0
0
                if(!defined($error) && @warnings) {
821
0
0
                        my $w = join '', @warnings;
822
0
0
                        $error = "warning: $w"
823                                if $w =~ /uninitialized|undefined|blessed|invalid/i;
824                }
825        }
826
827        # Record bugs — only when the input was valid per the schema.
828        # A die on invalid input is correct behaviour, not a bug.
829
643
610
        if($error && $self->_input_is_valid($input)) {
830
162
162
110
205
                push @{ $self->{bugs} }, { input => $input, error => "$error" };
831
162
129
                $self->{stats}{bugs}++;
832        }
833
834        # Keep the input in the corpus if it exercised new branches
835
643
510
        if($self->_is_interesting($coverage)) {
836
112
112
240
143
                push @{ $self->{corpus} }, { input => $input, coverage => $coverage };
837
112
138
                $self->_update_covered($coverage);
838
112
106
                $self->{stats}{interesting}++;
839        }
840}
841
842# --------------------------------------------------
843# _run_with_cover
844#
845# Purpose:    Run the target sub with Devel::Cover
846#             active and return the set of newly hit
847#             branches as a hashref.
848#
849# Entry:      $input      - value to pass to target_sub.
850#             $result_ref - scalar ref to store result.
851#             $error_ref  - scalar ref to store error.
852#
853# Exit:       Returns a hashref of newly hit branch
854#             keys ("file:line:branch").
855#
856# Side effects: Calls Devel::Cover::start/stop.
857#               Sets $$result_ref and $$error_ref.
858#
859# Notes:      Snapshot comparison is imprecise for
860#             concurrent use but correct for single-
861#             threaded fuzzing. Instance is passed
862#             as invocant when set. Devel::Cover state
863#             only grows, so this iteration's "before"
864#             is exactly the previous iteration's
865#             "after" -- cached in $self to avoid two
866#             full Devel::Cover walks per iteration.
867# --------------------------------------------------
868sub _run_with_cover {
869
644
603
        my ($self, $input, $result_ref, $error_ref) = @_;
870
871
644
876
        Devel::Cover::start() if Devel::Cover->can('start');
872
873
644
594
        my $before_ref = $self->{_last_cover_snapshot} || {};
874
644
644
429
447
        my %before = %{$before_ref};
875
876        # Include instance as invocant for method calls
877        my @call_args = defined($self->{instance})
878
644
580
                ? ($self->{instance}, $input)
879                : ($input);
880
881
644
431
        eval {
882
644
612
                local $SIG{__DIE__};
883                # See _run_one() — bound the call so a hanging target_sub
884                # cannot hang the whole fuzzing run.
885
644
1
2568
1000142
                local $SIG{ALRM} = sub { die "target_sub timed out after $self->{timeout}s\n" };
886
644
1159
                alarm($self->{timeout}) if $self->{timeout};
887
644
750
                $$result_ref = $self->{target_sub}->(@call_args);
888        };
889
644
3387
        alarm(0) if $self->{timeout};
890
644
526
        $$error_ref = $@ if $@;
891
892
644
500
        my $after = $self->_snapshot_cover();
893
644
465
        $self->{_last_cover_snapshot} = $after;
894
644
797
        Devel::Cover::stop() if Devel::Cover->can('stop');
895
896        # Return only branches newly hit in this call
897
644
397
        my %delta;
898
644
644
405
514
        for my $key (keys %{$after}) {
899
0
0
                $delta{$key} = 1 unless exists $before{$key};
900        }
901
902
644
658
        return \%delta;
903}
904
905# --------------------------------------------------
906# _snapshot_cover
907#
908# Purpose:    Take a lightweight snapshot of the
909#             currently hit branches from Devel::Cover.
910#
911# Entry:      None beyond $self.
912# Exit:       Returns a hash of "file:line:branch" keys.
913#
914# Side effects: Reads Devel::Cover internal state.
915#
916# Notes:      Falls back to empty hash if the
917#             Devel::Cover API is not accessible.
918#             All errors are silently swallowed since
919#             coverage is best-effort.
920# --------------------------------------------------
921sub _snapshot_cover {
922
644
444
        my ($self) = @_;
923
644
402
        my %snap;
924
925
644
388
        eval {
926
644
2228
                my $cover = Devel::Cover::get_coverage();
927
644
541
                return unless $cover;
928
929
644
644
374
1374
                for my $file (keys %{$cover}) {
930
0
0
                        my $branch = $cover->{$file}{branch} or next;
931
0
0
0
0
                        for my $line (keys %{$branch}) {
932
0
0
0
0
                                for my $b (0 .. $#{ $branch->{$line} }) {
933                                        $snap{"$file:$line:$b"} = 1
934
0
0
                                                if $branch->{$line}[$b];
935                                }
936                        }
937                }
938        };
939
940
644
508
        return \%snap;
941}
942
943# --------------------------------------------------
944# _is_interesting
945#
946# Purpose:    Return true if the coverage hashref
947#             contains any branch not yet in the
948#             global covered set.
949#
950# Entry:      $coverage - hashref of branch keys.
951# Exit:       Returns 1 if interesting, 0 otherwise.
952#
953# Side effects: None.
954#
955# Notes:      When no coverage data is available,
956#             keeps a random sample of inputs at
957#             RANDOM_KEEP_RATIO so the corpus still
958#             grows even without branch feedback.
959# --------------------------------------------------
960sub _is_interesting {
961
646
452
        my ($self, $coverage) = @_;
962
963        # Check for any newly covered branch
964
646
646
400
472
        for my $key (keys %{$coverage}) {
965
2
5
                return 1 unless $self->{covered}{$key};
966        }
967
968        # No coverage data — keep a random sample to grow the corpus
969
645
645
397
761
        return rand() < $RANDOM_KEEP_RATIO unless %{$coverage};
970
971
1
2
        return 0;
972}
973
974# --------------------------------------------------
975# _update_covered
976#
977# Purpose:    Merge newly covered branches into the
978#             global covered set.
979#
980# Entry:      $coverage - hashref of branch keys.
981# Exit:       Returns nothing. Updates $self->{covered}.
982# Side effects: Modifies $self->{covered}.
983# --------------------------------------------------
984sub _update_covered {
985
114
108
        my ($self, $coverage) = @_;
986
114
114
76
118
        $self->{covered}{$_} = 1 for keys %{$coverage};
987}
988
989# --------------------------------------------------
990# _generate_random
991#
992# Purpose:    Generate a random input value from the
993#             top-level schema input specification.
994#
995# Entry:      None beyond $self.
996# Exit:       Returns a randomly generated value.
997# Side effects: None.
998# --------------------------------------------------
999sub _generate_random {
1000
531
389
        my ($self) = @_;
1001
531
513
        return $self->_generate_for_schema($self->{schema}{input});
1002}
1003
1004# --------------------------------------------------
1005# _generate_for_schema
1006#
1007# Purpose:    Recursively generate a random value
1008#             matching a schema specification hashref.
1009#
1010# Entry:      $spec - schema spec hashref or scalar
1011#             type hint.
1012#
1013# Exit:       Returns a generated value appropriate
1014#             for the spec type, or undef if spec is
1015#             absent or 'undef'.
1016#
1017# Side effects: None.
1018#
1019# Notes:      Edge cases declared in edge_case_array
1020#             are selected at EDGE_CASE_RATIO frequency
1021#             to bias toward known interesting values.
1022# --------------------------------------------------
1023sub _generate_for_schema {
1024
962
1794
        my ($self, $spec) = @_;
1025
1026
962
835
        return undef unless defined $spec;
1027
936
949
        return undef if $spec eq 'undef';
1028
1029
935
1042
        my $type = ref($spec) ? ($spec->{type} // $TYPE_STRING) : $TYPE_STRING;
1030
1031        # Bias toward declared edge cases at EDGE_CASE_RATIO frequency
1032
935
1888
        if(ref($spec) && $spec->{edge_case_array} && rand() < $EDGE_CASE_RATIO) {
1033
6
6
12
5
                my @ec = @{ $spec->{edge_case_array} };
1034
6
7
                return $ec[ int(rand(@ec)) ];
1035        }
1036
1037        # Dispatch to type-specific generator
1038
929
442
917
947
        if    ($type eq $TYPE_INTEGER) { return $self->_rand_int($spec)    }
1039
0
0
        elsif ($type eq $TYPE_NUMBER)  { return $self->_rand_num($spec)    }
1040
19
99
        elsif ($type eq $TYPE_BOOLEAN) { return int(rand(2))               }
1041
8
53
        elsif ($type eq $TYPE_ARRAY)   { return $self->_rand_array($spec)  }
1042
10
74
        elsif ($type eq $TYPE_HASH)    { return $self->_rand_hash($spec)   }
1043
450
3344
        else                           { return $self->_rand_string($spec) }
1044}
1045
1046# --------------------------------------------------
1047# _rand_int
1048#
1049# Purpose:    Generate a random integer within the
1050#             spec's min/max range, biased toward
1051#             boundary values at INT_BOUNDARY_RATIO.
1052#
1053# Entry:      $spec - schema spec hashref.
1054# Exit:       Returns an integer scalar.
1055# Side effects: None.
1056# --------------------------------------------------
1057sub _rand_int {
1058
482
4669
        my ($self, $spec) = @_;
1059
1060
482
541
        my $min = $spec->{min} // $INT32_MIN;
1061
482
1513
        my $max = $spec->{max} // $INT32_MAX;
1062
1063        # Bias toward boundary values to probe edge conditions
1064
482
1353
        if(rand() < $INT_BOUNDARY_RATIO) {
1065
160
384
                my @interesting = ($min, $min + 1, 0, -1, 1, $max - 1, $max);
1066
160
268
                return $interesting[ int(rand(@interesting)) ];
1067        }
1068
1069
322
851
        return $min + int(rand($max - $min + 1));
1070}
1071
1072# --------------------------------------------------
1073# _rand_num
1074#
1075# Purpose:    Generate a random floating point number
1076#             within the spec's min/max range.
1077#
1078# Entry:      $spec - schema spec hashref.
1079# Exit:       Returns a numeric scalar.
1080# Side effects: None.
1081# --------------------------------------------------
1082sub _rand_num {
1083
10
1009
        my ($self, $spec) = @_;
1084
1085
10
10
        my $min = $spec->{min} // -1e9;
1086
10
10
        my $max = $spec->{max} //  1e9;
1087
1088
10
8
        return $min + rand($max - $min);
1089}
1090
1091# --------------------------------------------------
1092# _rand_string
1093#
1094# Purpose:    Generate a random string within the
1095#             spec's min/max length range, biased
1096#             toward boundary lengths.
1097#
1098# Entry:      $spec - schema spec hashref.
1099# Exit:       Returns a string scalar.
1100# Side effects: None.
1101#
1102# Notes:      Character set includes control chars
1103#             and NUL to probe boundary handling.
1104# --------------------------------------------------
1105sub _rand_string {
1106
451
364
        my ($self, $spec) = @_;
1107
1108
451
514
        my $min_len = $spec->{min} // 0;
1109
451
567
        my $max_len = $spec->{max} // $DEFAULT_MAX_STR_LEN;
1110
1111        # Bias toward boundary lengths at STR_BOUNDARY_RATIO frequency
1112
451
1270
        my $len;
1113
451
401
        if(rand() < $STR_BOUNDARY_RATIO) {
1114
57
139
                my @boundary_lens = ($min_len, $min_len + 1, $max_len - 1, $max_len);
1115
57
70
                $len = $boundary_lens[ int(rand(@boundary_lens)) ];
1116        } else {
1117
394
850
                $len = $min_len + int(rand($max_len - $min_len + 1));
1118        }
1119
1120        # Clamp to non-negative
1121
451
386
        $len = 0 if $len < 0;
1122
1123
451
16279
552
57090
        return join '', map { $RAND_CHARS[ int(rand(@RAND_CHARS)) ] } 1 .. $len;
1124}
1125
1126# --------------------------------------------------
1127# _rand_array
1128#
1129# Purpose:    Generate a random arrayref with 0 to
1130#             DEFAULT_MAX_ARRAY elements, each
1131#             generated from the items spec.
1132#
1133# Entry:      $spec - schema spec hashref.
1134# Exit:       Returns an arrayref.
1135# Side effects: None.
1136# --------------------------------------------------
1137sub _rand_array {
1138
208
75540
        my ($self, $spec) = @_;
1139
1140
208
241
        my $items = $spec->{items} // {};
1141
208
243
        my $count = int(rand($DEFAULT_MAX_ARRAY + 1));
1142
1143
208
395
631
390
        return [ map { $self->_generate_for_schema($items) } 1 .. $count ];
1144}
1145
1146# --------------------------------------------------
1147# _rand_hash
1148#
1149# Purpose:    Generate a random hashref with values
1150#             generated from the properties spec.
1151#
1152# Entry:      $spec - schema spec hashref.
1153# Exit:       Returns a hashref.
1154# Side effects: None.
1155# --------------------------------------------------
1156sub _rand_hash {
1157
10
10
        my ($self, $spec) = @_;
1158
1159
10
14
        my $props = $spec->{properties} // {};
1160
10
8
        my %h;
1161
1162
10
10
7
12
        for my $key (keys %{$props}) {
1163
1
3
                $h{$key} = $self->_generate_for_schema($props->{$key});
1164        }
1165
1166
10
23
        return \%h;
1167}
1168
1169# --------------------------------------------------
1170# _input_is_valid
1171#
1172# Purpose:    Return true if the input satisfies all
1173#             constraints in the schema. Used to
1174#             distinguish real bugs (die on valid
1175#             input) from expected failures (die on
1176#             invalid input).
1177#
1178# Entry:      $input - the value to validate.
1179# Exit:       Returns 1 if valid, 0 if not.
1180#             Returns 1 if no schema is available.
1181# Side effects: None.
1182# --------------------------------------------------
1183sub _input_is_valid {
1184
187
146
        my ($self, $input) = @_;
1185
1186
187
141
        my $spec = $self->{schema}{input};
1187
1188        # No schema means we cannot judge validity
1189
187
219
        return 1 unless defined $spec && ref($spec);
1190
1191
186
227
        my $input_style = $self->{schema}{input_style} // '';
1192
1193
186
227
        if($input_style eq 'hash' || ref($input) eq 'HASH') {
1194
2
2
                return $self->_validate_hash_input($input, $spec);
1195        }
1196
1197
184
155
        return $self->_validate_value($input, $spec);
1198}
1199
1200# --------------------------------------------------
1201# _validate_hash_input
1202#
1203# Purpose:    Validate a hash-style input against the
1204#             schema spec, checking each named field.
1205#
1206# Entry:      $input - hashref of named parameters.
1207#             $spec  - schema spec hashref.
1208# Exit:       Returns 1 if valid, 0 if not.
1209# Side effects: None.
1210# --------------------------------------------------
1211sub _validate_hash_input {
1212
13
32
        my ($self, $input, $spec) = @_;
1213
1214
13
16
        return 0 unless defined $input;
1215
1216
11
11
9
12
        for my $key (keys %{$spec}) {
1217                # Skip internal metadata keys
1218
14
16
                next if $key =~ /^_/;
1219
1220
12
21
                my $field_spec = $spec->{$key};
1221
12
14
                next unless ref($field_spec) eq 'HASH';
1222
1223
12
14
                my $value = ref($input) eq 'HASH' ? $input->{$key} : undef;
1224
1225                # Required field missing is always invalid
1226
12
19
                if(!defined($value) && !$field_spec->{optional}) {
1227
3
6
                        return 0;
1228                }
1229
1230
9
10
                next unless defined $value;
1231
1232
7
9
                return 0 unless $self->_validate_value($value, $field_spec);
1233        }
1234
1235
6
13
        return 1;
1236}
1237
1238# --------------------------------------------------
1239# _validate_value
1240#
1241# Purpose:    Validate a single value against a schema
1242#             type spec, checking type and constraints.
1243#
1244# Entry:      $value - the value to validate.
1245#             $spec  - schema spec hashref.
1246# Exit:       Returns 1 if valid, 0 if not.
1247# Side effects: None.
1248#
1249# Notes:      Number validation accepts both integer
1250#             and floating point forms including
1251#             scientific notation. Type mismatch
1252#             always returns 0.
1253# --------------------------------------------------
1254sub _validate_value {
1255
231
266
        my ($self, $value, $spec) = @_;
1256
1257        # Undef is never valid unless optional — caller already checked optional
1258
231
199
        return 0 unless defined $value;
1259
1260
229
251
        my $type = $spec->{type} // $TYPE_STRING;
1261
1262
229
466
        if($type eq $TYPE_INTEGER) {
1263
51
152
                return 0 unless $value =~ /^-?\d+$/;
1264
47
80
                return 0 if defined($spec->{min}) && $value < $spec->{min};
1265
28
44
                return 0 if defined($spec->{max}) && $value > $spec->{max};
1266        }
1267        elsif($type eq $TYPE_NUMBER) {
1268                # Accept integers, decimals, and scientific notation
1269
5
32
                return 0 unless $value =~ /^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
1270
4
7
                return 0 if defined($spec->{min}) && $value < $spec->{min};
1271
4
7
                return 0 if defined($spec->{max}) && $value > $spec->{max};
1272        }
1273        elsif($type eq $TYPE_STRING) {
1274
160
716
                my $len = length($value);
1275
160
186
                return 0 if defined($spec->{min}) && $len < $spec->{min};
1276
158
164
                return 0 if defined($spec->{max}) && $len > $spec->{max};
1277
155
132
                if(defined($spec->{matches})) {
1278
5
17
                        (my $pat = $spec->{matches}) =~ s{^/(.+)/$}{$1};
1279
1280                        # ReDoS guard: a schema-supplied pattern matched against
1281                        # fuzzer-generated (attacker-shaped) input could exhibit
1282                        # catastrophic backtracking. Bound the match with alarm()
1283                        # the same way target_sub calls are bounded elsewhere in
1284                        # this module, and treat a timeout as a non-match.
1285
5
5
                        my $matched = eval {
1286
5
0
43
0
                                local $SIG{ALRM} = sub { die "matches regex timed out\n" };
1287
5
8
                                alarm($MATCHES_REGEX_TIMEOUT_SECS);
1288
5
148
                                my $m = $value =~ /$pat/;
1289
5
11
                                alarm(0);
1290
5
23
                                $m;
1291                        };
1292
5
10
                        alarm(0);
1293
5
11
                        return 0 unless $matched;
1294                }
1295        }
1296        elsif($type eq $TYPE_BOOLEAN) {
1297
6
48
                return 0 unless $value =~ /^[01]$/;
1298        }
1299        elsif($type eq $TYPE_ARRAY || $type eq 'array') {
1300
4
40
                return 0 unless ref($value) eq 'ARRAY';
1301        }
1302        elsif($type eq $TYPE_HASH || $type eq 'hash') {
1303
3
35
                return 0 unless ref($value) eq 'HASH';
1304        }
1305
1306
188
245
        return 1;
1307}
1308
1309# --------------------------------------------------
1310# _mutate
1311#
1312# Purpose:    Apply a random mutation to an input
1313#             value, dispatching on its type.
1314#
1315# Entry:      $input - the value to mutate.
1316# Exit:       Returns a mutated copy of the input.
1317# Side effects: None.
1318#
1319# Notes:      Blessed references are passed through
1320#             unchanged. Undef is replaced with a
1321#             freshly generated random value.
1322# --------------------------------------------------
1323sub _mutate {
1324
492
1072
        my ($self, $input) = @_;
1325
1326
492
336
        my $type = ref($input);
1327
1328
492
484
        if(!defined $input) {
1329                # Replace undef with a fresh random value
1330
13
14
                return $self->_generate_random();
1331        }
1332        elsif(!$type) {
1333                # Dispatch scalar mutation based on apparent type
1334
459
743
                if($input =~ /^-?\d+$/) {
1335
47
46
                        return $self->_mutate_int($input);
1336                } elsif($input =~ /^-?[\d.]+$/) {
1337
2
4
                        return $self->_mutate_num($input);
1338                } else {
1339
410
393
                        return $self->_mutate_string($input);
1340                }
1341        }
1342        elsif($type eq 'ARRAY') {
1343
10
11
                return $self->_mutate_array($input);
1344        }
1345        elsif($type eq 'HASH') {
1346
8
10
                return $self->_mutate_hash($input);
1347        }
1348
1349        # Blessed refs and other types pass through unchanged
1350
2
4
        return $input;
1351}
1352
1353# --------------------------------------------------
1354# _mutate_int
1355#
1356# Purpose:    Apply a random arithmetic mutation to
1357#             an integer value.
1358#
1359# Entry:      $n - the integer to mutate.
1360# Exit:       Returns a mutated integer.
1361# Side effects: None.
1362# --------------------------------------------------
1363sub _mutate_int {
1364
52
953
        my ($self, $n) = @_;
1365
52
51
        my $op = int(rand(8));
1366
52
58
        return $n + 1                      if $op == 0;
1367
44
42
        return $n - 1                      if $op == 1;
1368
39
41
        return $n * 2                      if $op == 2;
1369
32
46
        return $n == 0 ? 1 : int($n / 2)  if $op == 3;
1370
24
25
        return -$n                         if $op == 4;
1371
13
18
        return 0                           if $op == 5;
1372
10
16
        return $INT32_MAX                  if $op == 6;
1373
2
3
        return $INT32_MIN;
1374}
1375
1376# --------------------------------------------------
1377# _mutate_num
1378#
1379# Purpose:    Apply a random arithmetic mutation to
1380#             a floating point value.
1381#
1382# Entry:      $n - the number to mutate.
1383# Exit:       Returns a mutated number.
1384# Side effects: None.
1385# --------------------------------------------------
1386sub _mutate_num {
1387
5
460
        my ($self, $n) = @_;
1388
5
6
        my $op = int(rand(5));
1389
5
7
        return $n + rand(10)      if $op == 0;
1390
5
10
        return $n - rand(10)      if $op == 1;
1391
3
5
        return $n * (1 + rand())  if $op == 2;
1392
2
4
        return 0                  if $op == 3;
1393
0
0
        return -$n;
1394}
1395
1396# --------------------------------------------------
1397# _mutate_string
1398#
1399# Purpose:    Apply a random structural mutation to
1400#             a string value — bit flip, insert,
1401#             delete, truncate, repeat, or replace
1402#             with an interesting known value.
1403#
1404# Entry:      $s - the string to mutate.
1405# Exit:       Returns a mutated string.
1406# Side effects: None.
1407# --------------------------------------------------
1408sub _mutate_string {
1409
413
544
        my ($self, $s) = @_;
1410
1411
413
278
        my $len = length($s);
1412
1413        my @ops = (
1414                # Bit flip a random character
1415                sub {
1416
51
60
                        return $s unless $len;
1417
50
40
                        my $pos  = int(rand($len));
1418
50
46
                        my $char = substr($s, $pos, 1);
1419
50
74
                        substr($s, $pos, 1) = chr(ord($char) ^ (1 << int(rand(8))));
1420
50
147
                        $s
1421                },
1422                # Insert a random byte
1423                sub {
1424
113
116
                        my $pos  = int(rand($len + 1));
1425
113
126
                        my $char = chr(int(rand(256)));
1426
113
120
                        substr($s, $pos, 0, $char);
1427
113
456
                        $s
1428                },
1429                # Delete a random character
1430                sub {
1431
36
58
                        return $s unless $len;
1432
34
45
                        substr($s, int(rand($len)), 1, '');
1433
34
106
                        $s
1434                },
1435                # Truncate at a random position
1436
43
167
                sub { substr($s, 0, int(rand($len + 1))) },
1437                # Double the string
1438
106
356
                sub { $s x 2 },
1439                # Replace with a known interesting string
1440
64
73
                sub { $INTERESTING_STRINGS[ int(rand(@INTERESTING_STRINGS)) ] },
1441
413
1187
        );
1442
1443
413
408
        return $ops[ int(rand(@ops)) ]->();
1444}
1445
1446# --------------------------------------------------
1447# _mutate_array
1448#
1449# Purpose:    Apply a random structural mutation to
1450#             an arrayref — mutate element, duplicate,
1451#             delete, or empty.
1452#
1453# Entry:      $arr - the arrayref to mutate.
1454# Exit:       Returns a mutated arrayref copy.
1455# Side effects: None.
1456# --------------------------------------------------
1457sub _mutate_array {
1458
12
15
        my ($self, $arr) = @_;
1459
1460
12
12
9
13
        my @copy = @{$arr};
1461
1462        my @ops = (
1463                # Mutate a random element
1464                sub {
1465
3
4
                        return [] unless @copy;
1466
3
3
                        my $i = int(rand(@copy));
1467
3
7
                        $copy[$i] = $self->_mutate($copy[$i]);
1468                        \@copy
1469
3
7
                },
1470                # Duplicate a random element
1471                sub {
1472
3
32
                        return \@copy unless @copy;
1473
1
1
                        my $i = int(rand(@copy));
1474
1
2
                        splice @copy, $i, 0, $copy[$i];
1475                        \@copy
1476
1
3
                },
1477                # Delete a random element
1478                sub {
1479
5
9
                        return \@copy unless @copy;
1480
4
6
                        splice @copy, int(rand(@copy)), 1;
1481                        \@copy
1482
4
18
                },
1483                # Return empty array
1484
1
3
                sub { [] },
1485
12
37
        );
1486
1487
12
18
        return $ops[ int(rand(@ops)) ]->();
1488}
1489
1490# --------------------------------------------------
1491# _mutate_hash
1492#
1493# Purpose:    Apply a random mutation to one value
1494#             in a hashref copy.
1495#
1496# Entry:      $h - the hashref to mutate.
1497# Exit:       Returns a mutated hashref copy.
1498# Side effects: None.
1499# --------------------------------------------------
1500sub _mutate_hash {
1501
11
15
        my ($self, $h) = @_;
1502
1503
11
11
8
14
        my %copy = %{$h};
1504
11
13
        my @keys = keys %copy;
1505
1506        # Return unchanged if hash is empty
1507
11
18
        return \%copy unless @keys;
1508
1509
4
7
        my $k = $keys[ int(rand(@keys)) ];
1510
4
8
        $copy{$k} = $self->_mutate($copy{$k});
1511
1512
4
7
        return \%copy;
1513}
1514
1515# --------------------------------------------------
1516# _seed_corpus
1517#
1518# Purpose:    Pre-populate the corpus with a small
1519#             set of randomly generated inputs to
1520#             give the fuzzing loop a starting point.
1521#
1522# Entry:      None beyond $self.
1523# Exit:       Returns nothing. Appends to $self->{corpus}.
1524# Side effects: Modifies $self->{corpus}.
1525# --------------------------------------------------
1526sub _seed_corpus {
1527
69
72
        my $self = $_[0];
1528
1529
69
103
        for (1 .. $SEED_CORPUS_SIZE) {
1530
345
345
2015
385
                push @{ $self->{corpus} }, {
1531                        input    => $self->_generate_random(),
1532                        coverage => {},
1533                };
1534        }
1535}
1536
1537# --------------------------------------------------
1538# _build_report
1539#
1540# Purpose:    Construct the summary report hashref
1541#             returned by run().
1542#
1543# Entry:      None beyond $self.
1544# Exit:       Returns a report hashref.
1545# Side effects: None.
1546# --------------------------------------------------
1547sub _build_report {
1548
69
74
        my $self = $_[0];
1549
1550        return {
1551                total_iterations   => $self->{stats}{total},
1552                interesting_inputs => $self->{stats}{interesting},
1553
69
252
                corpus_size        => scalar @{ $self->{corpus} },
1554                branches_covered   => $self->{stats}{coverage},
1555                bugs_found         => $self->{stats}{bugs},
1556                bugs               => $self->{bugs},
1557
69
95
        };
1558}
1559
1560 - 1649
=head1 COMMON PITFALLS

=over 4

=item Forgetting that C<load_corpus> does not restore bugs

The JSON written by C<save_corpus> contains a C<bugs> array, but C<load_corpus>
reads only the C<corpus> key.  A freshly loaded fuzzer always starts with an
empty bugs list.  If you need to carry bugs across sessions, persist them
separately.

=item Assuming coverage guidance is always active

If C<Devel::Cover> is not installed, the fuzzer falls back to random-only mode
and emits a warning.  Corpus entries in this mode have C<coverage =E<gt> {}>
and are kept using the C<RANDOM_KEEP_RATIO> (20%) heuristic rather than branch
novelty.  Install C<Devel::Cover> (or C<cpanm --with-recommends
App::Test::Generator>) for full coverage-guided behaviour.

=item Using C<refaddr> on C<corpus()> after C<minimize_corpus>

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

=item Setting C<timeout =E<gt> 0> on blocking targets

Setting C<timeout> to 0 disables the per-call C<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. C<Sys::AlarmCall>) if you need a safety net for
blocking code.

=back

=head1 LIMITATIONS

=over 4

=item 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.

=item 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.

=item No inter-run learning without save/load

The corpus is entirely in memory.  To carry learning across separate
invocations, call C<save_corpus> at the end and C<load_corpus> at the start of
each subsequent run.

=back

=head1 SEE ALSO

=over 4

=item L<Devel::Cover>

=item L<App::Test::Generator>

=item C<bin/extract-schemas> (the C<--minimize-corpus> flag)

=back

=head1 AUTHOR

Nigel Horne, C<< <njh at nigelhorne.com> >>

Portions of this module's initial design and documentation were created
with the assistance of AI.

=head1 LICENCE AND COPYRIGHT

Copyright 2026 Nigel Horne.

Usage is subject to GPL2 licence terms.
If you use it,
please let me know.

=cut
1650
16511;