| File: | bin/extract-schemas |
| Coverage: | 77.1% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | #!/usr/bin/env perl | |||||
| 2 | ||||||
| 3 | 9 9 9 | 20051 8 151 | use strict; | |||
| 4 | 9 9 9 | 11 7 166 | use warnings; | |||
| 5 | ||||||
| 6 | 9 9 9 | 2285 29398 312 | use Data::Dumper; | |||
| 7 | 9 9 9 | 24 8 257 | use File::Path qw(make_path); | |||
| 8 | 9 9 9 | 14 6 61 | use File::Spec; | |||
| 9 | 9 9 9 | 3107 39799 24 | use Getopt::Long; | |||
| 10 | 9 9 9 | 2238 4469 242 | use FindBin; | |||
| 11 | 9 9 9 | 1694 2567 25 | use lib "$FindBin::Bin/../lib"; | |||
| 12 | 9 9 9 | 2534 228011 634 | use Pod::Usage; | |||
| 13 | ||||||
| 14 | 9 9 9 | 6959 22 17920 | use App::Test::Generator::SchemaExtractor; | |||
| 15 | ||||||
| 16 - 125 | =head1 NAME extract-schemas - Extract test schemas from Perl modules =head1 SYNOPSIS extract-schemas [options] <module.pm> Options: --output-dir DIR Output directory for schema files (default: schemas/) --strict-pod=off|warn|fatal --verbose Show detailed analysis --version Show the version of App::Test::Generator::SchemaExtractor --fuzz Run coverage-guided fuzzing on extracted schemas --fuzz-iters N Iterations per method when fuzzing (default: 100) (no short form, to avoid conflict with --fuzz/-f) --fuzz-all Fuzz all methods, including those with no input schema --corpus-dir DIR Directory to persist fuzz corpora (default: schemas/corpus/) --minimize-corpus After fuzzing, trim each corpus to the minimum subset that still covers all discovered branches (greedy set-cover). Keeps the corpus file small across many CI runs. --help Show this help message --man Show full documentation Examples: extract-schemas lib/MyModule.pm extract-schemas --output-dir my_schemas --verbose lib/MyModule.pm extract-schemas --fuzz lib/MyModule.pm extract-schemas --fuzz --fuzz-iters 300 --corpus-dir t/corpus lib/MyModule.pm extract-schemas --fuzz --fuzz-all lib/MyModule.pm extract-schemas --fuzz --minimize-corpus lib/MyModule.pm =head1 QUICK START Run C<extract-schemas --strict-pod=warn -v --fuzz lib/MyModule.pm> to analyse your module and automatically probe each method with hundreds of fuzzed inputs, looking for crashes caused by inputs that should be valid. Anything suspicious is saved to C<schemas/corpus/>. If genuine bugs are found, run C<fuzz-harness-generator --replay-corpus schemas/corpus/ -o t/fuzz_replay.t> to turn them into regression tests that will fail until you fix the underlying code and pass forever after. Run C<extract-schemas --fuzz> regularly - each run builds on the last, probing deeper into your code each time. Otherwise, for each of the functions in MyModule.pm, C<fuzz-harness-generator -r schemas/function.yml> =head1 DESCRIPTION This tool analyzes a Perl module and generates YAML schema files for each method, suitable for use with L<App::Test::Generator> using the C<fuzz-harness-generator> program which will create the C<.t> file to run through C<prove>. The extractor uses three sources of information: =over 4 =item 1. POD Documentation Parses parameter descriptions from POD to extract types and constraints. =item 2. Code Analysis Analyzes validation patterns in the code (ref checks, length checks, etc.) =item 3. Method Signatures Extracts parameter names from method signatures. =back The tool assigns a confidence level (high/medium/low) to each schema based on how much information it could infer. =head1 FUZZING When C<--fuzz> is specified, the tool will additionally run C<App::Test::Generator::CoverageGuidedFuzzer> against each method after schema extraction. By default all methods with at least one known input parameter are fuzzed, regardless of confidence level. Use C<--fuzz-all> to also attempt fuzzing methods with no input schema (these will use purely random generation). The fuzzer will: =over 4 =item * Load and C<require> the target module at runtime =item * Run coverage-guided fuzzing using the extracted schema as input spec =item * Report any crashes or unexpected errors found =item * Persist a corpus to C<--corpus-dir> for incremental improvement across runs =item * Optionally minimise the corpus with C<--minimize-corpus> to keep file sizes bounded =back Corpus files are named C<< <corpus-dir>/<method>.json >> and are automatically loaded on subsequent runs, so each run builds on the last. Without C<--minimize-corpus> the corpus grows by several entries per run; with it, only the entries that provide unique branch coverage are retained (greedy set-cover), plus a deduplicated set of entries from prior runs that cannot be re-evaluated. Bug-triggering inputs are always kept regardless of coverage contribution. =cut | |||||
| 126 | ||||||
| 127 | # --------------------------------------------------------------------------- | |||||
| 128 | # Option parsing | |||||
| 129 | # --------------------------------------------------------------------------- | |||||
| 130 | ||||||
| 131 | 9 | 726285 | my %cli_opts = ( | |||
| 132 | help => 0, | |||||
| 133 | man => 0, | |||||
| 134 | ); | |||||
| 135 | ||||||
| 136 | 9 | 35 | my %extractor_opts = ( | |||
| 137 | output_dir => 'schemas', | |||||
| 138 | strict_pod => 'warn', | |||||
| 139 | verbose => 0, | |||||
| 140 | version => 0, | |||||
| 141 | ); | |||||
| 142 | ||||||
| 143 | 9 | 12 | my $fuzz = 0; | |||
| 144 | 9 | 11 | my $fuzz_all = 0; | |||
| 145 | 9 | 13 | my $fuzz_iters = 100; | |||
| 146 | 9 | 9 | my $minimize_corpus = 0; | |||
| 147 | 9 | 11 | my $corpus_dir; # default set after output_dir is known | |||
| 148 | ||||||
| 149 | 9 | 40 | Getopt::Long::Configure('bundling'); | |||
| 150 | ||||||
| 151 | GetOptions( | |||||
| 152 | 'output-dir|o=s' => \$extractor_opts{output_dir}, | |||||
| 153 | 'strict-pod|s=s' => \$extractor_opts{strict_pod}, | |||||
| 154 | 'verbose|v' => \$extractor_opts{verbose}, | |||||
| 155 | 'version|V' => \$extractor_opts{version}, | |||||
| 156 | 'fuzz|f' => \$fuzz, | |||||
| 157 | 'fuzz-all' => \$fuzz_all, | |||||
| 158 | 'fuzz-iters=i' => \$fuzz_iters, | |||||
| 159 | 'corpus-dir|c=s' => \$corpus_dir, | |||||
| 160 | 'minimize-corpus' => \$minimize_corpus, | |||||
| 161 | 'help|h' => \$cli_opts{help}, | |||||
| 162 | 'man|m' => \$cli_opts{man}, | |||||
| 163 | 9 | 228 | ) or pod2usage(2); | |||
| 164 | ||||||
| 165 | 9 | 5888 | pod2usage(-exitval => 0, -verbose => 1) if $cli_opts{help}; | |||
| 166 | 8 | 16 | pod2usage(-exitval => 0, -verbose => 2) if $cli_opts{man}; | |||
| 167 | ||||||
| 168 | 8 | 18 | if($extractor_opts{version}) { | |||
| 169 | 0 | 0 | print $App::Test::Generator::SchemaExtractor::VERSION, "\n"; | |||
| 170 | 0 | 0 | exit 0; | |||
| 171 | } | |||||
| 172 | ||||||
| 173 | 8 | 33 | if ($extractor_opts{strict_pod} !~ /^(off|warn|fatal)$/) { | |||
| 174 | 1 | 0 | die "Invalid --strict-pod value '$extractor_opts{strict_pod}'. Expected off, warn, or fatal"; | |||
| 175 | } | |||||
| 176 | ||||||
| 177 | 7 | 21 | my $input_file = shift @ARGV or pod2usage('Error: No input file specified'); | |||
| 178 | 6 | 85 | die "Error: File not found: $input_file" unless -f $input_file; | |||
| 179 | ||||||
| 180 | # Default corpus dir sits under the output dir | |||||
| 181 | 6 | 58 | $corpus_dir //= File::Spec->catdir($extractor_opts{output_dir}, 'corpus'); | |||
| 182 | ||||||
| 183 | # --------------------------------------------------------------------------- | |||||
| 184 | # Schema extraction | |||||
| 185 | # --------------------------------------------------------------------------- | |||||
| 186 | ||||||
| 187 | 6 | 35 | print "Extracting schemas from: $input_file\n"; | |||
| 188 | 6 | 12 | print "Output directory: $extractor_opts{output_dir}\n\n"; | |||
| 189 | ||||||
| 190 | 6 | 698 | make_path($extractor_opts{output_dir}) unless -d $extractor_opts{output_dir}; | |||
| 191 | ||||||
| 192 | 6 | 46 | my $extractor = App::Test::Generator::SchemaExtractor->new( | |||
| 193 | input_file => $input_file, | |||||
| 194 | %extractor_opts, | |||||
| 195 | ); | |||||
| 196 | ||||||
| 197 | 6 | 17 | my $schemas = $extractor->extract_all(); | |||
| 198 | ||||||
| 199 | # --------------------------------------------------------------------------- | |||||
| 200 | # Optional: coverage-guided fuzzing | |||||
| 201 | # --------------------------------------------------------------------------- | |||||
| 202 | ||||||
| 203 | 6 | 8 | my %fuzz_results; # method_name => report hashref | |||
| 204 | ||||||
| 205 | 6 | 8 | if ($fuzz) { | |||
| 206 | 3 | 858 | require App::Test::Generator::CoverageGuidedFuzzer; | |||
| 207 | 3 | 270 | make_path($corpus_dir) unless -d $corpus_dir; | |||
| 208 | ||||||
| 209 | # Load the target module once so all methods are callable | |||||
| 210 | 3 | 10 | my $package = _load_target_module($input_file, $schemas); | |||
| 211 | ||||||
| 212 | # Try to build a default instance for object method calls. | |||||
| 213 | # Most OO modules need a $self as the first argument. | |||||
| 214 | # We try new() with no args, then new({}), then give up and fuzz as functions. | |||||
| 215 | 3 | 9 | my $instance = _try_construct($package); | |||
| 216 | 3 | 6 | if ($instance) { | |||
| 217 | 0 | 0 | print "Constructed $package instance for method calls.\n"; | |||
| 218 | } else { | |||||
| 219 | 3 | 150 | print "Could not construct $package instance; fuzzing as functions.\n"; | |||
| 220 | } | |||||
| 221 | ||||||
| 222 | 3 | 10 | print "Fuzzing with $fuzz_iters iterations per method", | |||
| 223 | ($fuzz_all ? ' (all methods)' : ' (methods with known inputs)'), | |||||
| 224 | "...\n\n"; | |||||
| 225 | ||||||
| 226 | 3 | 71 | foreach my $method (sort keys %$schemas) { | |||
| 227 | 3 | 6 | my $schema = $schemas->{$method}; | |||
| 228 | 3 | 6 | my $iconf = $schema->{_confidence}{input}{level} // 'low'; | |||
| 229 | ||||||
| 230 | 3 | 5 | unless ($fuzz_all) { | |||
| 231 | # Skip methods with no input schema at all â there is nothing to fuzz | |||||
| 232 | 3 0 | 6 0 | next if $iconf eq 'none' && !%{ $schema->{input} // {} }; | |||
| 233 | } | |||||
| 234 | ||||||
| 235 | 3 | 11 | my $sub_ref = $package->can($method); | |||
| 236 | 3 | 7 | unless ($sub_ref) { | |||
| 237 | 0 | 0 | warn " Skipping $method: not callable in $package\n"; | |||
| 238 | 0 | 0 | next; | |||
| 239 | } | |||||
| 240 | ||||||
| 241 | # Skip constructors and AUTOLOAD â not suitable for direct fuzzing | |||||
| 242 | 3 | 7 | if ($method =~ /^(new|AUTOLOAD|DESTROY|import)$/) { | |||
| 243 | print " Skipping $method (constructor/special method)\n" | |||||
| 244 | 0 | 0 | if $extractor_opts{verbose}; | |||
| 245 | 0 | 0 | next; | |||
| 246 | } | |||||
| 247 | ||||||
| 248 | 3 | 24 | my $corpus_file = File::Spec->catfile($corpus_dir, "$method.json"); | |||
| 249 | ||||||
| 250 | 3 | 6 | print " Fuzzing $method ($iconf confidence)... "; | |||
| 251 | ||||||
| 252 | 3 | 12 | my $fuzzer = App::Test::Generator::CoverageGuidedFuzzer->new( | |||
| 253 | schema => $schema, | |||||
| 254 | target_sub => $sub_ref, | |||||
| 255 | instance => $instance, | |||||
| 256 | iterations => $fuzz_iters, | |||||
| 257 | ); | |||||
| 258 | ||||||
| 259 | 3 | 23 | $fuzzer->load_corpus($corpus_file) if -f $corpus_file; | |||
| 260 | ||||||
| 261 | 3 | 6 | my $report = $fuzzer->run(); | |||
| 262 | ||||||
| 263 | 3 | 5 | if ($minimize_corpus) { | |||
| 264 | 1 | 2 | my $stats = $fuzzer->minimize_corpus(); | |||
| 265 | printf "%d bugs, %d branches covered, corpus %d->%d entries\n", | |||||
| 266 | $report->{bugs_found}, | |||||
| 267 | $report->{branches_covered}, | |||||
| 268 | $stats->{before}, | |||||
| 269 | 1 | 5 | $stats->{after}; | |||
| 270 | } | |||||
| 271 | ||||||
| 272 | 3 | 7 | $fuzzer->save_corpus($corpus_file); | |||
| 273 | ||||||
| 274 | 3 | 4 | $fuzz_results{$method} = $report; | |||
| 275 | ||||||
| 276 | printf "%d bugs, %d branches covered\n", | |||||
| 277 | $report->{bugs_found}, | |||||
| 278 | $report->{branches_covered} | |||||
| 279 | 3 | 27 | unless $minimize_corpus; | |||
| 280 | } | |||||
| 281 | ||||||
| 282 | 3 | 5 | print "\n"; | |||
| 283 | } | |||||
| 284 | ||||||
| 285 | # --------------------------------------------------------------------------- | |||||
| 286 | # Summary report | |||||
| 287 | # --------------------------------------------------------------------------- | |||||
| 288 | ||||||
| 289 | 6 | 14 | print '=' x 70, "\n", | |||
| 290 | "EXTRACTION SUMMARY\n", | |||||
| 291 | '=' x 70, "\n\n"; | |||||
| 292 | ||||||
| 293 | 6 | 15 | my %input_confidence_counts = (high => 0, medium => 0, low => 0, none => 0); | |||
| 294 | 6 | 13 | my %output_confidence_counts = (high => 0, medium => 0, low => 0, none => 0); | |||
| 295 | ||||||
| 296 | 6 | 14 | foreach my $method (sort keys %$schemas) { | |||
| 297 | 6 | 7 | my $schema = $schemas->{$method}; | |||
| 298 | 6 | 15 | my $iconf = $schema->{_confidence}{input}{level} // 'low'; | |||
| 299 | 6 | 12 | my $oconf = $schema->{_confidence}{output}{level} // 'low'; | |||
| 300 | 6 | 8 | $input_confidence_counts{$iconf}++; | |||
| 301 | 6 | 8 | $output_confidence_counts{$oconf}++; | |||
| 302 | ||||||
| 303 | 6 11 6 | 6 16 12 | my $param_count = scalar grep { $_ !~ /^_/ } keys %{ $schema->{input} }; | |||
| 304 | ||||||
| 305 | 6 | 9 | my $fuzz_col = ''; | |||
| 306 | 6 | 9 | if (exists $fuzz_results{$method}) { | |||
| 307 | 3 | 4 | my $r = $fuzz_results{$method}; | |||
| 308 | $fuzz_col = $r->{bugs_found} | |||||
| 309 | ? sprintf(' BUGS: %d', $r->{bugs_found}) | |||||
| 310 | 3 | 6 | : ' fuzz: ok'; | |||
| 311 | } | |||||
| 312 | ||||||
| 313 | 6 | 27 | printf "%-30s %d params [%s input confidence] [%s output confidence]%s\n", | |||
| 314 | $method, $param_count, uc($iconf), uc($oconf), $fuzz_col; | |||||
| 315 | } | |||||
| 316 | ||||||
| 317 | 6 | 8 | print "\n"; | |||
| 318 | 6 | 31 | print 'Total methods: ', (scalar keys %$schemas), "\n"; | |||
| 319 | 6 | 5 | print " Input:\n"; | |||
| 320 | 6 | 10 | print " High confidence: $input_confidence_counts{high}\n"; | |||
| 321 | 6 | 9 | print " Medium confidence: $input_confidence_counts{medium}\n"; | |||
| 322 | 6 | 11 | print " Low confidence: $input_confidence_counts{low}\n"; | |||
| 323 | 6 | 7 | print " Output:\n"; | |||
| 324 | 6 | 29 | print " High confidence: $output_confidence_counts{high}\n"; | |||
| 325 | 6 | 7 | print " Medium confidence: $output_confidence_counts{medium}\n"; | |||
| 326 | 6 | 12 | print " Low confidence: $output_confidence_counts{low}\n"; | |||
| 327 | 6 | 5 | print "\n"; | |||
| 328 | ||||||
| 329 | 6 | 29 | if ($input_confidence_counts{low} > 0 || $input_confidence_counts{medium} > 0) { | |||
| 330 | 0 | 0 | print "RECOMMENDATION:\n", | |||
| 331 | "Review the generated schemas in $extractor_opts{output_dir}/\n", | |||||
| 332 | "Focus on methods with medium/low confidence ratings.\n\n"; | |||||
| 333 | } | |||||
| 334 | ||||||
| 335 | # Fuzz bug detail | |||||
| 336 | 6 | 8 | if (%fuzz_results) { | |||
| 337 | 3 | 4 | my $total_bugs = 0; | |||
| 338 | 3 | 7 | $total_bugs += $_->{bugs_found} for values %fuzz_results; | |||
| 339 | ||||||
| 340 | 3 | 3 | if ($total_bugs) { | |||
| 341 | 0 | 0 | print '=' x 70, "\n", | |||
| 342 | "FUZZING BUGS FOUND ($total_bugs total)\n", | |||||
| 343 | '=' x 70, "\n\n"; | |||||
| 344 | ||||||
| 345 | 0 | 0 | foreach my $method (sort keys %fuzz_results) { | |||
| 346 | 0 | 0 | my $r = $fuzz_results{$method}; | |||
| 347 | 0 | 0 | next unless $r->{bugs_found}; | |||
| 348 | 0 | 0 | print " $method:\n"; | |||
| 349 | 0 0 | 0 0 | for my $i (0 .. $#{ $r->{bugs} }) { | |||
| 350 | 0 | 0 | my $bug = $r->{bugs}[$i]; | |||
| 351 | 0 | 0 | my $inp = defined($bug->{input}) ? qq("$bug->{input}") : 'undef'; | |||
| 352 | printf " Bug %d: input=%-30s error=%s\n", | |||||
| 353 | 0 | 0 | $i + 1, $inp, $bug->{error}; | |||
| 354 | } | |||||
| 355 | 0 | 0 | print "\n"; | |||
| 356 | } | |||||
| 357 | 0 | 0 | print "Corpora saved to: $corpus_dir/\n\n"; | |||
| 358 | } else { | |||||
| 359 | 3 | 7 | print "Fuzzing complete: no bugs found across ", | |||
| 360 | scalar(keys %fuzz_results), " methods.\n\n"; | |||||
| 361 | } | |||||
| 362 | } | |||||
| 363 | ||||||
| 364 | 6 | 12 | if ($extractor_opts{verbose}) { | |||
| 365 | 1 | 3 | print "Schemas:\n\t", Dumper($schemas); | |||
| 366 | } | |||||
| 367 | ||||||
| 368 | 6 | 353 | print "Schema files written to: $extractor_opts{output_dir}/\n"; | |||
| 369 | ||||||
| 370 | # --------------------------------------------------------------------------- | |||||
| 371 | # Helper: load the target module so methods become callable | |||||
| 372 | # --------------------------------------------------------------------------- | |||||
| 373 | ||||||
| 374 | sub _load_target_module { | |||||
| 375 | 3 | 5 | my ($input_file, $schemas) = @_; | |||
| 376 | ||||||
| 377 | # Derive the package name from the first schema entry that has 'module' set | |||||
| 378 | 3 | 8 | my ($package) = map { $schemas->{$_}{module} } | |||
| 379 | 3 3 | 7 7 | grep { $schemas->{$_}{module} } | |||
| 380 | keys %$schemas; | |||||
| 381 | ||||||
| 382 | 3 | 5 | die 'Could not determine package name from extracted schemas' unless $package; | |||
| 383 | ||||||
| 384 | # Reject anything that is not a syntactically valid Perl package name | |||||
| 385 | # before it is used to build a require path â guards against this | |||||
| 386 | # becoming a code-injection vector if $package is ever sourced from | |||||
| 387 | # something less constrained than a PPI-parsed 'package' statement. | |||||
| 388 | 3 | 11 | die "Invalid package name: $package" | |||
| 389 | unless $package =~ /^[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*\z/; | |||||
| 390 | ||||||
| 391 | # Add the module's containing lib dir to @INC | |||||
| 392 | # Walks up from the file looking for a 'lib' directory | |||||
| 393 | 3 | 49 | my $abs = File::Spec->rel2abs($input_file); | |||
| 394 | 3 | 30 | my ($volume, $directory) = File::Spec->splitpath($abs); | |||
| 395 | 3 | 17 | my @dirs = File::Spec->splitdir($directory); | |||
| 396 | ||||||
| 397 | 3 | 6 | while (@dirs) { | |||
| 398 | # catpath() (not catdir()) keeps $volume attached, so this still | |||||
| 399 | # resolves on Windows when the temp dir and the checkout live on | |||||
| 400 | # different drive letters (a bare catdir() result is interpreted | |||||
| 401 | # as relative to the *current* drive, not $volume's). | |||||
| 402 | 9 | 47 | my $candidate = File::Spec->catpath($volume, File::Spec->catdir(@dirs, 'lib'), ''); | |||
| 403 | 9 | 34 | if (-d $candidate) { | |||
| 404 | 3 | 17 | lib->import($candidate); | |||
| 405 | 3 | 142 | last; | |||
| 406 | } | |||||
| 407 | 6 | 8 | pop @dirs; | |||
| 408 | } | |||||
| 409 | ||||||
| 410 | # require() the module by file path rather than via string eval, so | |||||
| 411 | # $package is never compiled as Perl source. | |||||
| 412 | 3 | 4 | (my $module_file = $package) =~ s{::}{/}g; | |||
| 413 | 3 3 | 2 753 | eval { require "$module_file.pm" } | |||
| 414 | or die "Could not load $package for fuzzing: $@"; | |||||
| 415 | ||||||
| 416 | 3 | 5 | return $package; | |||
| 417 | } | |||||
| 418 | ||||||
| 419 | # Try to construct a default instance of the target package for method calls. | |||||
| 420 | # Attempts new() with progressively more forgiving argument lists. | |||||
| 421 | # Returns the instance on success, undef if nothing works. | |||||
| 422 | sub _try_construct { | |||||
| 423 | 3 | 4 | my ($package) = @_; | |||
| 424 | ||||||
| 425 | 3 | 6 | for my $args ([], [{}], [undef]) { | |||
| 426 | 9 9 | 6 57 | my $obj = eval { $package->new(@$args) }; | |||
| 427 | 9 | 12 | next if $@; | |||
| 428 | 0 | 0 | next unless defined $obj && ref $obj; | |||
| 429 | 0 | 0 | return $obj; | |||
| 430 | } | |||||
| 431 | ||||||
| 432 | 3 | 6 | return undef; | |||
| 433 | } | |||||
| 434 | ||||||