wiki.techinc.nl/tests/phpunit/structure/AutoLoaderStructureTest.php

240 lines
7 KiB
PHP
Raw Normal View History

<?php
Safer autoloading with respect to file-scope code Many files were in the autoloader despite having potentially harmful file-scope code. * Exclude all CommandLineInc maintenance scripts from the autoloader. * Introduce "NO_AUTOLOAD" tag which excludes the file containing it from the autoloader. Use it on CommandLineInc.php and a few suspicious-looking files without classes in case they are refactored to add classes in the future. * Add a test which parses all non-PSR4 class files and confirms that they do not contain dangerous file-scope code. It's slow (15s) but its results were enlightening. * Several maintenance scripts define constants in the file scope, intending to modify the behaviour of MediaWiki. Either move the define() to a later setup function, or protect with NO_AUTOLOAD. * Use require_once consistently with Maintenance.php and doMaintenance.php, per the original convention which is supposed to allow one maintenance script to use the class of another maintenance script. Using require breaks autoloading of these maintenance class files. * When Maintenance.php is included, check if MediaWiki has already started, and if so, return early. Revert the fix for T250003 which is incompatible with this safety measure. Hopefully it was superseded by splitting out the class file. * In runScript.php add a redundant PHP_SAPI check since it does some things in file-scope code before any other check will be run. * Change the if(false) class_alias(...) to something more hackish and more compatible with the new test. * Some site-related scripts found Maintenance.php in a non-standard way. Use the standard way. * fileOpPerfTest.php called error_reporting(). Probably debugging code left in; removed. * Moved mediawiki.compress.7z registration from the class file to the caller. Change-Id: I1b1be90343a5ab678df6f1b1bdd03319dcf6537f
2021-01-08 02:16:02 +00:00
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Stmt;
use PhpParser\ParserFactory;
class AutoLoaderStructureTest extends MediaWikiIntegrationTestCase {
/**
* Assert that there were no classes loaded that are not registered with the AutoLoader.
*
* For example foo.php having class Foo and class Bar but only registering Foo.
* This is important because we should not be relying on Foo being used before Bar.
*/
public function testAutoLoadConfig() {
$results = self::checkAutoLoadConf();
$this->assertEquals(
$results['expected'],
$results['actual']
);
}
private static function parseFile( $contents ) {
// We could use token_get_all() here, but this is faster
// Note: Keep in sync with ClassCollector
$matches = [];
preg_match_all( '/
^ [\t ]* (?:
(?:final\s+)? (?:abstract\s+)? (?:class|interface|trait) \s+
(?P<class> \w+)
|
class_alias \s* \( \s*
([\'"]) (?P<original> [^\'"]+) \g{-2} \s* , \s*
([\'"]) (?P<alias> [^\'"]+ ) \g{-2} \s*
\) \s* ;
|
class_alias \s* \( \s*
(?P<originalStatic> [\w\\\\]+)::class \s* , \s*
([\'"]) (?P<aliasString> [^\'"]+ ) \g{-2} \s*
\) \s* ;
)
/imx', $contents, $matches, PREG_SET_ORDER );
$namespaceMatch = [];
preg_match( '/
^ [\t ]*
namespace \s+
(\w+(\\\\\w+)*)
\s* ;
/imx', $contents, $namespaceMatch );
$fileNamespace = $namespaceMatch ? $namespaceMatch[1] . '\\' : '';
$classesInFile = [];
$aliasesInFile = [];
foreach ( $matches as $match ) {
if ( !empty( $match['class'] ) ) {
// 'class Foo {}'
$class = $fileNamespace . $match['class'];
$classesInFile[$class] = true;
} elseif ( !empty( $match['original'] ) ) {
// 'class_alias( "Foo", "Bar" );'
$aliasesInFile[self::removeSlashes( $match['alias'] )] = $match['original'];
} else {
// 'class_alias( Foo::class, "Bar" );'
$aliasesInFile[self::removeSlashes( $match['aliasString'] )] =
$fileNamespace . $match['originalStatic'];
}
}
return [ $classesInFile, $aliasesInFile ];
}
private static function removeSlashes( $str ) {
return str_replace( '\\\\', '\\', $str );
}
AutoLoaderStructureTest: Re-write slashes so test passes on Windows A difference between UNIX and Windows is the dir separator, which makes this test failing, because the file name does not match to filter out the psr4 loaded classes to just keep the alias, which must be part of the autoloader class list. 1) AutoLoaderStructureTest::testAutoLoadConfig Failed asserting that two arrays are equal. --- Expected +++ Actual @@ @@ 'ParserTestFileSuite' => '[...]...te.php' 'ParserTestTopLevelSuite' => '[...]...te.php' 'SuiteEventsTrait' => '[...]...it.php' + 'MediaWiki\Block\DatabaseBlock' => '[...]...ck.php' + 'MediaWiki\SpecialPage\SpecialPageFactory' => '[...]...ry.php' + 'MediaWiki\Revision\IncompleteRevisionException' => '[...]...on.php' + 'MediaWiki\Revision\MutableRevisionRecord' => '[...]...rd.php' + 'MediaWiki\Revision\MutableRevisionSlots' => '[...]...ts.php' + 'MediaWiki\Revision\RevisionAccessException' => '[...]...on.php' + 'MediaWiki\Revision\RevisionArchiveRecord' => '[...]...rd.php' + 'MediaWiki\Revision\RevisionFactory' => '[...]...ry.php' + 'MediaWiki\Revision\RevisionLookup' => '[...]...up.php' + 'MediaWiki\Revision\RevisionRecord' => '[...]...rd.php' + 'MediaWiki\Revision\RevisionSlots' => '[...]...ts.php' + 'MediaWiki\Revision\RevisionStore' => '[...]...re.php' + 'MediaWiki\Revision\RevisionStoreRecord' => '[...]...rd.php' + 'MediaWiki\Revision\SlotRecord' => '[...]...rd.php' + 'MediaWiki\Revision\SuppressedDataException' => '[...]...on.php' ) Change-Id: I1485cc7309c20d131f398473367d281a3ce78a25
2020-10-31 01:04:49 +00:00
private static function fixSlashes( $str ) {
return str_replace( '\\', '/', $str );
}
protected static function checkAutoLoadConf() {
global $wgAutoloadLocalClasses, $wgAutoloadClasses, $IP;
// wgAutoloadLocalClasses has precedence, just like in includes/AutoLoader.php
$expected = $wgAutoloadLocalClasses + $wgAutoloadClasses;
$actual = [];
$psr4Namespaces = [];
foreach ( AutoLoader::getAutoloadNamespaces() as $ns => $path ) {
AutoLoaderStructureTest: Re-write slashes so test passes on Windows A difference between UNIX and Windows is the dir separator, which makes this test failing, because the file name does not match to filter out the psr4 loaded classes to just keep the alias, which must be part of the autoloader class list. 1) AutoLoaderStructureTest::testAutoLoadConfig Failed asserting that two arrays are equal. --- Expected +++ Actual @@ @@ 'ParserTestFileSuite' => '[...]...te.php' 'ParserTestTopLevelSuite' => '[...]...te.php' 'SuiteEventsTrait' => '[...]...it.php' + 'MediaWiki\Block\DatabaseBlock' => '[...]...ck.php' + 'MediaWiki\SpecialPage\SpecialPageFactory' => '[...]...ry.php' + 'MediaWiki\Revision\IncompleteRevisionException' => '[...]...on.php' + 'MediaWiki\Revision\MutableRevisionRecord' => '[...]...rd.php' + 'MediaWiki\Revision\MutableRevisionSlots' => '[...]...ts.php' + 'MediaWiki\Revision\RevisionAccessException' => '[...]...on.php' + 'MediaWiki\Revision\RevisionArchiveRecord' => '[...]...rd.php' + 'MediaWiki\Revision\RevisionFactory' => '[...]...ry.php' + 'MediaWiki\Revision\RevisionLookup' => '[...]...up.php' + 'MediaWiki\Revision\RevisionRecord' => '[...]...rd.php' + 'MediaWiki\Revision\RevisionSlots' => '[...]...ts.php' + 'MediaWiki\Revision\RevisionStore' => '[...]...re.php' + 'MediaWiki\Revision\RevisionStoreRecord' => '[...]...rd.php' + 'MediaWiki\Revision\SlotRecord' => '[...]...rd.php' + 'MediaWiki\Revision\SuppressedDataException' => '[...]...on.php' ) Change-Id: I1485cc7309c20d131f398473367d281a3ce78a25
2020-10-31 01:04:49 +00:00
$psr4Namespaces[rtrim( $ns, '\\' ) . '\\'] = self::fixSlashes( rtrim( $path, '/' ) );
}
foreach ( $expected as $class => $file ) {
// Only prefix $IP if it doesn't have it already.
// Generally local classes don't have it, and those from extensions and test suites do.
if ( substr( $file, 0, 1 ) != '/' && substr( $file, 1, 1 ) != ':' ) {
AutoLoaderStructureTest: Re-write slashes so test passes on Windows A difference between UNIX and Windows is the dir separator, which makes this test failing, because the file name does not match to filter out the psr4 loaded classes to just keep the alias, which must be part of the autoloader class list. 1) AutoLoaderStructureTest::testAutoLoadConfig Failed asserting that two arrays are equal. --- Expected +++ Actual @@ @@ 'ParserTestFileSuite' => '[...]...te.php' 'ParserTestTopLevelSuite' => '[...]...te.php' 'SuiteEventsTrait' => '[...]...it.php' + 'MediaWiki\Block\DatabaseBlock' => '[...]...ck.php' + 'MediaWiki\SpecialPage\SpecialPageFactory' => '[...]...ry.php' + 'MediaWiki\Revision\IncompleteRevisionException' => '[...]...on.php' + 'MediaWiki\Revision\MutableRevisionRecord' => '[...]...rd.php' + 'MediaWiki\Revision\MutableRevisionSlots' => '[...]...ts.php' + 'MediaWiki\Revision\RevisionAccessException' => '[...]...on.php' + 'MediaWiki\Revision\RevisionArchiveRecord' => '[...]...rd.php' + 'MediaWiki\Revision\RevisionFactory' => '[...]...ry.php' + 'MediaWiki\Revision\RevisionLookup' => '[...]...up.php' + 'MediaWiki\Revision\RevisionRecord' => '[...]...rd.php' + 'MediaWiki\Revision\RevisionSlots' => '[...]...ts.php' + 'MediaWiki\Revision\RevisionStore' => '[...]...re.php' + 'MediaWiki\Revision\RevisionStoreRecord' => '[...]...rd.php' + 'MediaWiki\Revision\SlotRecord' => '[...]...rd.php' + 'MediaWiki\Revision\SuppressedDataException' => '[...]...on.php' ) Change-Id: I1485cc7309c20d131f398473367d281a3ce78a25
2020-10-31 01:04:49 +00:00
$filePath = self::fixSlashes( "$IP/$file" );
} else {
AutoLoaderStructureTest: Re-write slashes so test passes on Windows A difference between UNIX and Windows is the dir separator, which makes this test failing, because the file name does not match to filter out the psr4 loaded classes to just keep the alias, which must be part of the autoloader class list. 1) AutoLoaderStructureTest::testAutoLoadConfig Failed asserting that two arrays are equal. --- Expected +++ Actual @@ @@ 'ParserTestFileSuite' => '[...]...te.php' 'ParserTestTopLevelSuite' => '[...]...te.php' 'SuiteEventsTrait' => '[...]...it.php' + 'MediaWiki\Block\DatabaseBlock' => '[...]...ck.php' + 'MediaWiki\SpecialPage\SpecialPageFactory' => '[...]...ry.php' + 'MediaWiki\Revision\IncompleteRevisionException' => '[...]...on.php' + 'MediaWiki\Revision\MutableRevisionRecord' => '[...]...rd.php' + 'MediaWiki\Revision\MutableRevisionSlots' => '[...]...ts.php' + 'MediaWiki\Revision\RevisionAccessException' => '[...]...on.php' + 'MediaWiki\Revision\RevisionArchiveRecord' => '[...]...rd.php' + 'MediaWiki\Revision\RevisionFactory' => '[...]...ry.php' + 'MediaWiki\Revision\RevisionLookup' => '[...]...up.php' + 'MediaWiki\Revision\RevisionRecord' => '[...]...rd.php' + 'MediaWiki\Revision\RevisionSlots' => '[...]...ts.php' + 'MediaWiki\Revision\RevisionStore' => '[...]...re.php' + 'MediaWiki\Revision\RevisionStoreRecord' => '[...]...rd.php' + 'MediaWiki\Revision\SlotRecord' => '[...]...rd.php' + 'MediaWiki\Revision\SuppressedDataException' => '[...]...on.php' ) Change-Id: I1485cc7309c20d131f398473367d281a3ce78a25
2020-10-31 01:04:49 +00:00
$filePath = self::fixSlashes( $file );
}
if ( !is_file( $filePath ) ) {
$actual[$class] = "[file '$filePath' does not exist]";
continue;
}
$contents = @file_get_contents( $filePath );
if ( $contents === false ) {
$actual[$class] = "[couldn't read file '$filePath']";
continue;
}
list( $classesInFile, $aliasesInFile ) = self::parseFile( $contents );
foreach ( $classesInFile as $className => $ignore ) {
// Skip if it's a PSR4 class
$parts = explode( '\\', $className );
for ( $i = count( $parts ) - 1; $i > 0; $i-- ) {
$ns = implode( '\\', array_slice( $parts, 0, $i ) ) . '\\';
if ( isset( $psr4Namespaces[$ns] ) ) {
$expectedPath = $psr4Namespaces[$ns] . '/'
. implode( '/', array_slice( $parts, $i ) )
. '.php';
if ( $filePath === $expectedPath ) {
continue 2;
}
}
}
// Nope, add it.
$actual[$className] = $file;
}
// Only accept aliases for classes in the same file, because for correct
// behavior, all aliases for a class must be set up when the class is loaded
// (see <https://bugs.php.net/bug.php?id=61422>).
foreach ( $aliasesInFile as $alias => $class ) {
if ( isset( $classesInFile[$class] ) ) {
$actual[$alias] = $file;
} else {
$actual[$alias] = "[original class not in $file]";
}
}
}
return [
'expected' => $expected,
'actual' => $actual,
];
}
public function testAutoloadOrder() {
$path = __DIR__ . '/../../..';
$oldAutoload = file_get_contents( $path . '/autoload.php' );
$generator = new AutoloadGenerator( $path, 'local' );
$generator->setPsr4Namespaces( AutoLoader::getAutoloadNamespaces() );
$generator->initMediaWikiDefault();
$newAutoload = $generator->getAutoload( 'maintenance/generateLocalAutoload.php' );
$this->assertEquals( $oldAutoload, $newAutoload, 'autoload.php does not match' .
' output of generateLocalAutoload.php script.' );
}
/**
* Verify that all the directories specified for PSR-4 autoloading
* actually exist, to prevent situations like T259448
*/
public function testAutoloadNamespaces() {
$missing = [];
foreach ( AutoLoader::$psr4Namespaces as $ns => $path ) {
if ( !is_dir( $path ) ) {
$missing[] = "Directory $path for namespace $ns does not exist";
}
}
$this->assertSame( [], $missing );
}
Safer autoloading with respect to file-scope code Many files were in the autoloader despite having potentially harmful file-scope code. * Exclude all CommandLineInc maintenance scripts from the autoloader. * Introduce "NO_AUTOLOAD" tag which excludes the file containing it from the autoloader. Use it on CommandLineInc.php and a few suspicious-looking files without classes in case they are refactored to add classes in the future. * Add a test which parses all non-PSR4 class files and confirms that they do not contain dangerous file-scope code. It's slow (15s) but its results were enlightening. * Several maintenance scripts define constants in the file scope, intending to modify the behaviour of MediaWiki. Either move the define() to a later setup function, or protect with NO_AUTOLOAD. * Use require_once consistently with Maintenance.php and doMaintenance.php, per the original convention which is supposed to allow one maintenance script to use the class of another maintenance script. Using require breaks autoloading of these maintenance class files. * When Maintenance.php is included, check if MediaWiki has already started, and if so, return early. Revert the fix for T250003 which is incompatible with this safety measure. Hopefully it was superseded by splitting out the class file. * In runScript.php add a redundant PHP_SAPI check since it does some things in file-scope code before any other check will be run. * Change the if(false) class_alias(...) to something more hackish and more compatible with the new test. * Some site-related scripts found Maintenance.php in a non-standard way. Use the standard way. * fileOpPerfTest.php called error_reporting(). Probably debugging code left in; removed. * Moved mediawiki.compress.7z registration from the class file to the caller. Change-Id: I1b1be90343a5ab678df6f1b1bdd03319dcf6537f
2021-01-08 02:16:02 +00:00
public static function provideAutoloadNoFileScope() {
global $wgAutoloadLocalClasses;
$files = array_unique( $wgAutoloadLocalClasses );
$args = [];
foreach ( $files as $file ) {
$args[$file] = [ $file ];
}
return $args;
}
/**
* Confirm that all files in $wgAutoloadLocalClasses have no file-scope code
* apart from specific exemptions.
*
* This is slow (~15s). Running it arguably renders all the performance
* optimisations above obsolete.
*
* @dataProvider provideAutoloadNoFileScope
*/
public function testAutoloadNoFileScope( $file ) {
$parser = ( new ParserFactory )->create( ParserFactory::ONLY_PHP7 );
$ast = $parser->parse( file_get_contents( $file ) );
foreach ( $ast as $node ) {
if ( $node instanceof Stmt\ClassLike
|| $node instanceof Stmt\Namespace_
|| $node instanceof Stmt\Use_
|| $node instanceof Stmt\Nop
|| $node instanceof Stmt\Declare_
|| $node instanceof Stmt\Function_
) {
continue;
}
if ( $node instanceof Stmt\Expression ) {
$expr = $node->expr;
if ( $expr instanceof Expr\FuncCall ) {
if ( $expr->name instanceof Node\Name ) {
if ( in_array( $expr->name->toString(), [
'class_alias',
'define'
] ) ) {
continue;
}
}
} elseif ( $expr instanceof Expr\Include_ ) {
if ( $expr->type === Expr\Include_::TYPE_REQUIRE_ONCE ) {
continue;
}
} elseif ( $expr instanceof Expr\Assign ) {
if ( $expr->var->name === 'maintClass' ) {
continue;
}
}
}
$line = $node->getLine();
$this->assertNull( $node, "Found file scope code in $file at line $line" );
}
$this->assertTrue( true );
}
}