Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
MediaWikiJsonSchemaValidator
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 3
110
0.00% covered (danger)
0.00%
0 / 1
 validateOnSave
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 validate
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 formatSchemaErrors
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Helper for validating on-wiki JSON config pages.
4 *
5 * Validates a specific JSON message page against a bundled JSON schema file when
6 * it is saved.
7 *
8 * @file
9 * @ingroup Extensions
10 * @copyright 2025 VisualEditor Team and others; see AUTHORS.txt
11 * @license MIT
12 */
13
14namespace MediaWiki\Extension\VisualEditor;
15
16use JsonSchema\Validator;
17use MediaWiki\Content\TextContent;
18use MediaWiki\Revision\RenderedRevision;
19use MediaWiki\Status\Status;
20use Wikimedia\Message\MessageValue;
21
22class MediaWikiJsonSchemaValidator {
23
24    /**
25     * Validate against a JSON schema.
26     *
27     * Intended to be called from a MultiContentSave hook handler, returning its
28     * result directly. Saves of unrelated pages, or non-text content, are ignored.
29     *
30     * @param RenderedRevision $renderedRevision The revision being saved
31     * @param Status $status Status to populate with a fatal error on failure
32     * @param string $pageDBkey Lower-cased DB key of the MediaWiki: page to validate,
33     *   e.g. 'editcheck-config.json'
34     * @param string $schemaPath Absolute path to the JSON schema file to validate against
35     * @param string|null $definition Name of an entry within the schema's "definitions" to
36     *   validate against instead of the whole schema, e.g. 'textMatchRuleInline'. This is
37     *   used to validate files that hold just one part of the config (such as a TextMatch
38     *   rule imported via the "import" key). When null, the full schema is used.
39     * @return bool|null False to abort the save when validation fails, null otherwise
40     */
41    public static function validateOnSave(
42        RenderedRevision $renderedRevision,
43        Status $status,
44        string $pageDBkey,
45        string $schemaPath,
46        ?string $definition = null
47    ): ?bool {
48        $page = $renderedRevision->getRevision()->getPageAsLinkTarget();
49        if (
50            $page->getNamespace() !== NS_MEDIAWIKI ||
51            strtolower( $page->getDBkey() ) !== $pageDBkey
52        ) {
53            return null;
54        }
55        $content = $renderedRevision->getRevision()->getMainContentRaw();
56        if ( !( $content instanceof TextContent ) ) {
57            // If for some reason the content isn't text, we can't validate it
58            return null;
59        }
60
61        return self::validate( $content, $status, $schemaPath, $definition );
62    }
63
64    /**
65     * Validate specific JSON content against a JSON schema.
66     *
67     * @param TextContent $content The content being saved
68     * @param Status $status See validateOnSave()
69     * @param string $schemaPath See validateOnSave()
70     * @param string|null $definition See validateOnSave()
71     * @return bool|null See validateOnSave()
72     */
73    public static function validate(
74        TextContent $content,
75        Status $status,
76        string $schemaPath,
77        ?string $definition = null
78    ): ?bool {
79        $config = json_decode( $content->getText() );
80        if ( json_last_error() !== JSON_ERROR_NONE ) {
81            $status->fatal( 'visualeditor-config-save-invalid-schema', json_last_error_msg() );
82            return false;
83        }
84
85        $schema = json_decode( file_get_contents( $schemaPath ) );
86        if ( $definition !== null ) {
87            // Validate against a single named definition, while keeping the schema's other
88            // definitions in the document so internal $ref pointers still resolve.
89            $schema = (object)[
90                '$ref' => '#/definitions/' . $definition,
91                'definitions' => $schema->definitions,
92            ];
93        }
94
95        $validator = new Validator();
96        $validator->validate( $config, $schema );
97        if ( $validator->isValid() ) {
98            return null;
99        }
100
101        $status->fatal(
102            // MessageValue ensures our formatted error is treated as wikitext.
103            new MessageValue(
104                'visualeditor-config-save-invalid-schema',
105                [ self::formatSchemaErrors( $validator->getErrors() ) ]
106            )
107        );
108        return false;
109    }
110
111    /**
112     * Format JSON schema validation errors as a wikitext definition list.
113     *
114     * @param array[] $errors Errors as returned by Validator::getErrors()
115     * @return string Wikitext
116     */
117    private static function formatSchemaErrors( array $errors ): string {
118        $lastProperty = null;
119        return implode( '', array_map(
120            static function ( array $error ) use ( &$lastProperty ): string {
121                $prefix = '';
122                if ( $lastProperty !== $error['property'] ) {
123                    $prefix = "\n;<nowiki>" . $error['property'] . "</nowiki>";
124                    $lastProperty = $error['property'];
125                }
126                return $prefix . "\n:" . $error['message'];
127            },
128            $errors
129        ) );
130    }
131}