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