Bug 1250191 - Add a way to serialize JSON canonically. r=MattN
authorMathieu Leplatre <mathieu@mozilla.com>
Mon, 18 Apr 2016 12:02:42 +0200
changeset 331512 4046917f854bbb806ebeaccde35fdb34674b9423
parent 331511 e8ef4670ee16f419b1037b715f0d28978825439e
child 331513 05a9daa3368cb8e32c7393fcb5ff7179023c6c50
push id6048
push userkmoir@mozilla.com
push dateMon, 06 Jun 2016 19:02:08 +0000
treeherdermozilla-beta@46d72a56c57d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMattN
bugs1250191
milestone48.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1250191 - Add a way to serialize JSON canonically. r=MattN Based on Alexis Metaireau's patch. MozReview-Commit-ID: 3H3SKWy5GgM
toolkit/modules/CanonicalJSON.jsm
toolkit/modules/moz.build
toolkit/modules/tests/xpcshell/test_CanonicalJSON.js
toolkit/modules/tests/xpcshell/xpcshell.ini
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/CanonicalJSON.jsm
@@ -0,0 +1,62 @@
+/* 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. Note that this method will cause an infinite loop if
+   * cycles exist in the source (bug 1265357).
+   *
+   * @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'