Search moodle.org's
Developer Documentation

  • Bug fixes for general core bugs in 3.11.x will end 9 May 2022 (12 months).
  • Bug fixes for security issues in 3.11.x will end 14 November 2022 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is 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  }