| File: | blib/lib/App/Test/Generator/CoverageGuidedFuzzer.pm |
| Coverage: | 86.7% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package 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 | # -------------------------------------------------- | |||||
| 12 | Readonly my $DEFAULT_ITERATIONS => 100; | |||||
| 13 | Readonly my $DEFAULT_TIMEOUT_SECS => 5; # per-call alarm() timeout; 0 disables | |||||
| 14 | Readonly my $CORPUS_MUTATE_RATIO => 0.70; # 70% mutate, 30% explore | |||||
| 15 | Readonly my $RANDOM_KEEP_RATIO => 0.20; # keep 20% random when no coverage | |||||
| 16 | Readonly my $EDGE_CASE_RATIO => 0.40; # 40% chance to use declared edge case | |||||
| 17 | Readonly my $INT_BOUNDARY_RATIO => 0.30; # 30% chance to use boundary int | |||||
| 18 | Readonly my $STR_BOUNDARY_RATIO => 0.30; # 30% chance to use boundary length | |||||
| 19 | Readonly my $SEED_CORPUS_SIZE => 5; # initial random inputs to seed corpus | |||||
| 20 | Readonly my $DEFAULT_MAX_STR_LEN => 64; | |||||
| 21 | Readonly my $MATCHES_REGEX_TIMEOUT_SECS => 1; # ReDoS guard for schema 'matches' patterns | |||||
| 22 | Readonly my $DEFAULT_MAX_ARRAY => 4; # max elements in random array (0..N) | |||||
| 23 | Readonly my $INT32_MAX => 2**31 - 1; | |||||
| 24 | Readonly 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 | # -------------------------------------------------- | |||||
| 31 | Readonly my @RAND_CHARS => ('a'..'z', 'A'..'Z', '0'..'9', ' ', "\t", "\n", "\0"); | |||||
| 32 | Readonly 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 | # -------------------------------------------------- | |||||
| 43 | Readonly my $TYPE_INTEGER => 'integer'; | |||||
| 44 | Readonly my $TYPE_NUMBER => 'number'; | |||||
| 45 | Readonly my $TYPE_BOOLEAN => 'boolean'; | |||||
| 46 | Readonly my $TYPE_ARRAY => 'arrayref'; | |||||
| 47 | Readonly my $TYPE_HASH => 'hashref'; | |||||
| 48 | Readonly my $TYPE_STRING => 'string'; | |||||
| 49 | ||||||
| 50 | # -------------------------------------------------- | |||||
| 51 | # JSON module preference order | |||||
| 52 | # -------------------------------------------------- | |||||
| 53 | Readonly my @JSON_MODULES => qw(JSON::MaybeXS JSON); | |||||
| 54 | ||||||
| 55 | our $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 | ||||||
| 210 | sub 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 | ||||||
| 327 | sub 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 | ||||||
| 474 | sub 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 | ||||||
| 564 | sub 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 | ||||||
| 648 | sub 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 | # -------------------------------------------------- | |||||
| 728 | sub _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 | # -------------------------------------------------- | |||||
| 755 | sub _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 | # -------------------------------------------------- | |||||
| 790 | sub _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 | # -------------------------------------------------- | |||||
| 868 | sub _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 | # -------------------------------------------------- | |||||
| 921 | sub _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 | # -------------------------------------------------- | |||||
| 960 | sub _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 | # -------------------------------------------------- | |||||
| 984 | sub _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 | # -------------------------------------------------- | |||||
| 999 | sub _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 | # -------------------------------------------------- | |||||
| 1023 | sub _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 | # -------------------------------------------------- | |||||
| 1057 | sub _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 | # -------------------------------------------------- | |||||
| 1082 | sub _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 | # -------------------------------------------------- | |||||
| 1105 | sub _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 | # -------------------------------------------------- | |||||
| 1137 | sub _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 | # -------------------------------------------------- | |||||
| 1156 | sub _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 | # -------------------------------------------------- | |||||
| 1183 | sub _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 | # -------------------------------------------------- | |||||
| 1211 | sub _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 | # -------------------------------------------------- | |||||
| 1254 | sub _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 | # -------------------------------------------------- | |||||
| 1323 | sub _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 | # -------------------------------------------------- | |||||
| 1363 | sub _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 | # -------------------------------------------------- | |||||
| 1386 | sub _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 | # -------------------------------------------------- | |||||
| 1408 | sub _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 | # -------------------------------------------------- | |||||
| 1457 | sub _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 | # -------------------------------------------------- | |||||
| 1500 | sub _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 | # -------------------------------------------------- | |||||
| 1526 | sub _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 | # -------------------------------------------------- | |||||
| 1547 | sub _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 | ||||||
| 1651 | 1; | |||||