Bug 1250191 - Add a way to serialize JSON canonically draft
authorMathieu Leplatre <mathieu@mozilla.com>
Mon, 18 Apr 2016 12:02:42 +0200
changeset 352702 e09660d0e031e72093546df684229aae616b5a72
parent 349384 e847cfcb315f511f4928b03fd47dcf57aad05e1e
child 518700 fac78a3ece9f604e2d944d7ae3ecb8d545e92d28
push id15747
push usermleplatre@mozilla.com
push dateMon, 18 Apr 2016 13:20:00 +0000
bugs1250191
milestone48.0a1
Bug 1250191 - Add a way to serialize JSON canonically Import Alexis Metaireau's patch. MozReview-Commit-ID: 3H3SKWy5GgM *** Bug 1250191 - Address rnewman review, r=rnewman MozReview-Commit-ID: GfWytXLRj9X *** Bug 1250191 - Load 3rd party lazily, r=MattN MozReview-Commit-ID: 8s8U0hCyM8C *** Bug 1250191 - Fix indentation in tests,r=MattN MozReview-Commit-ID: 9sXlff5qzaK *** Bug 1250191 - Move to toolkit and rename to CanonicalJSON.jsm,r=MattN MozReview-Commit-ID: 1il9xE7qbDF
services/common/moz.build
toolkit/modules/CanonicalJSON.jsm
toolkit/modules/moz.build
toolkit/modules/tests/xpcshell/test_CanonicalJSON.js
toolkit/modules/tests/xpcshell/xpcshell.ini
--- a/services/common/moz.build
+++ b/services/common/moz.build
@@ -39,9 +39,8 @@ if CONFIG['MOZ_WIDGET_TOOLKIT'] != 'andr
 TESTING_JS_MODULES.services.common += [
     'modules-testing/logging.js',
     'modules-testing/utils.js',
 ]
 
 JS_PREFERENCE_FILES += [
     'services-common.js',
 ]
