Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 3.9.x will end* 10 May 2021 (12 months).
  • Bug fixes for security issues in 3.9.x will end* 8 May 2023 (36 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.
   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  }