Repository: google/dfindexeddb

Issue:
JSON serialization in CLI tools not exporting JS values correctly
When testing on a dataset, properties of JS arrays are not being printed when using json/jsonl output modes (however values were correctly displayed in`repr`)


Discussion:

Patch:
diff --git a/dfindexeddb/indexeddb/chromium/v8.py b/dfindexeddb/indexeddb/chromium/v8.py
index e178410..b892e83 100644
--- a/dfindexeddb/indexeddb/chromium/v8.py
+++ b/dfindexeddb/indexeddb/chromium/v8.py
@@ -13,6 +13,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 """Parsers for v8 javascript serialized objects."""
+from __future__ import annotations
+
 from dataclasses import dataclass
 from datetime import datetime
 import io
@@ -34,15 +36,22 @@ class BufferArrayView:
   flags: int
 
 
-class JSArray(list):
+class JSArray:
   """A parsed Javascript array.
 
-  This is a wrapper around a standard Python list to allow assigning arbitrary
-  properties as is possible in the Javascript equivalent.
+  A Javascript array behaves like a Python list but allows assigning arbitrary
+  properties.  The array is stored in the attribute __array__.
   """
+  def __init__(self):
+    self.__array__ = []
+
+  def Append(self, element: Any):
+    """Appends a new element to the array."""
+    self.__array__.append(element)
 
   def __repr__(self):
-    array_entries = ", ".join([str(entry) for entry in list(self)])
+    array_entries = ", ".join(
+        [str(entry) for entry in list(self.__array__)])
     properties = ", ".join(
         f'{key}: {value}' for key, value in self.properties.items())
     return f'[{array_entries}, {properties}]'
@@ -52,6 +61,11 @@ class JSArray(list):
     """Returns the object properties."""
     return self.__dict__
 
+  def __eq__(self, other: JSArray):
+    return (
+        self.__array__ == other.__array__
+        and self.properties == other.properties)
+
   def __contains__(self, item):
     return item in self.__dict__
 
@@ -194,6 +208,7 @@ class ValueDeserializer:
     if tag and tag == definitions.V8SerializationTag.ARRAY_BUFFER_VIEW:
       self._ConsumeTag(tag)
       result = self._ReadJSArrayBufferView(result)
+      self.objects[self._GetNextId()] = result
     return result
 
   def _ReadObjectInternal(self) -> Tuple[definitions.V8SerializationTag, Any]:
@@ -385,7 +400,10 @@ class ValueDeserializer:
     while self._PeekTag() != end_tag:
       key = self._ReadObject()
       value = self._ReadObject()
-      js_object[key] = value
+      if isinstance(js_object, dict):
+        js_object[key] = value
+      else:
+        js_object.properties[key] = value
       num_properties += 1
     self._ConsumeTag(end_tag)
     return num_properties
@@ -407,7 +425,7 @@ class ValueDeserializer:
     js_array = JSArray()
     _, length = self.decoder.DecodeUint32Varint()
     for _ in range(length):
-      js_array.append(Undefined())
+      js_array.Append(Undefined())
 
     num_properties = self._ReadJSObjectProperties(
         js_array.__dict__, definitions.V8SerializationTag.END_SPARSE_JS_ARRAY)
@@ -440,7 +458,7 @@ class ValueDeserializer:
 
       if self.version < 11 and isinstance(array_object, Undefined):
         continue
-      js_array.append(array_object)
+      js_array.Append(array_object)
 
     num_properties = self._ReadJSObjectProperties(
         js_array.__dict__, definitions.V8SerializationTag.END_DENSE_JS_ARRAY)
@@ -571,6 +589,7 @@ class ValueDeserializer:
     if is_resizable:
       _, max_byte_length = self.decoder.DecodeUint32Varint()
       if byte_length > max_byte_length:
+        self.objects[next_id] = array_buffer
         return array_buffer
     if byte_length:
       _, array_buffer = self.decoder.ReadBytes(byte_length)
diff --git a/dfindexeddb/indexeddb/cli.py b/dfindexeddb/indexeddb/cli.py
index 5246dce..d07bffd 100644
--- a/dfindexeddb/indexeddb/cli.py
+++ b/dfindexeddb/indexeddb/cli.py
@@ -51,6 +51,8 @@ class Encoder(json.JSONEncoder):
       return o.isoformat()
     if isinstance(o, v8.Undefined):
       return "<undefined>"
+    if isinstance(o, v8.JSArray):
+      return o.__dict__
     if isinstance(o, v8.Null):
       return "<null>"
     if isinstance(o, set):
diff --git a/dfindexeddb/indexeddb/safari/webkit.py b/dfindexeddb/indexeddb/safari/webkit.py
index ed8023e..72b50aa 100644
--- a/dfindexeddb/indexeddb/safari/webkit.py
+++ b/dfindexeddb/indexeddb/safari/webkit.py
@@ -82,15 +82,22 @@ class FileList:
   files: List[FileData]
 
 
