packages/sql-formatter/fuzz/Target/FormatTarget.php
1<?php
2
3declare(strict_types=1);
4
5namespace Fuzz\Target;
6
7use Error;
8use SqlFormatter\Core\FormatOptions;
9use SqlFormatter\Core\FormattingException;
10use SqlFormatter\Core\Style;
11use SqlFormatter\Facade\Formatter;
12use SqlParser\Lexer\SourceException;
13use SqlParser\MySql\MySqlParser;
14use SqlParser\PostgreSql\PostgreSqlParser;
15use SqlParser\Sqlite\SqliteParser;
16
17/**
18 * Formats arbitrary bytes as SQL text and reports anything but a clean rejection.
19 *
20 * The first input byte selects the layout preset and the indentation width, the rest is
21 * handed to the formatter as the SQL text. Text the parser rejects is not a finding: the
22 * parser's own exceptions are the documented answer to invalid input. Everything else is
23 * one: a verification failure, any other exception, a fatal error, a timeout, or output
24 * that changes when it is formatted again.
25 */
26final class FormatTarget
27{
28 /**
29 * @param MySqlParser|PostgreSqlParser|SqliteParser $parser The parser of the dialect and release under test
30 * @param string $grammarVersion The release tag for findings, e.g. "mysql-8.4.7"
31 */
32 public function __construct(
33 private readonly MySqlParser|PostgreSqlParser|SqliteParser $parser,
34 private readonly string $grammarVersion,
35 ) {
36 }
37
38 /**
39 * Derives the layout from the selector byte: two bits choose the preset, four the indentation.
40 *
41 * @param int $selector The first input byte
42 *
43 * @return FormatOptions One of the 64 layouts
44 */
45 public static function options(int $selector): FormatOptions
46 {
47 $styles = Style::cases();
48 return new FormatOptions($styles[$selector % count($styles)], (($selector >> 2) % 16) + 1);
49 }
50
51 /**
52 * Formats the text and formats the result again.
53 *
54 * @param string $input The fuzzer input: a selector byte followed by the SQL text
55 *
56 * @throws Error When formatting fails verification, or when formatting is not idempotent
57 */
58 public function verify(string $input): void
59 {
60 if ($input === '') {
61 return;
62 }
63 $options = self::options(ord($input[0]));
64 $sql = substr($input, 1);
65 $context = "Grammar: {$this->grammarVersion}\nStyle: {$options->style->value}, indent {$options->indentWidth}\nInput (hex): " . bin2hex($input) . "\nSQL: {$sql}";
66 $formatter = new Formatter($this->parser, $options);
67 try {
68 $formatted = $formatter->format($sql);
69 } catch (SourceException) {
70 return;
71 } catch (FormattingException $failure) {
72 throw new Error("Formatting failed verification\n{$context}\nError: {$failure->getMessage()}", 0, $failure);
73 }
74 try {
75 $again = $formatter->format($formatted);
76 } catch (SourceException|FormattingException $failure) {
77 throw new Error("Formatted SQL cannot be formatted again\n{$context}\nFormatted: {$formatted}\nError: {$failure->getMessage()}", 0, $failure);
78 }
79 if ($again !== $formatted) {
80 throw new Error("Formatting is not idempotent\n{$context}\nFirst: {$formatted}\nSecond: {$again}");
81 }
82 }
83}
84