Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 144
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
XMLStore
0.00% covered (danger)
0.00%
0 / 144
0.00% covered (danger)
0.00%
0 / 8
2862
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 readFile
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 doTopLevel
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
72
 doElection
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
156
 readEntity
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
272
 addParentIds
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 readStringElement
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
72
 callbackValidVotes
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace MediaWiki\Extension\SecurePoll\Store;
4
5use MediaWiki\Status\Status;
6use XMLReader;
7
8/**
9 * Storage class for an XML file store. Election configuration data is cached,
10 * and vote data can be loaded into a tallier on demand.
11 */
12class XMLStore extends MemoryStore {
13    /** @var XMLReader|null */
14    public $xmlReader;
15    /** @var string */
16    public $fileName;
17    /** @var callable|null */
18    public $voteCallback;
19    /** @var int|null */
20    public $voteElectionId;
21    /** @var Status|null */
22    public $voteCallbackStatus;
23
24    /** @var string[][] Valid entity info keys by entity type. */
25    private static $entityInfoKeys = [
26        'election' => [
27            'id',
28            'title',
29            'ballot',
30            'tally',
31            'primaryLang',
32            'startDate',
33            'endDate',
34            'auth'
35        ],
36        'question' => [
37            'id',
38            'election'
39        ],
40        'option' => [
41            'id',
42            'election'
43        ],
44    ];
45
46    /** @var string[][] The type of each entity child and its corresponding (plural) info element */
47    private static $childTypes = [
48        'election' => [ 'question' => 'questions' ],
49        'question' => [ 'option' => 'options' ],
50        'option' => []
51    ];
52
53    /** @var string[] All entity types */
54    private static $entityTypes = [
55        'election',
56        'question',
57        'option'
58    ];
59
60    /**
61     * Constructor. Note that readFile() must be called before any information
62     * can be accessed. Context::newFromXmlFile() is a shortcut
63     * method for this.
64     * @param string $fileName
65     */
66    public function __construct( $fileName ) {
67        $this->fileName = $fileName;
68    }
69
70    /**
71     * Read the file and return boolean success.
72     * @return bool
73     */
74    public function readFile() {
75        $this->xmlReader = new XMLReader;
76        $xr = $this->xmlReader;
77        $fileName = realpath( $this->fileName );
78        $uri = 'file://' . str_replace( '%2F', '/', rawurlencode( $fileName ) );
79        $xr->open( $uri );
80        $xr->setParserProperty( XMLReader::SUBST_ENTITIES, true );
81        $success = $this->doTopLevel();
82        $xr->close();
83        $this->xmlReader = null;
84
85        return $success;
86    }
87
88    /**
89     * Do the top-level document element, and return success.
90     * @return bool
91     */
92    public function doTopLevel() {
93        $xr = $this->xmlReader;
94
95        # Check document element
96        while ( $xr->read() && $xr->nodeType !== XMLReader::ELEMENT ) {
97        }
98
99        if ( $xr->name != 'SecurePoll' ) {
100            wfDebug( __METHOD__ . ": invalid document element\n" );
101
102            return false;
103        }
104
105        while ( $xr->read() ) {
106            if ( $xr->nodeType !== XMLReader::ELEMENT ) {
107                continue;
108            }
109            if ( $xr->name !== 'election' ) {
110                continue;
111            }
112            if ( !$this->doElection() ) {
113                return false;
114            }
115        }
116
117        return true;
118    }
119
120    /**
121     * Read an <election> element and position the cursor past the end of it.
122     * Return success.
123     * @return bool
124     */
125    public function doElection() {
126        $xr = $this->xmlReader;
127        if ( $xr->isEmptyElement ) {
128            wfDebug( __METHOD__ . ": unexpected empty element\n" );
129
130            return false;
131        }
132        $xr->read();
133        $electionInfo = false;
134        while ( $xr->nodeType !== XMLReader::NONE ) {
135            if ( $xr->nodeType === XMLReader::END_ELEMENT ) {
136                # Finished
137                return true;
138            }
139            if ( $xr->nodeType !== XMLReader::ELEMENT ) {
140                # Skip comments, intervening text, etc.
141                $xr->read();
142                continue;
143            }
144            if ( $xr->name === 'configuration' ) {
145                # Load configuration
146                $electionInfo = $this->readEntity( 'election' );
147                if ( $electionInfo === false ) {
148                    return false;
149                }
150                continue;
151            }
152
153            if ( $xr->name === 'vote' ) {
154                # Notify tallier of vote record if requested
155                if ( $this->voteCallback && $electionInfo && $electionInfo['id'] == $this->voteElectionId ) {
156                    $record = $this->readStringElement();
157                    $status = call_user_func( $this->voteCallback, $this, $record );
158                    if ( !$status->isOK() ) {
159                        $this->voteCallbackStatus = $status;
160
161                        return false;
162                    }
163                } else {
164                    $xr->next();
165                }
166                continue;
167            }
168
169            wfDebug( __METHOD__ . ": ignoring unrecognized element <{$xr->name}>\n" );
170            $xr->next();
171        }
172        wfDebug( __METHOD__ . ": unexpected end of stream\n" );
173
174        return false;
175    }
176
177    /**
178     * Read an entity configuration element: <configuration>, <question> or
179     * <option>, and position the cursor past the end of it.
180     *
181     * This function operates recursively to read child elements. It returns
182     * the info array for the entity.
183     * @param string $entityType
184     * @return false|array
185     */
186    public function readEntity( $entityType ) {
187        $xr = $this->xmlReader;
188        $info = [ 'type' => $entityType ];
189        $messages = [];
190        $properties = [];
191        if ( $xr->isEmptyElement ) {
192            wfDebug( __METHOD__ . ": unexpected empty element\n" );
193            $xr->read();
194
195            return false;
196        }
197        $xr->read();
198
199        while ( true ) {
200            if ( $xr->nodeType === XMLReader::NONE ) {
201                wfDebug( __METHOD__ . ": unexpected end of stream\n" );
202
203                return false;
204            }
205            if ( $xr->nodeType === XMLReader::END_ELEMENT ) {
206                # End of entity
207                $xr->read();
208                break;
209            }
210            if ( $xr->nodeType !== XMLReader::ELEMENT ) {
211                # Intervening text, comments, etc.
212                $xr->read();
213                continue;
214            }
215            if ( $xr->name === 'message' ) {
216                $name = $xr->getAttribute( 'name' );
217                $lang = $xr->getAttribute( 'lang' );
218                $value = $this->readStringElement();
219                // @phan-suppress-next-line PhanTypeMismatchDimAssignment
220                $messages[$lang][$name] = $value;
221                continue;
222            }
223            if ( $xr->name == 'property' ) {
224                $name = $xr->getAttribute( 'name' );
225                $value = $this->readStringElement();
226                // @phan-suppress-next-line PhanTypeMismatchDimAssignment
227                $properties[$name] = $value;
228                continue;
229            }
230
231            # Info elements
232            if ( in_array( $xr->name, self::$entityInfoKeys[$entityType] ) ) {
233                $name = $xr->name;
234                $value = $this->readStringElement();
235                # Fix date format
236                if ( $name == 'startDate' || $name == 'endDate' ) {
237                    $value = wfTimestamp( TS_MW, $value );
238                }
239                $info[$name] = $value;
240                continue;
241            }
242
243            # Child elements
244            if ( isset( self::$childTypes[$entityType][$xr->name] ) ) {
245                $infoKey = self::$childTypes[$entityType][$xr->name];
246                $childInfo = $this->readEntity( $xr->name );
247                if ( !$childInfo ) {
248                    return false;
249                }
250                $info[$infoKey][] = $childInfo;
251                continue;
252            }
253
254            wfDebug( __METHOD__ . ": ignoring unrecognized element <{$xr->name}>\n" );
255            $xr->next();
256        }
257
258        if ( !isset( $info['id'] ) ) {
259            wfDebug( __METHOD__ . ": missing id element in <$entityType>\n" );
260
261            return false;
262        }
263
264        # This has to be done after the element is fully parsed, or you
265        # have to require 'id' to be above any children in the XML doc.
266        $this->addParentIds( $info, $info['type'], $info['id'] );
267
268        $id = $info['id'];
269        if ( isset( $info['title'] ) ) {
270            $this->idsByName[$info['title']] = $id;
271        }
272        $this->entityInfo[$id] = $info;
273        foreach ( $messages as $lang => $values ) {
274            $this->messages[$lang][$id] = $values;
275        }
276        $this->properties[$id] = $properties;
277
278        return $info;
279    }
280
281    /**
282     * Propagate parent ids to child elements
283     * @param array &$info
284     * @param string $key
285     * @param int $id
286     */
287    public function addParentIds( &$info, $key, $id ) {
288        foreach ( self::$childTypes[$info['type']] as $childType ) {
289            if ( isset( $info[$childType] ) ) {
290                foreach ( $info[$childType] as &$child ) {
291                    $child[$key] = $id;
292                    # Recurse
293                    $this->addParentIds( $child, $key, $id );
294                }
295            }
296        }
297    }
298
299    /**
300     * When the cursor is positioned on an element node, this reads the entire
301     * element and returns the contents as a string. On return, the cursor is
302     * positioned past the end of the element.
303     * @return string
304     */
305    public function readStringElement() {
306        $xr = $this->xmlReader;
307        if ( $xr->isEmptyElement ) {
308            $xr->read();
309
310            return '';
311        }
312        $s = '';
313        $level = 1;
314        while ( $xr->read() && $level ) {
315            if ( $xr->nodeType == XMLReader::TEXT ) {
316                $s .= $xr->value;
317                continue;
318            }
319            if ( $xr->nodeType == XMLReader::ELEMENT && !$xr->isEmptyElement ) {
320                $level++;
321                continue;
322            }
323            if ( $xr->nodeType == XMLReader::END_ELEMENT ) {
324                $level--;
325                continue;
326            }
327        }
328
329        return $s;
330    }
331
332    public function callbackValidVotes( $electionId, $callback, $voterId = null ) {
333        $this->voteCallback = $callback;
334        $this->voteElectionId = $electionId;
335        $this->voteCallbackStatus = Status::newGood();
336        $success = $this->readFile();
337        $this->voteCallback = $this->voteElectionId = null;
338        if ( !$this->voteCallbackStatus->isOK() ) {
339            return $this->voteCallbackStatus;
340        } elseif ( $success ) {
341            return Status::newGood();
342        } else {
343            return Status::newFatal( 'securepoll-dump-file-corrupt' );
344        }
345    }
346}