1<?php 2/** 3 * Performs fuzz-style testing of MediaWiki's preprocessor. 4 * 5 * This program is free software; you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation; either version 2 of the License, or 8 * (at your option) any later version. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License along 16 * with this program; if not, write to the Free Software Foundation, Inc., 17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 18 * http://www.gnu.org/copyleft/gpl.html 19 * 20 * @file 21 * @ingroup Maintenance 22 */ 23 24use MediaWiki\MediaWikiServices; 25 26use Wikimedia\TestingAccessWrapper; 27 28$optionsWithoutArgs = [ 'verbose' ]; 29require_once __DIR__ . '/CommandLineInc.php'; 30 31class PPFuzzTester { 32 public $hairs = [ 33 '[[', ']]', '{{', '{{', '}}', '}}', '{{{', '}}}', 34 '<', '>', '<nowiki', '<gallery', '</nowiki>', '</gallery>', '<nOwIkI>', '</NoWiKi>', 35 '<!--', '-->', 36 "\n==", "==\n", 37 '|', '=', "\n", ' ', "\t", "\x7f", 38 '~~', '~~~', '~~~~', 'subst:', 39 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 40 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 41 42 // extensions 43 // '<ref>', '</ref>', '<references/>', 44 ]; 45 public $minLength = 0; 46 public $maxLength = 20; 47 public $maxTemplates = 5; 48 // public $outputTypes = [ 'OT_HTML', 'OT_WIKI', 'OT_PREPROCESS' ]; 49 public $entryPoints = [ 'fuzzTestSrvus', 'fuzzTestPst', 'fuzzTestPreprocess' ]; 50 public $verbose = false; 51 52 /** 53 * @var bool|PPFuzzTest 54 */ 55 private static $currentTest = false; 56 57 public function execute() { 58 if ( !file_exists( 'results' ) ) { 59 mkdir( 'results' ); 60 } 61 if ( !is_dir( 'results' ) ) { 62 echo "Unable to create 'results' directory\n"; 63 exit( 1 ); 64 } 65 $overallStart = microtime( true ); 66 $reportInterval = 1000; 67 // @phan-suppress-next-line PhanInfiniteLoop 68 for ( $i = 1; true; $i++ ) { 69 $t = -microtime( true ); 70 try { 71 self::$currentTest = new PPFuzzTest( $this ); 72 self::$currentTest->execute(); 73 $passed = 'passed'; 74 } catch ( Exception $e ) { 75 $testReport = self::$currentTest->getReport(); 76 $exceptionReport = $e instanceof MWException ? $e->getText() : (string)$e; 77 $hash = md5( $testReport ); 78 // @phan-suppress-next-line SecurityCheck-PathTraversal False positive T268920 79 file_put_contents( "results/ppft-$hash.in", serialize( self::$currentTest ) ); 80 // @phan-suppress-next-line SecurityCheck-PathTraversal False positive T268920 81 file_put_contents( "results/ppft-$hash.fail", 82 "Input:\n$testReport\n\nException report:\n$exceptionReport\n" ); 83 print "Test $hash failed\n"; 84 $passed = 'failed'; 85 } 86 $t += microtime( true ); 87 88 if ( $this->verbose ) { 89 printf( "Test $passed in %.3f seconds\n", $t ); 90 print self::$currentTest->getReport(); 91 } 92 93 $reportMetric = ( microtime( true ) - $overallStart ) / $i * $reportInterval; 94 if ( $reportMetric > 25 ) { 95 if ( substr( $reportInterval, 0, 1 ) === '1' ) { 96 $reportInterval /= 2; 97 } else { 98 $reportInterval /= 5; 99 } 100 } elseif ( $reportMetric < 4 ) { 101 if ( substr( $reportInterval, 0, 1 ) === '1' ) { 102 $reportInterval *= 5; 103 } else { 104 $reportInterval *= 2; 105 } 106 } 107 if ( $i % $reportInterval == 0 ) { 108 print "$i tests done\n"; 109 /* 110 $testReport = self::$currentTest->getReport(); 111 $filename = 'results/ppft-' . md5( $testReport ) . '.pass'; 112 file_put_contents( $filename, "Input:\n$testReport\n" );*/ 113 } 114 } 115 } 116 117 public function makeInputText( $max = false ) { 118 if ( $max === false ) { 119 $max = $this->maxLength; 120 } 121 $length = mt_rand( $this->minLength, $max ); 122 $s = ''; 123 for ( $i = 0; $i < $length; $i++ ) { 124 $hairIndex = mt_rand( 0, count( $this->hairs ) - 1 ); 125 $s .= $this->hairs[$hairIndex]; 126 } 127 // Send through the UTF-8 normaliser 128 // This resolves a few differences between the old preprocessor and the 129 // XML-based one, which doesn't like illegals and converts line endings. 130 // It's done by the MW UI, so it's a reasonably legitimate thing to do. 131 $s = MediaWikiServices::getInstance()->getContentLanguage()->normalize( $s ); 132 133 return $s; 134 } 135 136 public function makeTitle() { 137 return Title::newFromText( mt_rand( 0, 1000000 ), mt_rand( 0, 10 ) ); 138 } 139 140 /* 141 public function pickOutputType() { 142 $count = count( $this->outputTypes ); 143 return $this->outputTypes[ mt_rand( 0, $count - 1 ) ]; 144 }*/ 145 146 public function pickEntryPoint() { 147 $count = count( $this->entryPoints ); 148 149 return $this->entryPoints[mt_rand( 0, $count - 1 )]; 150 } 151} 152 153class PPFuzzTest { 154 /** 155 * @var array[] 156 * @phan-var array<string,array{text:string|false,finalTitle:Title}> 157 */ 158 public $templates; 159 public $mainText, $title, $entryPoint, $output; 160 161 /** @var PPFuzzTester */ 162 private $parent; 163 /** @var string */ 164 public $nickname; 165 /** @var bool */ 166 public $fancySig; 167 168 /** 169 * @param PPFuzzTester $tester 170 */ 171 public function __construct( $tester ) { 172 global $wgMaxSigChars; 173 $this->parent = $tester; 174 $this->mainText = $tester->makeInputText(); 175 $this->title = $tester->makeTitle(); 176 // $this->outputType = $tester->pickOutputType(); 177 $this->entryPoint = $tester->pickEntryPoint(); 178 $this->nickname = $tester->makeInputText( $wgMaxSigChars + 10 ); 179 $this->fancySig = (bool)mt_rand( 0, 1 ); 180 $this->templates = []; 181 } 182 183 /** 184 * @param Title $title 185 * @return array 186 */ 187 public function templateHook( $title ) { 188 $titleText = $title->getPrefixedDBkey(); 189 190 if ( !isset( $this->templates[$titleText] ) ) { 191 $finalTitle = $title; 192 if ( count( $this->templates ) >= $this->parent->maxTemplates ) { 193 // Too many templates 194 $text = false; 195 } else { 196 if ( !mt_rand( 0, 1 ) ) { 197 // Redirect 198 $finalTitle = $this->parent->makeTitle(); 199 } 200 if ( !mt_rand( 0, 5 ) ) { 201 // Doesn't exist 202 $text = false; 203 } else { 204 $text = $this->parent->makeInputText(); 205 } 206 } 207 $this->templates[$titleText] = [ 208 'text' => $text, 209 'finalTitle' => $finalTitle ]; 210 } 211 212 return $this->templates[$titleText]; 213 } 214 215 public function execute() { 216 global $wgUser; 217 218 $user = new PPFuzzUser; 219 $user->mName = 'Fuzz'; 220 $user->mFrom = 'name'; 221 $user->ppfz_test = $this; 222 223 $wgUser = $user; 224 225 $options = ParserOptions::newFromUser( $user ); 226 $options->setTemplateCallback( [ $this, 'templateHook' ] ); 227 $options->setTimestamp( wfTimestampNow() ); 228 $this->output = call_user_func( 229 [ TestingAccessWrapper::newFromObject( 230 MediaWikiServices::getInstance()->getParser() 231 ), $this->entryPoint ], 232 $this->mainText, 233 $this->title, 234 $options 235 ); 236 237 return $this->output; 238 } 239 240 public function getReport() { 241 $s = "Title: " . $this->title->getPrefixedDBkey() . "\n" . 242 // "Output type: {$this->outputType}\n" . 243 "Entry point: {$this->entryPoint}\n" . 244 "User: " . ( $this->fancySig ? 'fancy' : 'no-fancy' ) . 245 ' ' . var_export( $this->nickname, true ) . "\n" . 246 "Main text: " . var_export( $this->mainText, true ) . "\n"; 247 foreach ( $this->templates as $titleText => $template ) { 248 $finalTitle = $template['finalTitle']; 249 if ( $finalTitle != $titleText ) { 250 $s .= "[[$titleText]] -> [[$finalTitle]]: " . var_export( $template['text'], true ) . "\n"; 251 } else { 252 $s .= "[[$titleText]]: " . var_export( $template['text'], true ) . "\n"; 253 } 254 } 255 $s .= "Output: " . var_export( $this->output, true ) . "\n"; 256 257 return $s; 258 } 259} 260 261class PPFuzzUser extends User { 262 public $ppfz_test, $mDataLoaded; 263 264 public function load( $flags = null ) { 265 if ( $this->mDataLoaded ) { 266 return; 267 } 268 $this->mDataLoaded = true; 269 $this->loadDefaults( $this->mName ); 270 } 271 272 public function getOption( $oname, $defaultOverride = null, $ignoreHidden = false ) { 273 if ( $oname === 'fancysig' ) { 274 return $this->ppfz_test->fancySig; 275 } elseif ( $oname === 'nickname' ) { 276 return $this->ppfz_test->nickname; 277 } else { 278 return parent::getOption( $oname, $defaultOverride, $ignoreHidden ); 279 } 280 } 281} 282 283ini_set( 'memory_limit', '50M' ); 284if ( isset( $args[0] ) ) { 285 $testText = file_get_contents( $args[0] ); 286 if ( !$testText ) { 287 print "File not found\n"; 288 exit( 1 ); 289 } 290 $test = unserialize( $testText ); 291 $result = $test->execute(); 292 print "Test passed.\n"; 293} else { 294 $tester = new PPFuzzTester; 295 $tester->verbose = isset( $options['verbose'] ); 296 $tester->execute(); 297} 298