-
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/CanonicalJSON.jsm
@@ -0,0 +1,61 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+this.EXPORTED_SYMBOLS = ["CanonicalJSON"];
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "jsesc",
+                                  "resource://gre/modules/third_party/jsesc/jsesc.js");
+
+this.CanonicalJSON = {
+  /**
+   * Return the canonical JSON form of the passed source, sorting all the object
+   * keys recursively.
+   *
+   * @param source
+   *        The elements to be serialized.
+   *
+   * The output will have all unicode chars escaped with the unicode codepoint
+   * as lowercase hexadecimal.
+   *
+   * @usage
+   *        CanonicalJSON.stringify(listOfRecords);
+   **/
+  stringify: function stringify(source) {
+    if (Array.isArray(source)) {
+      const jsonArray = source.map(x => typeof x === "undefined" ? null : x);
+      return `[${jsonArray.map(stringify).join(",")}]`;
+    }
+
+    if (typeof source === "number") {
+      if (source === 0) {
+        return (Object.is(source, -0)) ? "-0" : "0";
+      }
+    }
+
+    // Leverage jsesc library, mainly for unicode escaping.
+    const toJSON = (input) => jsesc(input, {lowercaseHex: true, json: true});
+
+    if (typeof source !== "object" || source === null) {
+      return toJSON(source);
+    }
+
+    // Dealing with objects, ordering keys.
+    const sortedKeys = Object.keys(source).sort();
+    const lastIndex = sortedKeys.length - 1;
+    return sortedKeys.reduce((serial, key, index) => {
+      const value = source[key];
+      // JSON.stringify drops keys with an undefined value.
+      if (typeof value === "undefined") {
+        return serial;
+      }
+      const jsonValue = value && value.toJSON ? value.toJSON() : value;
+      const suffix = index !== lastIndex ? "," : "";
+      const escapedKey = toJSON(key);
+      return serial + `${escapedKey}:${stringify(jsonValue)}${suffix}`;
+    }, "{") + "}";
+  },
+};
--- a/toolkit/modules/moz.build
+++ b/toolkit/modules/moz.build
@@ -22,16 +22,17 @@ EXTRA_JS_MODULES += [
     'addons/WebNavigationFrames.jsm',
     'addons/WebRequest.jsm',
     'addons/WebRequestCommon.jsm',
     'addons/WebRequestContent.js',
     'AsyncPrefs.jsm',
     'Battery.jsm',
     'BinarySearch.jsm',
     'BrowserUtils.jsm',
+    'CanonicalJSON.jsm',
     'CertUtils.jsm',
     'CharsetMenu.jsm',
     'ClientID.jsm',
     'Console.jsm',
     'debug.js',
     'DeferredTask.jsm',
     'Deprecated.jsm',
     'FileUtils.jsm',
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/tests/xpcshell/test_CanonicalJSON.js
@@ -0,0 +1,146 @@
+const { CanonicalJSON } = Components.utils.import("resource://gre/modules/CanonicalJSON.jsm");
+
+function stringRepresentation(obj) {
+  const clone = JSON.parse(JSON.stringify(obj));
+  return JSON.stringify(clone);
+}
+
+add_task(function* test_canonicalJSON_should_preserve_array_order() {
+  const input = ['one', 'two', 'three'];
+  // No sorting should be done on arrays.
+  do_check_eq(CanonicalJSON.stringify(input), '["one","two","three"]');
+});
+
+add_task(function* test_canonicalJSON_orders_object_keys() {
+  const input = [{
+    b: ['two', 'three'],
+    a: ['zero', 'one']
+  }];
+  do_check_eq(
+    CanonicalJSON.stringify(input),
+    '[{"a":["zero","one"],"b":["two","three"]}]'
+  );
+});
+
+add_task(function* test_canonicalJSON_orders_nested_object_keys() {
+  const input = [{
+    b: {d: 'd', c: 'c'},
+    a: {b: 'b', a: 'a'}
+  }];
+  do_check_eq(
+    CanonicalJSON.stringify(input),
+    '[{"a":{"a":"a","b":"b"},"b":{"c":"c","d":"d"}}]'
+  );
+});
+
+add_task(function* test_canonicalJSON_escapes_unicode_values() {
+  do_check_eq(
+    CanonicalJSON.stringify([{key: '✓'}]),
+    '[{"key":"\\u2713"}]'
+  );
+  // Unicode codepoints should be output in lowercase.
+  do_check_eq(
+    CanonicalJSON.stringify([{key: 'é'}]),
+    '[{"key":"\\u00e9"}]'
+  );
+});
+
+add_task(function* test_canonicalJSON_escapes_unicode_object_keys() {
+  do_check_eq(
+    CanonicalJSON.stringify([{'é': 'check'}]),
+    '[{"\\u00e9":"check"}]'
+  );
+});
+
+
+add_task(function* test_canonicalJSON_does_not_alter_input() {
+  const records = [
+    {'foo': 'bar', 'last_modified': '12345', 'id': '1'},
+    {'bar': 'baz', 'last_modified': '45678', 'id': '2'}
+  ];
+  const serializedJSON = JSON.stringify(records);
+  CanonicalJSON.stringify(records);
+  do_check_eq(JSON.stringify(records), serializedJSON);
+});
+
+
+add_task(function* test_canonicalJSON_preserves_data() {
+  const records = [
+    {'foo': 'bar', 'last_modified': '12345', 'id': '1'},
+    {'bar': 'baz', 'last_modified': '45678', 'id': '2'},
+  ]
+  const serialized = CanonicalJSON.stringify(records);
+  const expected = '[{"foo":"bar","id":"1","last_modified":"12345"},' +
+                   '{"bar":"baz","id":"2","last_modified":"45678"}]';
+  do_check_eq(CanonicalJSON.stringify(records), expected);
+});
+
+add_task(function* test_canonicalJSON_does_not_add_space_separators() {
+  const records = [
+    {'foo': 'bar', 'last_modified': '12345', 'id': '1'},
+    {'bar': 'baz', 'last_modified': '45678', 'id': '2'},
+  ]
+  const serialized = CanonicalJSON.stringify(records);
+  do_check_false(serialized.includes(" "));
+});
+
+add_task(function* test_canonicalJSON_serializes_empty_object() {
+  do_check_eq(CanonicalJSON.stringify({}), "{}");
+});
+
+add_task(function* test_canonicalJSON_serializes_empty_array() {
+  do_check_eq(CanonicalJSON.stringify([]), "[]");
+});
+
+add_task(function* test_canonicalJSON_serializes_NaN() {
+  do_check_eq(CanonicalJSON.stringify(NaN), "null");
+});
+
+add_task(function* test_canonicalJSON_serializes_inf() {
+  // This isn't part of the JSON standard.
+  do_check_eq(CanonicalJSON.stringify(Infinity), "null");
+});
+
+
+add_task(function* test_canonicalJSON_serializes_empty_string() {
+  do_check_eq(CanonicalJSON.stringify(""), '""');
+});
+
+add_task(function* test_canonicalJSON_escapes_backslashes() {
+  do_check_eq(CanonicalJSON.stringify("This\\and this"), '"This\\\\and this"');
+});
+
+add_task(function* test_canonicalJSON_handles_signed_zeros() {
+  // do_check_eq doesn't support comparison of -0 and 0 properly.
+  do_check_true(CanonicalJSON.stringify(-0) === '-0');
+  do_check_true(CanonicalJSON.stringify(0) === '0');
+});
+
+
+add_task(function* test_canonicalJSON_with_deeply_nested_dicts() {
+  const records = [{
+    'a': {
+      'b': 'b',
+      'a': 'a',
+      'c': {
+        'b': 'b',
+        'a': 'a',
+        'c': ['b', 'a', 'c'],
+        'd': {'b': 'b', 'a': 'a'},
+        'id': '1',
+        'e': 1,
+        'f': [2, 3, 1],
+        'g': {2: 2, 3: 3, 1: {
+          'b': 'b', 'a': 'a', 'c': 'c'}}}},
+    'id': '1'}]
+  const expected =
+    '[{"a":{"a":"a","b":"b","c":{"a":"a","b":"b","c":["b","a","c"],' +
+    '"d":{"a":"a","b":"b"},"e":1,"f":[2,3,1],"g":{' +
+    '"1":{"a":"a","b":"b","c":"c"},"2":2,"3":3},"id":"1"}},"id":"1"}]';
+
+  do_check_eq(CanonicalJSON.stringify(records), expected);
+});
+
+function run_test() {
+  run_next_test();
+}
--- a/toolkit/modules/tests/xpcshell/xpcshell.ini
+++ b/toolkit/modules/tests/xpcshell/xpcshell.ini
@@ -6,16 +6,17 @@ skip-if = toolkit == 'gonk'
 support-files =
   propertyLists/bug710259_propertyListBinary.plist
   propertyLists/bug710259_propertyListXML.plist
   chromeappsstore.sqlite
   zips/zen.zip
 
 [test_BinarySearch.js]
 skip-if = toolkit == 'android'
+[test_CanonicalJSON.js]
 [test_client_id.js]
 skip-if = toolkit == 'android'
 [test_DeferredTask.js]
 skip-if = toolkit == 'android'
 [test_FileUtils.js]
 skip-if = toolkit == 'android'
 [test_GMPInstallManager.js]
 skip-if = toolkit == 'android'