Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 77
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
MediaWikiHooksHelper
0.00% covered (danger)
0.00%
0 / 77
0.00% covered (danger)
0.00%
0 / 9
1260
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 / 6
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 / 2
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
 getMwParserClassFQSEN
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 getPPFrameClassFQSEN
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace SecurityCheckPlugin;
4
5use Phan\CodeBase;
6use Phan\Config;
7use Phan\Language\FQSEN\FullyQualifiedClassName;
8use Phan\Language\FQSEN\FullyQualifiedFunctionLikeName;
9use Phan\Language\FQSEN\FullyQualifiedFunctionName;
10use Phan\Language\FQSEN\FullyQualifiedMethodName;
11
12/**
13 * This program is free software; you can redistribute it and/or modify
14 * it under the terms of the GNU General Public License as published by
15 * the Free Software Foundation; either version 2 of the License, or
16 * (at your option) any later version.
17 *
18 * This program is distributed in the hope that it will be useful,
19 * but WITHOUT ANY WARRANTY; without even the implied warranty of
20 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
21 * GNU General Public License for more details.
22 *
23 * You should have received a copy of the GNU General Public License along
24 * with this program; if not, write to the Free Software Foundation, Inc.,
25 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
26 */
27
28class MediaWikiHooksHelper {
29    /**
30     * @var bool Whether extension.json/skin.json was already loaded
31     */
32    private $extensionJsonLoaded = false;
33
34    /**
35     * @var FullyQualifiedFunctionLikeName[][] A mapping from hook names to FQSEN that implement it
36     * @phan-var array<string,FullyQualifiedFunctionLikeName[]>
37     */
38    private $hookSubscribers = [];
39
40    private ?FullyQualifiedClassName $parserFQSEN = null;
41    private ?FullyQualifiedClassName $ppFrameFQSEN = null;
42
43    /** @var self|null */
44    private static $instance;
45
46    public static function getInstance(): self {
47        if ( !self::$instance ) {
48            self::$instance = new self;
49        }
50        return self::$instance;
51    }
52
53    /**
54     * Clear the extension.json cache, for testing purpose
55     *
56     * @suppress PhanUnreferencedPublicMethod
57     */
58    public function clearCache(): void {
59        $this->extensionJsonLoaded = false;
60    }
61
62    /**
63     * Add a hook implementation to our list.
64     *
65     * This also handles parser hooks which aren't normal hooks.
66     * Non-normal hooks start their name with a "!"
67     *
68     * @param string $hookName Name of hook
69     * @param FullyQualifiedFunctionLikeName $fqsen The implementing method
70     * @return bool true if already registered, false otherwise
71     */
72    public function registerHook( string $hookName, FullyQualifiedFunctionLikeName $fqsen ): bool {
73        if ( !isset( $this->hookSubscribers[$hookName] ) ) {
74            $this->hookSubscribers[$hookName] = [];
75        }
76        if ( in_array( $fqsen, $this->hookSubscribers[$hookName], true ) ) {
77            return true;
78        }
79        $this->hookSubscribers[$hookName][] = $fqsen;
80        return false;
81    }
82
83    /**
84     * Register hooks from extension.json/skin.json
85     *
86     * Assumes extension.json/skin.json is in project root directory
87     * unless SECURITY_CHECK_EXT_PATH is set
88     */
89    protected function loadExtensionJson(): void {
90        if ( $this->extensionJsonLoaded ) {
91            return;
92        }
93        foreach ( [ 'extension.json', 'skin.json' ] as $filename ) {
94            $envPath = getenv( 'SECURITY_CHECK_EXT_PATH' );
95            if ( $envPath ) {
96                $jsonPath = $envPath . '/' . $filename;
97            } else {
98                $jsonPath = Config::projectPath( $filename );
99            }
100            if ( file_exists( $jsonPath ) ) {
101                $this->readJsonFile( $jsonPath );
102            }
103        }
104        $this->extensionJsonLoaded = true;
105    }
106
107    private function readJsonFile( string $jsonPath ): void {
108        $json = json_decode( file_get_contents( $jsonPath ), true );
109        if ( !is_array( $json ) || !isset( $json['Hooks'] ) || !is_array( $json['Hooks'] ) ) {
110            return;
111        }
112        $namedHandlers = [];
113        foreach ( $json['HookHandlers'] ?? [] as $name => $handler ) {
114            // TODO: This key is not unique if more than one extension is being analyzed. Is that wanted, though?
115            $namedHandlers[$name] = $handler;
116        }
117
118        foreach ( $json['Hooks'] as $hookName => $cbList ) {
119            if ( isset( $cbList["handler"] ) ) {
120                $cbList = $cbList["handler"];
121            }
122            if ( is_string( $cbList ) ) {
123                $cbList = [ $cbList ];
124            }
125
126            foreach ( $cbList as $cb ) {
127                if ( isset( $namedHandlers[$cb] ) ) {
128                    // TODO ObjectFactory not fully handled here. Would deserve some code in a general-purpose
129                    // MediaWiki plugin, see T275742.
130                    if ( isset( $namedHandlers[$cb]['class'] ) ) {
131                        // Like core's HookContainer::run
132                        $normalizedHookName = ucfirst( strtr( $hookName, ':-', '__' ) );
133                        $callbackString = $namedHandlers[$cb]['class'] . "::on$normalizedHookName";
134                    } elseif ( isset( $namedHandlers[$cb]['factory'] ) ) {
135                        // TODO: We'd need a CodeBase to retrieve the factory method and check its return value
136                        continue;
137                    } else {
138                        // @phan-suppress-previous-line PhanPluginDuplicateIfStatements
139                        continue;
140                    }
141                    $callback = FullyQualifiedMethodName::fromFullyQualifiedString( $callbackString );
142                } elseif ( strpos( $cb, '::' ) === false ) {
143                    $callback = FullyQualifiedFunctionName::fromFullyQualifiedString( $cb );
144                } else {
145                    $callback = FullyQualifiedMethodName::fromFullyQualifiedString( $cb );
146                }
147                $this->registerHook( $hookName, $callback );
148            }
149        }
150    }
151
152    /**
153     * Get a list of subscribers for hook
154     *
155     * @param string $hookName Hook in question. Hooks starting with ! are special.
156     * @return FullyQualifiedFunctionLikeName[]
157     */
158    public function getHookSubscribers( string $hookName ): array {
159        $this->loadExtensionJson();
160        return $this->hookSubscribers[$hookName] ?? [];
161    }
162
163    /**
164     * Is a particular function implementing a special hook.
165     *
166     * @note This assumes that any given func will only implement
167     *   one hook
168     * @param FullyQualifiedFunctionLikeName $fqsen The function to check
169     * @return string|null The hook it is implementing or null if no hook
170     */
171    public function isSpecialHookSubscriber( FullyQualifiedFunctionLikeName $fqsen ): ?string {
172        $this->loadExtensionJson();
173        $specialHooks = [
174            '!ParserFunctionHook',
175            '!ParserHook'
176        ];
177
178        // @todo This is probably not the most efficient thing.
179        foreach ( $specialHooks as $hook ) {
180            if ( !isset( $this->hookSubscribers[$hook] ) ) {
181                continue;
182            }
183            if ( in_array( $fqsen, $this->hookSubscribers[$hook], true ) ) {
184                return $hook;
185            }
186        }
187        return null;
188    }
189
190    public function getMwParserClassFQSEN( CodeBase $codeBase ): FullyQualifiedClassName {
191        if ( !$this->parserFQSEN ) {
192            $namespacedFQSEN = FullyQualifiedClassName::fromFullyQualifiedString(
193                '\\MediaWiki\\Parser\\Parser'
194            );
195            if ( $codeBase->hasClassWithFQSEN( $namespacedFQSEN ) ) {
196                $this->parserFQSEN = $namespacedFQSEN;
197            } else {
198                $this->parserFQSEN = FullyQualifiedClassName::fromFullyQualifiedString(
199                    '\\Parser'
200                );
201            }
202        }
203        return $this->parserFQSEN;
204    }
205
206    public function getPPFrameClassFQSEN( CodeBase $codeBase ): FullyQualifiedClassName {
207        if ( !$this->ppFrameFQSEN ) {
208            $namespacedFQSEN = FullyQualifiedClassName::fromFullyQualifiedString(
209                '\\MediaWiki\\Parser\\PPFrame'
210            );
211            if ( $codeBase->hasClassWithFQSEN( $namespacedFQSEN ) ) {
212                $this->ppFrameFQSEN = $namespacedFQSEN;
213            } else {
214                $this->ppFrameFQSEN = FullyQualifiedClassName::fromFullyQualifiedString(
215                    '\\PPFrame'
216                );
217            }
218        }
219        return $this->ppFrameFQSEN;
220    }
221}