1 <?php 2 // This file is part of Moodle - http://moodle.org/ 3 // 4 // Moodle is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 // 9 // Moodle is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU General Public License for more details. 13 // 14 // You should have received a copy of the GNU General Public License 15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>. 16 17 /** 18 * Wrapper for CFPropertyList to handle low level iteration. 19 * 20 * @package quizaccess_seb 21 * @author Andrew Madden <andrewmadden@catalyst-au.net> 22 * @copyright 2019 Catalyst IT 23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 */ 25 26 namespace quizaccess_seb; 27 28 use CFPropertyList\CFArray; 29 use CFPropertyList\CFBoolean; 30 use CFPropertyList\CFData; 31 use CFPropertyList\CFDate; 32 use CFPropertyList\CFDictionary; 33 use CFPropertyList\CFNumber; 34 use CFPropertyList\CFPropertyList; 35 use CFPropertyList\CFString; 36 use CFPropertyList\CFType; 37 use \Collator; 38 use \DateTime; 39 40 41 /** 42 * Wrapper for CFPropertyList to handle low level iteration. 43 * 44 * @copyright 2020 Catalyst IT 45 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 46 */ 47 class property_list { 48 49 /** A random 4 character unicode string to replace backslashes during json_encode. */ 50 private const BACKSLASH_SUBSTITUTE = "ؼҷҍԴ"; 51 52 /** @var CFPropertyList $cfpropertylist */ 53 private $cfpropertylist; 54 55 /** 56 * property_list constructor. 57 * 58 * @param string $xml A Plist XML string. 59 */ 60 public function __construct(string $xml = '') { 61 $this->cfpropertylist = new CFPropertyList(); 62 63 if (empty($xml)) { 64 // If xml not provided, create a blank PList with root dictionary set up. 65 $this->cfpropertylist->add(new CFDictionary([])); 66 } else { 67 // Parse the XML into a PList object. 68 $this->cfpropertylist->parse($xml, CFPropertyList::FORMAT_XML); 69 } 70 } 71 72 /** 73 * Add a new element to the root dictionary element. 74 * 75 * @param string $key Key to assign to new element. 76 * @param CFType $element The new element. May be a collection such as an array. 77 */ 78 public function add_element_to_root(string $key, CFType $element) { 79 // Get the PList's root dictionary and add new element. 80 $this->cfpropertylist->getValue()->add($key, $element); 81 } 82 83 /** 84 * Get value of element identified by key. 85 * 86 * @param string $key Key of element. 87 * @return mixed Value of element found, or null if none found. 88 */ 89 public function get_element_value(string $key) { 90 $result = null; 91 $this->plist_map( function($elvalue, $elkey, $parent) use ($key, &$result) { 92 // Convert date to iso 8601 if date object. 93 if ($key === $elkey) { 94 $result = $elvalue->getValue(); 95 } 96 }, $this->cfpropertylist->getValue()); 97 98 if (is_array($result)) { 99 // Turn CFType elements in PHP elements. 100 $result = $this->array_serialize_cftypes($result); 101 } 102 return $result; 103 } 104 105 /** 106 * Update the value of any element with matching key. 107 * 108 * Only allow string, number and boolean elements to be updated. 109 * 110 * @param string $key Key of element to update. 111 * @param mixed $value Value to update element with. 112 */ 113 public function update_element_value(string $key, $value) { 114 if (is_array($value)) { 115 throw new \invalid_parameter_exception('Use update_element_array to update a collection.'); 116 } 117 $this->plist_map( function($elvalue, $elkey, $parent) use ($key, $value) { 118 // Set new value. 119 if ($key === $elkey) { 120 $element = $parent->get($elkey); 121 // Limit update to boolean and strings types, and check value matches expected type. 122 if (($element instanceof CFString && is_string($value)) 123 || ($element instanceof CFNumber && is_numeric($value)) 124 || ($element instanceof CFBoolean && is_bool($value))) { 125 $element->setValue($value); 126 } else { 127 throw new \invalid_parameter_exception( 128 'Only string, number and boolean elements can be updated, or value type does not match element type: ' 129 . get_class($element)); 130 } 131 } 132 }, $this->cfpropertylist->getValue()); 133 } 134 135 /** 136 * Update the array of any dict or array element with matching key. 137 * 138 * Will replace array. 139 * 140 * @param string $key Key of element to update. 141 * @param array $value Array to update element with. 142 */ 143 public function update_element_array(string $key, array $value) { 144 // Validate new array. 145 foreach ($value as $element) { 146 // If any element is not a CFType instance, then throw exception. 147 if (!($element instanceof CFType)) { 148 throw new \invalid_parameter_exception('New array must only contain CFType objects.'); 149 } 150 } 151 $this->plist_map( function($elvalue, $elkey, $parent) use ($key, $value) { 152 if ($key === $elkey) { 153 $element = $parent->get($elkey); 154 // Replace existing element with new element and array but same key. 155 if ($element instanceof CFDictionary) { 156 $parent->del($elkey); 157 $parent->add($elkey, new CFDictionary($value)); 158 } else if ($element instanceof CFArray) { 159 $parent->del($elkey); 160 $parent->add($elkey, new CFArray($value)); 161 } 162 } 163 }, $this->cfpropertylist->getValue()); 164 } 165 166 /** 167 * Delete any element with a matching key. 168 * 169 * @param string $key Key of element to delete. 170 */ 171 public function delete_element(string $key) { 172 $this->plist_map( function($elvalue, $elkey, $parent) use ($key) { 173 // Convert date to iso 8601 if date object. 174 if ($key === $elkey) { 175 $parent->del($key); 176 } 177 }, $this->cfpropertylist->getValue()); 178 } 179 180 /** 181 * Helper function to either set or update a CF type value to the plist. 182 * 183 * @param string $key 184 * @param CFType $input 185 */ 186 public function set_or_update_value(string $key, CFType $input) { 187 $value = $this->get_element_value($key); 188 if (empty($value)) { 189 $this->add_element_to_root($key, $input); 190 } else { 191 $this->update_element_value($key, $input->getValue()); 192 } 193 } 194 195 /** 196 * Convert the PList to XML. 197 * 198 * @return string XML ready for creating an XML file. 199 */ 200 public function to_xml() : string { 201 return $this->cfpropertylist->toXML(); 202 } 203 204 /** 205 * Return a JSON representation of the PList. The JSON is constructed to be used to generate a SEB Config Key. 206 * 207 * See the developer documention for SEB for more information on the requirements on generating a SEB Config Key. 208 * https://safeexambrowser.org/developer/seb-config-key.html 209 * 210 * 1. Don't add any whitespace or line formatting to the SEB-JSON string. 211 * 2. Don't add character escaping (also backshlashes "\" as found in URL filter rules should not be escaped). 212 * 3. All <dict> elements from the plist XML must be ordered (alphabetically sorted) by their key names. Use a 213 * recursive method to apply ordering also to nested dictionaries contained in the root-level dictionary and in 214 * arrays. Use non-localized (culture invariant), non-ASCII value based case insensitive ordering. For example the 215 * key <key>allowWlan</key> comes before <key>allowWLAN</key>. Cocoa/Obj-C and .NET/C# usually use this case 216 * insensitive ordering as default, but PHP for example doesn't. 217 * 4. Remove empty <dict> elements (key/value). Current versions of SEB clients should anyways not generate empty 218 * dictionaries, but this was possible with outdated versions. If config files have been generated that time, such 219 * elements might still be around. 220 * 5. All string elements must be UTF8 encoded. 221 * 6. Base16 strings should use lower-case a-f characters, even though this isn't relevant in the current 222 * implementation of the Config Key calculation. 223 * 7. <data> plist XML elements must be converted to Base64 strings. 224 * 8. <date> plist XML elements must be converted to ISO 8601 formatted strings. 225 * 226 * @return string A json encoded string. 227 */ 228 public function to_json() : string { 229 // Create a clone of the PList, so main list isn't mutated. 230 $jsonplist = new CFPropertyList(); 231 $jsonplist->parse($this->cfpropertylist->toXML(), CFPropertyList::FORMAT_XML); 232 233 // Pass root dict to recursively convert dates to ISO 8601 format, encode strings to UTF-8, 234 // lock data to Base 64 encoding and remove empty dictionaries. 235 $this->prepare_plist_for_json_encoding($jsonplist->getValue()); 236 237 // Serialize PList to array. 238 $plistarray = $jsonplist->toArray(); 239 240 // Sort array alphabetically by key using case insensitive, natural sorting. See point 3 for more information. 241 $plistarray = $this->array_sort($plistarray); 242 243 // Encode in JSON with following rules from SEB docs. 244 // 1. Don't add any whitespace or line formatting to the SEB-JSON string. 245 // 2. Don't add unicode or slash escaping. 246 $json = json_encode($plistarray, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); 247 248 // There is no way to prevent json_encode from escaping backslashes. We replace each backslash with a unique string 249 // prior to encoding in prepare_plist_for_json_encoding(). We can then replace the substitute with a single backslash. 250 $json = str_replace(self::BACKSLASH_SUBSTITUTE, "\\", $json); 251 return $json; 252 } 253 254 /** 255 * Recursively convert PList date values from unix to iso 8601 format, and ensure strings are UTF 8 encoded. 256 * 257 * This will mutate the PList. 258 */ 259 260 /** 261 * Recursively convert PList date values from unix to iso 8601 format, and ensure strings are UTF 8 encoded. 262 * 263 * This will mutate the PList. 264 * @param \Iterator $root The root element of the PList. Must be a dictionary or array. 265 */ 266 private function prepare_plist_for_json_encoding($root) { 267 $this->plist_map( function($value, $key, $parent) { 268 // Convert date to ISO 8601 if date object. 269 if ($value instanceof CFDate) { 270 $date = DateTime::createFromFormat('U', $value->getValue()); 271 $date->setTimezone(new \DateTimeZone('UTC')); // Zulu timezone a.k.a. UTC+00. 272 $isodate = $date->format('c'); 273 $value->setValue($isodate); 274 } 275 // Make sure strings are UTF 8 encoded. 276 if ($value instanceof CFString) { 277 // As literal backslashes will be lost during encoding, we must replace them with a unique substitute to be 278 // reverted after JSON encoding. 279 $string = str_replace("\\", self::BACKSLASH_SUBSTITUTE, $value->getValue()); 280 $value->setValue(mb_convert_encoding($string, 'UTF-8')); 281 } 282 // Data should remain base 64 encoded, so convert to base encoded string for export. Otherwise 283 // CFData will decode the data when serialized. 284 if ($value instanceof CFData) { 285 $data = trim($value->getCodedValue()); 286 $parent->del($key); 287 $parent->add($key, new CFString($data)); 288 } 289 // Empty dictionaries should be removed. 290 if ($value instanceof CFDictionary && empty($value->getValue())) { 291 $parent->del($key); 292 } 293 }, $root); 294 295 } 296 297 /** 298 * Iterate through the PList elements, and call the callback on each. 299 * 300 * @param callable $callback A callback function called for every element. 301 * @param \Iterator $root The root element of the PList. Must be a dictionary or array. 302 * @param bool $recursive Whether the function should traverse dicts and arrays recursively. 303 */ 304 private function plist_map(callable $callback, \Iterator $root, bool $recursive = true) { 305 $root->rewind(); 306 while ($root->valid()) { 307 $value = $root->current(); 308 $key = $root->key(); 309 310 // Recursively traverse all dicts and arrays if flag is true. 311 if ($recursive && $value instanceof \Iterator) { 312 $this->plist_map($callback, $value); 313 } 314 315 // Callback function called for every element. 316 $callback($value, $key, $root); 317 318 $root->next(); 319 } 320 } 321 322 /** 323 * Recursively sort array alphabetically by key. 324 * 325 * @link https://safeexambrowser.org/developer/seb-config-key.html 326 * 327 * @param array $array Top level array to process. 328 * @return array Processed array. 329 */ 330 private function array_sort(array $array) { 331 foreach ($array as $key => $value) { 332 if (is_array($value)) { 333 $array[$key] = $this->array_sort($array[$key]); 334 } 335 } 336 // Sort assoc array. From SEB docs: 337 // 338 // All <dict> elements from the plist XML must be ordered (alphabetically sorted) by their key names. Use 339 // a recursive method to apply ordering also to nested dictionaries contained in the root-level dictionary 340 // and in arrays. Use non-localized (culture invariant), non-ASCII value based case insensitive ordering. 341 // For example the key <key>allowWlan</key> comes before <key>allowWLAN</key>. Cocoa/Obj-C and .NET/C# 342 // usually use this case insensitive ordering as default, but PHP for example doesn't. 343 if ($this->is_associative_array($array)) { 344 // Note this is a pragmatic solution as none of the native PHP *sort method appear to sort strings that 345 // differ only in case (e.g. ["allowWLAN", "allowWlan"] is expected to have the lower version first). 346 $keys = array_keys($array); 347 (new Collator('root'))->asort($keys); // Use Unicode Collation Algorithm (UCA). 348 $original = $array; 349 $array = []; 350 foreach ($keys as $key) { 351 $array[$key] = $original[$key]; 352 } 353 } 354 355 return $array; 356 } 357 358 /** 359 * Recursively remove empty arrays. 360 * 361 * @param array $array Top level array to process. 362 * @return array Processed array. 363 */ 364 private function array_remove_empty_arrays(array $array) { 365 foreach ($array as $key => $value) { 366 if (is_array($value)) { 367 $array[$key] = $this->array_remove_empty_arrays($array[$key]); 368 } 369 370 // Remove empty arrays. 371 if (is_array($array[$key]) && empty($array[$key])) { 372 unset($array[$key]); 373 } 374 } 375 376 return $array; 377 } 378 379 /** 380 * If an array contains CFType objects, wrap array in a CFDictionary to allow recursive serialization of data 381 * into a standard PHP array. 382 * 383 * @param array $array Array containing CFType objects. 384 * @return array Standard PHP array. 385 */ 386 private function array_serialize_cftypes(array $array) : array { 387 $array = new CFDictionary($array); // Convert back to CFDictionary so serialization is recursive. 388 return $array->toArray(); // Serialize. 389 } 390 391 /** 392 * Check if an array is associative or sequential. 393 * 394 * @param array $array Array to check. 395 * @return bool False if not associative. 396 */ 397 private function is_associative_array(array $array) { 398 if (empty($array)) { 399 return false; 400 } 401 // Check that all keys are not sequential integers starting from 0 (Which is what PHP arrays have behind the scenes.) 402 return array_keys($array) !== range(0, count($array) - 1); 403 } 404 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body