-class JSArray(list):
-  """A parsed JavaScript array.
+class JSArray:
+  """A parsed Javascript array.
 
-  This is a wrapper around a standard Python list to allow assigning arbitrary
-  properties as is possible in the JavaScript equivalent.
+  A Javascript array behaves like a Python list but allows assigning arbitrary
+  properties.  The array is stored in the attribute __array__.
   """
+  def __init__(self):
+    self.__array__ = []
+
+  def Append(self, element: Any):
+    """Appends a new element to the array."""
+    self.__array__.append(element)
 
   def __repr__(self):
-    array_entries = ", ".join([str(entry) for entry in list(self)])
+    array_entries = ", ".join(
+        [str(entry) for entry in list(self.__array__)])
     properties = ", ".join(
         f'{key}: {value}' for key, value in self.properties.items())
     return f'[{array_entries}, {properties}]'
@@ -100,6 +107,11 @@ class JSArray(list):
     """Returns the object properties."""
     return self.__dict__
 
+  def __eq__(self, other: JSArray):
+    return (
+        self.__array__ == other.__array__
+        and self.properties == other.properties)
+
   def __contains__(self, item):
     return item in self.__dict__
 
@@ -107,24 +119,36 @@ class JSArray(list):
     return self.__dict__[name]
 
 
-class JSSet(set):
-  """A parsed JavaScript set.
+class JSSet:
+  """A parsed Javascript set.
 
-  This is a wrapper around a standard Python set to allow assigning arbitrary
-  properties as is possible in the JavaScript equivalent.
+  A Javascript set behaves like a Python set but allows assigning arbitrary
+  properties.  The array is stored in the attribute __set__.
   """
+  def __init__(self):
+    self.__set__ = set()
+
+  def Add(self, element: Any):
+    """Adds a new element to the set."""
+    self.__set__.add(element)
 
   def __repr__(self):
-    set_entries = ", ".join([str(entry) for entry in list(self)])
+    array_entries = ", ".join(
+        [str(entry) for entry in list(self.__set__)])
     properties = ", ".join(
         f'{key}: {value}' for key, value in self.properties.items())
-    return f'[{set_entries}, {properties}]'
+    return f'[{array_entries}, {properties}]'
 
   @property
   def properties(self) -> Dict[str, Any]:
     """Returns the object properties."""
     return self.__dict__
 
+  def __eq__(self, other: JSSet):
+    return (
+        self.__set__ == other.__set__
+        and self.properties == other.properties)
+
   def __contains__(self, item):
     return item in self.__dict__
 
@@ -294,7 +318,7 @@ class SerializedScriptValueDecoder():
     for _ in range(length):
       _, _ = self.decoder.DecodeUint32()
       _, value = self.DecodeValue()
-      array.append(value)
+      array.Append(value)
 
     offset, terminator_tag = self.decoder.DecodeUint32()
     if terminator_tag != definitions.TerminatorTag:
@@ -480,7 +504,7 @@ class SerializedScriptValueDecoder():
 
     while tag != definitions.SerializationTag.NON_SET_PROPERTIES:
       _, key = self.DecodeValue()
-      js_set.add(key)
+      js_set.Add(key)
       tag = self.PeekSerializationTag()
 
     # consume the NonSetPropertiesTag


Test patch:
diff --git a/tests/dfindexeddb/indexeddb/chromium/v8.py b/tests/dfindexeddb/indexeddb/chromium/v8.py
index dda6ebb..a73fa10 100644
--- a/tests/dfindexeddb/indexeddb/chromium/v8.py
+++ b/tests/dfindexeddb/indexeddb/chromium/v8.py
@@ -163,7 +163,11 @@ class V8Test(unittest.TestCase):
       # > x['propa'] = 123
       # > console.log(v8.serialize(x)).toString('hex'))
       buffer = bytes.fromhex('ff0d4103490249044906220570726f706149f601240103')
-      expected_value = v8.JSArray([1, 2, 3])
+      expected_value = v8.JSArray()
+      expected_value.Append(1)
+      expected_value.Append(2)
+      expected_value.Append(3)
+      expected_value.properties['propa'] = 123
       parsed_value = v8.ValueDeserializer.FromBytes(buffer, None)
       self.assertEqual(parsed_value, expected_value)
       self.assertTrue(hasattr(parsed_value, 'propa'))
@@ -174,7 +178,9 @@ class V8Test(unittest.TestCase):
       # > x.length = 10
       # > console.log(v8.serialize(x)).toString('hex'))
       buffer = bytes.fromhex('ff0d610a40000a')
-      expected_value = v8.JSArray([v8.Undefined() for i in range(10)])
+      expected_value = v8.JSArray()
+      for _ in range(10):
+        expected_value.Append(v8.Undefined())
       parsed_value = v8.ValueDeserializer.FromBytes(buffer, None)
       self.assertEqual(parsed_value, expected_value)
 
