Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 60
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
MediaWikiHooksHelper
0.00% covered (danger)
0.00%
0 / 60
0.00% covered (danger)
0.00%
0 / 8
930
0.00% covered (danger)
0.00%
0 / 1
 getInstance
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 clearCache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 registerHook
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 loadExtensionJson
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 readJsonFile
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
182
 getHookSubscribers
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 isSpecialHookSubscriber
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 normalizeHookName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php declare( strict_types = 1 );
2
3namespace SecurityCheckPlugin;
4
5/**
6 * @license GPL-2.0-or-later
7 */
8
9use Phan\Config;
10use Phan\Language\FQSEN\FullyQualifiedFunctionLikeName;
11use Phan\Language\FQSEN\FullyQualifiedFunctionName;
12use Phan\Language\FQSEN\FullyQualifiedMethodName;
13
14class MediaWikiHooksHelper {
15    /**
16     * @var bool Whether extension.json/skin.json was already loaded
17     */
18    private $extensionJsonLoaded = false;
19
20    /**
21     * @var FullyQualifiedFunctionLikeName[][] A mapping from normalized hook names to FQSEN that implement it
22     * @phan-var array<string,FullyQualifiedFunctionLikeName[]>
23     */
24    private $hookSubscribers = [];
25
26    /** @var self|null */
27    private static $instance;
28
29    public static function getInstance(): self {
30        if ( !self::$instance ) {
31            self::$instance = new self;
32        }
33        return self::$instance;
34    }
35
36    /**
37     * Clear the extension.json cache, for testing purpose
38     *
39     * @suppress PhanUnreferencedPublicMethod
40     */
41    public function clearCache(): void {
42        $this->extensionJsonLoaded = false;
43    }
44
45    /**
46     * Add a hook implementation to our list.
47     *
48     * This also handles parser hooks which aren't normal hooks.
49     * Non-normal hooks start their name with a "!"
50     *
51     * @param string $hookName Name of hook
52     * @param FullyQualifiedFunctionLikeName $fqsen The implementing method
53     * @return bool true if already registered, false otherwise
54     */
55    public function registerHook( string $hookName, FullyQualifiedFunctionLikeName $fqsen ): bool {
56        $normalizedHookName = self::normalizeHookName( $hookName );
57        if ( !isset( $this->hookSubscribers[$normalizedHookName] ) ) {
58            $this->hookSubscribers[$normalizedHookName] = [];
59        }
60        if ( in_array( $fqsen, $this->hookSubscribers[$normalizedHookName], true ) ) {
61            return true;
62        }
63        $this->hookSubscribers[$normalizedHookName][] = $fqsen;
64        return false;
65    }
66
67    /**
68     * Register hooks from extension.json/skin.json
69     *
70     * Assumes extension.json/skin.json is in project root directory
71     * unless SECURITY_CHECK_EXT_PATH is set
72     */
73    protected function loadExtensionJson(): void {
74        if ( $this->extensionJsonLoaded ) {
75            return;
76        }
77        foreach ( [ 'extension.json', 'skin.json' ] as $filename ) {
78            $envPath = getenv( 'SECURITY_CHECK_EXT_PATH' );
79            if ( $envPath ) {
80                $jsonPath = $envPath . '/' . $filename;
81            } else {
82                $jsonPath = Config::projectPath( $filename );
83            }
84            if ( file_exists( $jsonPath ) ) {
85                $this->readJsonFile( $jsonPath );
86            }
87        }
88        $this->extensionJsonLoaded = true;
89    }
90
91    private function readJsonFile( string $jsonPath ): void {
92        $json = json_decode( file_get_contents( $jsonPath ), true );
93        if ( !is_array( $json ) || !isset( $json['Hooks'] ) || !is_array( $json['Hooks'] ) ) {
94            return;
95        }
96        $namedHandlers = [];
97        foreach ( $json['HookHandlers'] ?? [] as $name => $handler ) {
98            // TODO: This key is not unique if more than one extension is being analyzed. Is that wanted, though?
99            $namedHandlers[$name] = $handler;
100        }
101
102        foreach ( $json['Hooks'] as $hookName => $cbList ) {
103            if ( isset( $cbList["handler"] ) ) {
104                $cbList = $cbList["handler"];
105            }
106            if ( is_string( $cbList ) ) {
107                $cbList = [ $cbList ];
108            }
109
110            foreach ( $cbList as $cb ) {
111                if ( isset( $namedHandlers[$cb] ) ) {
112                    // TODO ObjectFactory not fully handled here. Would deserve some code in a general-purpose
113                    // MediaWiki plugin, see T275742.
114                    if ( isset( $namedHandlers[$cb]['class'] ) ) {
115                        $normalizedHookName = ucfirst( self::normalizeHookName( $hookName ) );
116                        $callbackString = $namedHandlers[$cb]['class'] . "::on$normalizedHookName";
117                    } elseif ( isset( $namedHandlers[$cb]['factory'] ) ) {
118                        // TODO: We'd need a CodeBase to retrieve the factory method and check its return value
119                        continue;
120                    } else {
121                        // @phan-suppress-previous-line PhanPluginDuplicateIfStatements
122                        continue;
123                    }
124                    $callback = FullyQualifiedMethodName::fromFullyQualifiedString( $callbackString );
125                } elseif ( !str_contains( $cb, '::' ) ) {
126                    $callback = FullyQualifiedFunctionName::fromFullyQualifiedString( $cb );
127                } else {
128                    $callback = FullyQualifiedMethodName::fromFullyQualifiedString( $cb );
129                }
130                $this->registerHook( $hookName, $callback );
131            }
132        }
133    }
134
135    /**
136     * Get a list of subscribers for a hook, given its handler method name.
137     *
138     * @return FullyQualifiedFunctionLikeName[]
139     */
140    public function getHookSubscribers( string $handlerMethodName ): array {
141        $this->loadExtensionJson();
142        $normalizedHookName = self::normalizeHookName( preg_replace( '/^on/', '', $handlerMethodName ) );
143        return $this->hookSubscribers[$normalizedHookName] ?? [];
144    }
145
146    /**
147     * Is a particular function implementing a special hook.
148     *
149     * @note This assumes that any given func will only implement
150     *   one hook
151     * @param FullyQualifiedFunctionLikeName $fqsen The function to check
152     * @return string|null The hook it is implementing or null if no hook
153     */
154    public function isSpecialHookSubscriber( FullyQualifiedFunctionLikeName $fqsen ): ?string {
155        $this->loadExtensionJson();
156        $specialHooks = [
157            '!ParserFunctionHook',
158            '!ParserHook'
159        ];
160
161        // @todo This is probably not the most efficient thing.
162        foreach ( $specialHooks as $hook ) {
163            if ( !isset( $this->hookSubscribers[$hook] ) ) {
164                continue;
165            }
166            if ( in_array( $fqsen, $this->hookSubscribers[$hook], true ) ) {
167                return $hook;
168            }
169        }
170        return null;
171    }
172
173    /**
174     * Normalize hook names so that we can match them against handler names. See `HookContainer::getHookMethodName()`.
175     */
176    private static function normalizeHookName( string $name ): string {
177        return strtr( $name, ':\\-', '___' );
178    }
179}