| File: | bin/fuzz-harness-generator |
| Coverage: | 39.0% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | #!/usr/bin/env perl | |||||
| 2 | ||||||
| 3 | 7 7 7 | 15516 5 116 | use strict; | |||
| 4 | 7 7 7 | 8 7 140 | use warnings; | |||
| 5 | 7 7 7 | 1243 56418 11 | use autodie qw(:all); | |||
| 6 | ||||||
| 7 | 7 7 7 | 70180 18 205 | use App::Test::Generator; | |||
| 8 | 7 7 7 | 16 5 80 | use File::Spec; | |||
| 9 | 7 7 7 | 12 6 228 | use File::Temp; | |||
| 10 | 7 7 7 | 2598 31303 15 | use Getopt::Long qw(GetOptions); | |||
| 11 | 7 7 7 | 2463 124739 522 | use Pod::Usage; | |||
| 12 | 7 7 7 | 24 5 5794 | use YAML::XS qw(LoadFile); | |||
| 13 | ||||||
| 14 | # Derive prove from $^X so we always use the same perl installation that | |||||
| 15 | # invoked this script, even when $PATH resolves to a different system perl. | |||||
| 16 | 7 | 526880 | my ($prove_vol, $prove_dir) = (File::Spec->splitpath($^X))[0, 1]; | |||
| 17 | 7 | 67 | my $prove = File::Spec->catpath($prove_vol, $prove_dir, 'prove'); | |||
| 18 | ||||||
| 19 - 97 | =head1 NAME fuzz-harness-generator - Generate fuzzing + corpus-based test harnesses from test schemas =head1 SYNOPSIS fuzz-harness-generator [-r] [-o output_file] input.yaml fuzz-harness-generator --dry-run input.yaml fuzz-harness-generator --replay-corpus schemas/corpus/ -o t/fuzz_replay.t fuzz-harness-generator --replay-corpus schemas/corpus/translate.json -o t/fuzz_replay.t =head1 DESCRIPTION This tool generates a test file that fuzzes and validates a target module's function or method, using both randomized fuzz cases and a static corpus cases (Perl or YAML). It can also generate regression test files from corpus JSON files previously written by C<extract-schemas --fuzz>, using C<--replay-corpus>. A starter C<input.yaml> can be created using C<extract-schemas> which is also in this package. =head1 OPTIONS =over 4 =item B<--help> Show this help. =item B<--input> The input configuration file =item B<--output> The (optional) output file. =item B<--dry-run> Validate the input configuration and schema extraction without writing any output files or running tests. =item B<--run> Call C<prove> on the output file. C<fuzz-harness-generator -r t/conf/data_text_append.conf> will, therefore, dynamically create and run tests on the C<append> method of L<Data::Text> =item B<--replay-corpus> PATH Instead of generating a fuzz harness, generate a regression test file from one or more corpus JSON files previously written by C<extract-schemas --fuzz>. PATH may be either: =over 4 =item * A single corpus file, e.g. C<schemas/corpus/translate.json> =item * A directory, e.g. C<schemas/corpus/> â all C<*.json> files in that directory will be included =back The generated test file contains one failing test per bug recorded in the corpus. Each test calls the target method with the exact input that previously caused a crash and expects it B<not> to die. Tests will be red until the underlying bug is fixed, at which point they go green and stay green â acting as permanent regression tests. Only corpus entries with recorded bugs are included. Clean corpus entries (inputs that did not cause a bug) are ignored. =item B<--version> Prints the version of L<App::Test::Generator> =back =cut | |||||
| 98 | ||||||
| 99 | 7 | 42 | my $infile; | |||
| 100 | my $outfile; | |||||
| 101 | 7 | 0 | my $help; | |||
| 102 | 7 | 0 | my $run; | |||
| 103 | 7 | 0 | my $verbose; | |||
| 104 | 7 | 0 | my $version; | |||
| 105 | 7 | 0 | my $dry_run; | |||
| 106 | 7 | 0 | my $replay_corpus; | |||
| 107 | ||||||
| 108 | 7 | 30 | Getopt::Long::Configure('bundling'); | |||
| 109 | ||||||
| 110 | 7 | 168 | GetOptions( | |||
| 111 | 'help|h' => \$help, | |||||
| 112 | 'input|i=s' => \$infile, | |||||
| 113 | 'dry-run|n' => \$dry_run, | |||||
| 114 | 'output|o=s' => \$outfile, | |||||
| 115 | 'run|r' => \$run, | |||||
| 116 | 'verbose|v' => \$verbose, | |||||
| 117 | 'version|V' => \$version, | |||||
| 118 | 'replay-corpus|R=s' => \$replay_corpus, | |||||
| 119 | ) or pod2usage(2); | |||||
| 120 | ||||||
| 121 | 7 | 3906 | pod2usage(-exitval => 0, -verbose => 1) if($help); | |||
| 122 | ||||||
| 123 | 5 | 14 | if($version) { | |||
| 124 | 1 | 6 | print $App::Test::Generator::VERSION, "\n"; | |||
| 125 | 1 | 48 | exit 0; | |||
| 126 | } | |||||
| 127 | ||||||
| 128 | # --------------------------------------------------------------------------- | |||||
| 129 | # --replay-corpus mode: generate a regression .t from corpus bug entries | |||||
| 130 | # --------------------------------------------------------------------------- | |||||
| 131 | ||||||
| 132 | 4 | 6 | if($replay_corpus) { | |||
| 133 | 0 | 0 | pod2usage('--replay-corpus cannot be combined with --dry-run') if $dry_run; | |||
| 134 | 0 | 0 | pod2usage('--replay-corpus cannot be combined with --input') if $infile; | |||
| 135 | ||||||
| 136 | 0 | 0 | my @corpus_files = _collect_corpus_files($replay_corpus); | |||
| 137 | 0 | 0 | die "No corpus JSON files found at: $replay_corpus\n" unless @corpus_files; | |||
| 138 | ||||||
| 139 | 0 | 0 | my $tap = _generate_replay_tap(@corpus_files); | |||
| 140 | ||||||
| 141 | 0 | 0 | if($outfile) { | |||
| 142 | 0 | 0 | open(my $fh, '>', $outfile) | |||
| 143 | or die "Cannot write to $outfile: $!"; | |||||
| 144 | 0 | 0 | print $fh $tap; | |||
| 145 | 0 | 0 | close $fh; | |||
| 146 | 0 | 0 | chmod 0755, $outfile; | |||
| 147 | 0 | 0 | print "Replay test written to: $outfile\n"; | |||
| 148 | 0 | 0 | if($run) { | |||
| 149 | 0 | 0 | exit system($prove, '-l', $outfile) >> 8; | |||
| 150 | } | |||||
| 151 | } else { | |||||
| 152 | 0 | 0 | print $tap; | |||
| 153 | } | |||||
| 154 | 0 | 0 | exit 0; | |||
| 155 | } | |||||
| 156 | ||||||
| 157 | 4 | 18 | if($infile && @ARGV) { | |||
| 158 | 0 | 0 | pod2usage('Specify input file either as argument or via --input, not both'); | |||
| 159 | } | |||||
| 160 | ||||||
| 161 | 4 | 8 | if($infile) { | |||
| 162 | 4 4 | 6 15 | my $schema = eval { LoadFile($infile) }; | |||
| 163 | 4 | 445 | if($@) { | |||
| 164 | 1 | 94 | die "Cannot parse '$infile' as YAML: $@"; | |||
| 165 | } | |||||
| 166 | 3 | 10 | unless(ref($schema) eq 'HASH') { | |||
| 167 | 0 | 0 | die "Input file '$infile' does not contain a YAML hash"; | |||
| 168 | } | |||||
| 169 | 3 | 15 | unless($schema->{function}) { | |||
| 170 | 0 | 0 | die "Input file '$infile' is missing required 'function' key"; | |||
| 171 | } | |||||
| 172 | } | |||||
| 173 | ||||||
| 174 | 3 | 9 | $infile ||= shift @ARGV or pod2usage('No config file given'); | |||
| 175 | ||||||
| 176 | 3 | 9 | if($dry_run && $run) { | |||
| 177 | 0 | 0 | pod2usage('--dry-run cannot be used with --run'); | |||
| 178 | } | |||||
| 179 | ||||||
| 180 | 3 | 8 | if($dry_run && $outfile) { | |||
| 181 | 1 | 50 | warn '--dry-run specified; --output will be ignored'; | |||
| 182 | } | |||||
| 183 | ||||||
| 184 | 3 | 6 | if($verbose) { | |||
| 185 | 0 | 0 | $ENV{'TEST_VERBOSE'} = 1; | |||
| 186 | } | |||||
| 187 | ||||||
| 188 | 3 | 7 | if($run && !$outfile) { | |||
| 189 | 0 | 0 | my ($fh, $tmp) = File::Temp::tempfile(); | |||
| 190 | 0 | 0 | close $fh; | |||
| 191 | ||||||
| 192 | 0 | 0 | App::Test::Generator->generate($infile, $tmp); | |||
| 193 | ||||||
| 194 | 0 | 0 | exit system($prove, '-l', $tmp) >> 8; | |||
| 195 | } | |||||
| 196 | ||||||
| 197 | 3 | 13 | if($dry_run) { | |||
| 198 | 2 | 8 | my ($fh, $tmp) = File::Temp::tempfile(); | |||
| 199 | 2 | 568 | close $fh; | |||
| 200 | ||||||
| 201 | eval { | |||||
| 202 | 2 | 24 | App::Test::Generator->generate($infile, $tmp); | |||
| 203 | 2 | 193 | 1; | |||
| 204 | 2 | 951 | } or do { | |||
| 205 | 0 | 0 | die "Dry-run failed for $infile: $@"; | |||
| 206 | }; | |||||
| 207 | ||||||
| 208 | 2 | 7 | unlink $tmp; | |||
| 209 | 2 | 538 | print "Dry-run OK: $infile parsed and validated successfully\n"; | |||
| 210 | 2 | 109 | exit 0; | |||
| 211 | } elsif($outfile && -e $outfile && !$run) { | |||||
| 212 | 0 | 0 | warn "Overwriting existing file: $outfile"; | |||
| 213 | } | |||||
| 214 | ||||||
| 215 | 1 | 9 | App::Test::Generator->generate($infile, $outfile); | |||
| 216 | ||||||
| 217 | 1 | 82 | if($outfile) { | |||
| 218 | 1 | 3 | chmod 0755, $outfile if($outfile =~ /\.(pl|cgi)$/); | |||
| 219 | 1 | 2 | if($run) { | |||
| 220 | # Use list form to avoid shell interpolation of $outfile | |||||
| 221 | 0 | 0 | system($prove, '-l', $outfile); | |||
| 222 | } | |||||
| 223 | } | |||||
| 224 | ||||||
| 225 | 1 | 51 | exit 0; | |||
| 226 | ||||||
| 227 | # --------------------------------------------------------------------------- | |||||
| 228 | # Helpers for --replay-corpus | |||||
| 229 | # --------------------------------------------------------------------------- | |||||
| 230 | ||||||
| 231 | # -------------------------------------------------- | |||||
| 232 | # _collect_corpus_files | |||||
| 233 | # | |||||
| 234 | # Collect the list of corpus JSON files | |||||
| 235 | # to process for --replay-corpus mode. | |||||
| 236 | # Accepts either a single file path or | |||||
| 237 | # a directory, returning all *.json files | |||||
| 238 | # found in the directory case. | |||||
| 239 | # | |||||
| 240 | # Entry: $path - filesystem path to either a | |||||
| 241 | # single .json file or a directory | |||||
| 242 | # containing .json files. | |||||
| 243 | # | |||||
| 244 | # Exit: Returns a sorted list of file paths. | |||||
| 245 | # Returns an empty list if the path does | |||||
| 246 | # not exist or contains no .json files. | |||||
| 247 | # | |||||
| 248 | # Notes: Directory globbing matches only *.json | |||||
| 249 | # files at the top level of the directory; | |||||
| 250 | # subdirectories are not recursed into. | |||||
| 251 | # -------------------------------------------------- | |||||
| 252 | sub _collect_corpus_files { | |||||
| 253 | 0 | my ($path) = @_; | ||||
| 254 | ||||||
| 255 | 0 | if(-f $path) { | ||||
| 256 | 0 | return ($path); | ||||
| 257 | } elsif(-d $path) { | |||||
| 258 | 0 | my @files = glob(File::Spec->catfile($path, '*.json')); | ||||
| 259 | 0 | return sort @files; | ||||
| 260 | } | |||||
| 261 | ||||||
| 262 | 0 | return (); | ||||
| 263 | } | |||||
| 264 | ||||||
| 265 | # -------------------------------------------------- | |||||
| 266 | # _generate_replay_tap | |||||
| 267 | # | |||||
| 268 | # Purpose: Read one or more corpus JSON files and | |||||
| 269 | # produce a complete .t file as a string. | |||||
| 270 | # Each bug entry in the corpus becomes | |||||
| 271 | # one lives_ok test that calls the target | |||||
| 272 | # method with the exact input that | |||||
| 273 | # previously caused a crash, asserting | |||||
| 274 | # that it no longer dies. | |||||
| 275 | # | |||||
| 276 | # Entry: @corpus_files - list of paths to corpus | |||||
| 277 | # JSON files as returned | |||||
| 278 | # by _collect_corpus_files. | |||||
| 279 | # | |||||
| 280 | # Exit: Returns the complete .t file content as | |||||
| 281 | # a string. Never returns undef. | |||||
| 282 | # Returns a skip_all plan if no bugs are | |||||
| 283 | # found across all corpus files. | |||||
| 284 | # | |||||
| 285 | # Side effects: Reads corpus JSON files from disk. | |||||
| 286 | # Attempts to load JSON::MaybeXS or | |||||
| 287 | # JSON via block eval. | |||||
| 288 | # | |||||
| 289 | # Notes: Corpus files that cannot be parsed are | |||||
| 290 | # skipped with a warning rather than | |||||
| 291 | # aborting the entire run. | |||||
| 292 | # Clean corpus entries (those without | |||||
| 293 | # recorded bugs) are silently ignored â | |||||
| 294 | # only entries with a 'bugs' array are | |||||
| 295 | # processed. | |||||
| 296 | # The module name for each test is | |||||
| 297 | # inferred from the YAML schema file | |||||
| 298 | # alongside the corpus file via | |||||
| 299 | # _infer_module_from_schema. Falls back | |||||
| 300 | # to 'UNKNOWN::Module' if not found. | |||||
| 301 | # -------------------------------------------------- | |||||
| 302 | sub _generate_replay_tap { | |||||
| 303 | 0 | my (@corpus_files) = @_; | ||||
| 304 | ||||||
| 305 | # Prefer JSON::MaybeXS for correctness; fall back to JSON | |||||
| 306 | 0 | my $json_module; | ||||
| 307 | 0 | for my $mod (qw(JSON::MaybeXS JSON)) { | ||||
| 308 | 0 0 0 | eval { require $mod; 1 } and $json_module = $mod and last; | ||||
| 309 | } | |||||
| 310 | 0 | die "No JSON module available; install JSON or JSON::MaybeXS\n" | ||||
| 311 | unless $json_module; | |||||
| 312 | ||||||
| 313 | # Collect all bugs across all corpus files into a flat list | |||||
| 314 | 0 | my @tests; | ||||
| 315 | ||||||
| 316 | 0 | for my $file (@corpus_files) { | ||||
| 317 | 0 | open(my $fh, '<', $file) | ||||
| 318 | or die "Cannot read $file: $!"; | |||||
| 319 | 0 | my $data = eval { | ||||
| 320 | 0 0 0 | $json_module->new->decode(do { local $/; <$fh> }) | ||||
| 321 | }; | |||||
| 322 | 0 | close $fh; | ||||
| 323 | ||||||
| 324 | 0 | if($@) { | ||||
| 325 | 0 | warn "Skipping $file: could not parse JSON: $@\n"; | ||||
| 326 | 0 | next; | ||||
| 327 | } | |||||
| 328 | ||||||
| 329 | 0 | my $bugs = $data->{'bugs'} // []; | ||||
| 330 | 0 0 | next unless @{$bugs}; | ||||
| 331 | ||||||
| 332 | # Derive method name from filename: translate.json -> translate | |||||
| 333 | 0 | my (undef, undef, $fname) = File::Spec->splitpath($file); | ||||
| 334 | 0 | (my $method = $fname) =~ s/\.json$//; | ||||
| 335 | ||||||
| 336 | # Look up the module name from the companion schema file; | |||||
| 337 | # fall back to a placeholder if the schema cannot be found | |||||
| 338 | 0 | my $module = _infer_module_from_schema($file, $method) | ||||
| 339 | // 'UNKNOWN::Module'; | |||||
| 340 | ||||||
| 341 | 0 0 | for my $bug (@{$bugs}) { | ||||
| 342 | push @tests, { | |||||
| 343 | module => $module, | |||||
| 344 | method => $method, | |||||
| 345 | input => $bug->{'input'}, | |||||
| 346 | 0 | error => $bug->{'error'}, | ||||
| 347 | file => $file, | |||||
| 348 | }; | |||||
| 349 | } | |||||
| 350 | } | |||||
| 351 | ||||||
| 352 | # Build the .t header â include Test::Exception up front since | |||||
| 353 | # lives_ok is always needed when there are tests to emit | |||||
| 354 | 0 | my $t = <<'HEADER'; | ||||
| 355 | #!/usr/bin/env perl | |||||
| 356 | # Auto-generated by fuzz-harness-generator --replay-corpus | |||||
| 357 | # DO NOT EDIT - regenerate from corpus files instead | |||||
| 358 | use strict; | |||||
| 359 | use warnings; | |||||
| 360 | use Test::More; | |||||
| 361 | use Test::Exception; | |||||
| 362 | HEADER | |||||
| 363 | ||||||
| 364 | 0 | my $test_count = scalar @tests; | ||||
| 365 | ||||||
| 366 | 0 | if($test_count == 0) { | ||||
| 367 | 0 | $t .= "\nplan skip_all => 'No bugs recorded in corpus files';\n"; | ||||
| 368 | 0 | return $t; | ||||
| 369 | } | |||||
| 370 | ||||||
| 371 | # Emit one use statement per unique module (excluding the placeholder) | |||||
| 372 | 0 0 | my %modules = map { $_->{'module'} => 1 } @tests; | ||||
| 373 | 0 | for my $mod (sort keys %modules) { | ||||
| 374 | 0 | next if $mod eq 'UNKNOWN::Module'; | ||||
| 375 | 0 | $t .= "use $mod;\n"; | ||||
| 376 | } | |||||
| 377 | ||||||
| 378 | 0 | $t .= "\nplan tests => $test_count;\n\n"; | ||||
| 379 | ||||||
| 380 | 0 | for my $i (0 .. $#tests) { | ||||
| 381 | 0 | my $test = $tests[$i]; | ||||
| 382 | 0 | my $n = $i + 1; | ||||
| 383 | 0 | my $input = _format_input($test->{'input'}); | ||||
| 384 | 0 | my $label = "$test->{'method'} does not die on input from $test->{'file'}"; | ||||
| 385 | ||||||
| 386 | # Flatten and escape the original error for use as a comment | |||||
| 387 | 0 | (my $orig_error = $test->{'error'} // '') =~ s/\n/ /g; | ||||
| 388 | 0 | $orig_error =~ s/'/\\'/g; | ||||
| 389 | ||||||
| 390 | 0 | $t .= "# Corpus bug: $orig_error\n"; | ||||
| 391 | 0 | $t .= "lives_ok { $test->{'module'}\->$test->{'method'}($input) }\n"; | ||||
| 392 | 0 | $t .= " '$label';\n\n"; | ||||
| 393 | } | |||||
| 394 | ||||||
| 395 | 0 | return $t; | ||||
| 396 | } | |||||
| 397 | ||||||
| 398 | # -------------------------------------------------- | |||||
| 399 | # _format_input | |||||
| 400 | # | |||||
| 401 | # Purpose: Format a scalar input value as a Perl | |||||
| 402 | # literal string suitable for embedding | |||||
| 403 | # directly in generated test source code. | |||||
| 404 | # | |||||
| 405 | # Entry: $input - the input value to format. | |||||
| 406 | # May be undef, a numeric string, | |||||
| 407 | # or an arbitrary string. | |||||
| 408 | # | |||||
| 409 | # Exit: Returns a Perl literal string: | |||||
| 410 | # 'undef' if $input is undef | |||||
| 411 | # bare number if $input looks numeric | |||||
| 412 | # single-quoted string otherwise, with | |||||
| 413 | # backslashes and single quotes escaped. | |||||
| 414 | # | |||||
| 415 | # Side effects: None. | |||||
| 416 | # | |||||
| 417 | # Notes: Only scalar inputs are handled â corpus | |||||
| 418 | # entries with arrayref or hashref inputs | |||||
| 419 | # are not currently supported and will be | |||||
| 420 | # formatted as a single-quoted string of | |||||
| 421 | # the stringified reference, which will | |||||
| 422 | # not reproduce the original input. | |||||
| 423 | # -------------------------------------------------- | |||||
| 424 | sub _format_input { | |||||
| 425 | 0 | my ($input) = @_; | ||||
| 426 | ||||||
| 427 | 0 | return 'undef' unless defined $input; | ||||
| 428 | ||||||
| 429 | # Emit bare numeric literals without quoting | |||||
| 430 | 0 | return $input if $input =~ /^-?(?:\d+\.?\d*|\.\d+)$/; | ||||
| 431 | ||||||
| 432 | # Escape backslashes first, then single quotes, to avoid | |||||
| 433 | # double-escaping when both appear in the same string | |||||
| 434 | 0 | (my $escaped = $input) =~ s/\\/\\\\/g; | ||||
| 435 | 0 | $escaped =~ s/'/\\'/g; | ||||
| 436 | ||||||
| 437 | 0 | return "'$escaped'"; | ||||
| 438 | } | |||||
| 439 | ||||||
| 440 | # -------------------------------------------------- | |||||
| 441 | # _infer_module_from_schema | |||||
| 442 | # | |||||
| 443 | # Purpose: Attempt to determine the Perl module | |||||
| 444 | # name for a given corpus method by | |||||
| 445 | # locating and reading the companion YAML | |||||
| 446 | # schema file that sits alongside the | |||||
| 447 | # corpus directory. | |||||
| 448 | # | |||||
| 449 | # Entry: $corpus_file - path to the corpus JSON | |||||
| 450 | # file, e.g. | |||||
| 451 | # schemas/corpus/translate.json | |||||
| 452 | # $method - the method name derived | |||||
| 453 | # from the corpus filename, | |||||
| 454 | # e.g. 'translate' | |||||
| 455 | # | |||||
| 456 | # Exit: Returns the module name string if found, | |||||
| 457 | # or undef if no companion schema file | |||||
| 458 | # exists or the schema contains no | |||||
| 459 | # 'module:' line. | |||||
| 460 | # | |||||
| 461 | # Side effects: Reads schema files from disk. | |||||
| 462 | # | |||||
| 463 | # Notes: The corpus is expected to live one | |||||
| 464 | # directory below the schemas directory, | |||||
| 465 | # e.g. schemas/corpus/ alongside | |||||
| 466 | # schemas/translate.yaml. This function | |||||
| 467 | # walks up one level from the corpus | |||||
| 468 | # directory to find the schema. | |||||
| 469 | # Both .yaml and .yml extensions are | |||||
| 470 | # tried, in that order. | |||||
| 471 | # -------------------------------------------------- | |||||
| 472 | sub _infer_module_from_schema { | |||||
| 473 | 0 | my ($corpus_file, $method) = @_; | ||||
| 474 | ||||||
| 475 | 0 | my (undef, $corpus_dir) = File::Spec->splitpath($corpus_file); | ||||
| 476 | ||||||
| 477 | # Walk up one directory from corpus/ to reach the schemas/ dir | |||||
| 478 | 0 | my $schema_dir = File::Spec->catdir($corpus_dir, File::Spec->updir()); | ||||
| 479 | ||||||
| 480 | 0 | for my $ext (qw(yaml yml)) { | ||||
| 481 | 0 | my $schema_file = File::Spec->catfile($schema_dir, "$method.$ext"); | ||||
| 482 | 0 | next unless -f $schema_file; | ||||
| 483 | ||||||
| 484 | 0 | open(my $fh, '<', $schema_file) or next; | ||||
| 485 | 0 | while(<$fh>) { | ||||
| 486 | 0 | if(/^module:\s*(\S+)/) { | ||||
| 487 | 0 | close $fh; | ||||
| 488 | 0 | return $1; | ||||
| 489 | } | |||||
| 490 | } | |||||
| 491 | 0 | close $fh; | ||||
| 492 | } | |||||
| 493 | ||||||
| 494 | 0 | return undef; | ||||
| 495 | } | |||||
| 496 | ||||||