Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.91% covered (success)
90.91%
70 / 77
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
MediaWikiHooksHelper
90.91% covered (success)
90.91%
70 / 77
55.56% covered (warning)
55.56%
5 / 9
35.92
0.00% covered (danger)
0.00%
0 / 1
 getInstance
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 clearCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 registerHook
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 loadExtensionJson
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 readJsonFile
83.33% covered (warning)
83.33%
20 / 24
0.00% covered (danger)
0.00%
0 / 1
13.78
 getHookSubscribers
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isSpecialHookSubscriber
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 getMwParserClassFQSEN
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getPPFrameClassFQSEN
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
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    /**
47     * @return self
48     */
49    public static function getInstance(): self {
50        if ( !self::$instance ) {
51            self::$instance = new self;
52        }
53        return self::$instance;
54    }
55
56    /**
57     * Clear the extension.json cache, for testing purpose
58     *
59     * @suppress PhanUnreferencedPublicMethod
60     */
61    public function clearCache(): void {
62        $this->extensionJsonLoaded = false;
63    }
64
65    /**
66     * Add a hook implementation to our list.
67     *
68     * This also handles parser hooks which aren't normal hooks.
69     * Non-normal hooks start their name with a "!"
70     *
71     * @param string $hookName Name of hook
72     * @param FullyQualifiedFunctionLikeName $fqsen The implementing method
73     * @return bool true if already registered, false otherwise
74     */
75    public function registerHook( string $hookName, FullyQualifiedFunctionLikeName $fqsen ): bool {
76        if ( !isset( $this->hookSubscribers[$hookName] ) ) {
77            $this->hookSubscribers[$hookName] = [];
78        }
79        if ( in_array( $fqsen, $this->hookSubscribers[$hookName], true ) ) {
80            return true;
81        }
82        $this->hookSubscribers[$hookName][] = $fqsen;
83        return false;
84    }
85
86    /**
87     * Register hooks from extension.json/skin.json
88     *
89     * Assumes extension.json/skin.json is in project root directory
90     * unless SECURITY_CHECK_EXT_PATH is set
91     */
92    protected function loadExtensionJson(): void {
93        if ( $this->extensionJsonLoaded ) {
94            return;
95        }
96        foreach ( [ 'extension.json', 'skin.json' ] as $filename ) {
97            $envPath = getenv( 'SECURITY_CHECK_EXT_PATH' );
98            if ( $envPath ) {
99                $jsonPath = $envPath . '/' . $filename;
100            } else {
101                $jsonPath = Config::projectPath( $filename );
102            }
103            if ( file_exists( $jsonPath ) ) {
104                $this->readJsonFile( $jsonPath );
105            }
106        }
107        $this->extensionJsonLoaded = true;
108    }
109
110    /**
111     * @param string $jsonPath
112     */
113    private function readJsonFile( string $jsonPath ): void {
114        $json = json_decode( file_get_contents( $jsonPath ), true );
115        if ( !is_array( $json ) || !isset( $json['Hooks'] ) || !is_array( $json['Hooks'] ) ) {
116            return;
117        }
118        $namedHandlers = [];
119        foreach ( $json['HookHandlers'] ?? [] as $name => $handler ) {
120            // TODO: This key is not unique if more than one extension is being analyzed. Is that wanted, though?
121            $namedHandlers[$name] = $handler;
122        }
123
124        foreach ( $json['Hooks'] as $hookName => $cbList ) {
125            if ( isset( $cbList["handler"] ) ) {
126                $cbList = $cbList["handler"];
127            }
128            if ( is_string( $cbList ) ) {
129                $cbList = [ $cbList ];
130            }
131
132            foreach ( $cbList as $cb ) {
133                if ( isset( $namedHandlers[$cb] ) ) {
134                    // TODO ObjectFactory not fully handled here. Would deserve some code in a general-purpose
135                    // MediaWiki plugin, see T275742.
136                    if ( isset( $namedHandlers[$cb]['class'] ) ) {
137                        // Like core's HookContainer::run
138                        $normalizedHookName = ucfirst( strtr( $hookName, ':-', '__' ) );
139                        $callbackString = $namedHandlers[$cb]['class'] . "::on$normalizedHookName";
140                    } elseif ( isset( $namedHandlers[$cb]['factory'] ) ) {
141                        // TODO: We'd need a CodeBase to retrieve the factory method and check its return value
142                        continue;
143                    } else {
144                        // @phan-suppress-previous-line PhanPluginDuplicateIfStatements
145                        continue;
146                    }
147                    $callback = FullyQualifiedMethodName::fromFullyQualifiedString( $callbackString );
148                } elseif ( strpos( $cb, '::' ) === false ) {
149                    $callback = FullyQualifiedFunctionName::fromFullyQualifiedString( $cb );
150                } else {
151                    $callback = FullyQualifiedMethodName::fromFullyQualifiedString( $cb );
152                }
153                $this->registerHook( $hookName, $callback );
154            }
155        }
156    }
157
158    /**
159     * Get a list of subscribers for hook
160     *
161     * @param string $hookName Hook in question. Hooks starting with ! are special.
162     * @return FullyQualifiedFunctionLikeName[]
163     */
164    public function getHookSubscribers( string $hookName ): array {
165        $this->loadExtensionJson();
166        return $this->hookSubscribers[$hookName] ?? [];
167    }
168
169    /**
170     * Is a particular function implementing a special hook.
171     *
172     * @note This assumes that any given func will only implement
173     *   one hook
174     * @param FullyQualifiedFunctionLikeName $fqsen The function to check
175     * @return string|null The hook it is implementing or null if no hook
176     */
177    public function isSpecialHookSubscriber( FullyQualifiedFunctionLikeName $fqsen ): ?string {
178        $this->loadExtensionJson();
179        $specialHooks = [
180            '!ParserFunctionHook',
181            '!ParserHook'
182        ];
183
184        // @todo This is probably not the most efficient thing.
185        foreach ( $specialHooks as $hook ) {
186            if ( !isset( $this->hookSubscribers[$hook] ) ) {
187                continue;
188            }
189            if ( in_array( $fqsen, $this->hookSubscribers[$hook], true ) ) {
190                return $hook;
191            }
192        }
193        return null;
194    }
195
196    public function getMwParserClassFQSEN( CodeBase $codeBase ): FullyQualifiedClassName {
197        if ( !$this->parserFQSEN ) {
198            $namespacedFQSEN = FullyQualifiedClassName::fromFullyQualifiedString(
199                '\\MediaWiki\\Parser\\Parser'
200            );
201            if ( $codeBase->hasClassWithFQSEN( $namespacedFQSEN ) ) {
202                $this->parserFQSEN = $namespacedFQSEN;
203            } else {
204                $this->parserFQSEN = FullyQualifiedClassName::fromFullyQualifiedString(
205                    '\\Parser'
206                );
207            }
208        }
209        return $this->parserFQSEN;
210    }
211
212    public function getPPFrameClassFQSEN( CodeBase $codeBase ): FullyQualifiedClassName {
213        if ( !$this->ppFrameFQSEN ) {
214            $namespacedFQSEN = FullyQualifiedClassName::fromFullyQualifiedString(
215                '\\MediaWiki\\Parser\\PPFrame'
216            );
217            if ( $codeBase->hasClassWithFQSEN( $namespacedFQSEN ) ) {
218                $this->ppFrameFQSEN = $namespacedFQSEN;
219            } else {
220                $this->ppFrameFQSEN = FullyQualifiedClassName::fromFullyQualifiedString(
221                    '\\PPFrame'
222                );
223            }
224        }
225        return $this->ppFrameFQSEN;
226    }
227}