diff --git a/tests/dfindexeddb/indexeddb/safari/record.py b/tests/dfindexeddb/indexeddb/safari/record.py
index 2a780d9..aca9883 100644
--- a/tests/dfindexeddb/indexeddb/safari/record.py
+++ b/tests/dfindexeddb/indexeddb/safari/record.py
@@ -241,9 +241,13 @@ class SafariIndexedDBTest(unittest.TestCase):
 
   def test_set_record(self):
     """Tests for an IndexedDB record with a set value."""
+    expected_set = webkit.JSSet()
+    for i in range(1, 4):
+      expected_set.Add(i)
+
     expected_record = record.IndexedDBRecord(
         key=27,
-        value={'id': 27, 'value': {1, 2, 3}},
+        value={'id': 27, 'value': expected_set},
         object_store_id=1,
         object_store_name='test store a',
         database_name='IndexedDB test',
@@ -289,6 +293,13 @@ class SafariIndexedDBTest(unittest.TestCase):
 
   def test_mixed_object_record(self):
     """Tests for an IndexedDB record with mixed values within an object."""
+    expected_test_array = webkit.JSArray()
+    for value in [123, 456, 'abc', 'def']:
+      expected_test_array.Append(value)
+    expected_set = webkit.JSSet()
+    for i in range(1, 4):
+      expected_set.Add(i)
+
     expected_record = record.IndexedDBRecord(
         key=1,
         value={
@@ -305,13 +316,13 @@ class SafariIndexedDBTest(unittest.TestCase):
             'test_boolean_false_object': False,
             'test_bigint': 12300000000000001048576,
             'test_date': datetime.datetime(2023, 2, 12, 23, 20, 30, 456000),
-            'test_set': set([1, 2, 3]),
+            'test_set': expected_set,
             'test_map': {
                 'a': 1,
                 'b': 2,
                 'c': 3},
             'test_regexp': webkit.RegExp('\\w+', ''),
-            'test_array': [123, 456, 'abc', 'def'],
+            'test_array': expected_test_array,
             'test_object': {
                 'name': {
                     'first': 'Jane',
@@ -460,10 +471,13 @@ class SafariIndexedDBTest(unittest.TestCase):
 
   def test_array_key_record(self):
     """Tests for an IndexedDB record with an array in the key."""
+    expected_test_array = webkit.JSArray()
+    for value in [1, 2, 3]:
+      expected_test_array.Append(value)
     expected_record = record.IndexedDBRecord(
-        key=[1, 2, 3],
+        key=[1.0, 2.0, 3.0],
         value={
-            'id': [1, 2, 3],
+            'id': expected_test_array,
             'value': {}},
         object_store_id=1,
         object_store_name='test store a',
diff --git a/tests/dfindexeddb/indexeddb/safari/webkit.py b/tests/dfindexeddb/indexeddb/safari/webkit.py
index a15a24e..4ae51d6 100644
--- a/tests/dfindexeddb/indexeddb/safari/webkit.py
+++ b/tests/dfindexeddb/indexeddb/safari/webkit.py
@@ -211,7 +211,10 @@ class WebkitTest(unittest.TestCase):
         '0F00000002020000806964051B000000'
         '0500008076616C75651D070502000000'
         '050300000020FFFFFFFFFFFFFFFF')
-    expected_value = {'id': 27, 'value': {1, 2, 3}}
+    expected_set = webkit.JSSet()
+    for i in range(1, 4):
+      expected_set.Add(i)
+    expected_value = {'id': 27, 'value': expected_set}
     parsed_value = webkit.SerializedScriptValueDecoder.FromBytes(
         value_bytes)
     self.assertEqual(parsed_value, expected_value)
@@ -268,6 +271,14 @@ class WebkitTest(unittest.TestCase):
         '6A65637402040000806E616D650205000080666972737410040000804A616E65'
         '040000806C6173741003000080446F65FFFFFFFF030000806167650515000000'
         'FFFFFFFFFFFFFFFF')
+
+    expected_test_array = webkit.JSArray()
+    for value in [123, 456, 'abc', 'def']:
+      expected_test_array.Append(value)
+    expected_set = webkit.JSSet()
+    for i in range(1, 4):
+      expected_set.Add(i)
+
     expected_value = {
        'id': 1,
         'test_undef': webkit.Undefined(),
@@ -282,13 +293,13 @@ class WebkitTest(unittest.TestCase):
         'test_boolean_false_object': False,
         'test_bigint': 12300000000000001048576,
         'test_date': datetime.datetime(2023, 2, 12, 23, 20, 30, 456000),
-        'test_set': set([1, 2, 3]),
+        'test_set': expected_set,
         'test_map': {
             'a': 1,
             'b': 2,
             'c': 3},
         'test_regexp': webkit.RegExp('\\w+', ''),
-        'test_array': [123, 456, 'abc', 'def'],
+        'test_array': expected_test_array,
         'test_object': {
             'name': {
                 'first': 'Jane',