dfindexeddb 20251109__py3-none-any.whl → 20260205__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,11 +14,14 @@
14
14
  # limitations under the License.
15
15
  """Definitions for IndexedDB."""
16
16
  from enum import Enum, IntEnum, IntFlag
17
+ import textwrap
17
18
 
18
19
  REQUIRES_PROCESSING_SSV_PSEUDO_VERSION = 0x11
19
20
  REPLACE_WITH_BLOB = 0x01
20
21
  COMPRESSED_WITH_SNAPPY = 0x02
21
22
 
23
+ SENTINEL = 0x00
24
+
22
25
 
23
26
  class DatabaseMetaDataKeyType(IntEnum):
24
27
  """Database Metadata key types."""
@@ -72,6 +75,16 @@ class IDBKeyType(IntEnum):
72
75
  BINARY = 6
73
76
 
74
77
 
78
+ class OrderedIDBKeyType(IntEnum):
79
+ """Ordered IndexedDB key types."""
80
+
81
+ NUMBER = 0x10
82
+ DATE = 0x20
83
+ STRING = 0x30
84
+ BINARY = 0x40
85
+ ARRAY = 0x50
86
+
87
+
75
88
  class IndexMetaDataKeyType(IntEnum):
76
89
  """IndexedDB metadata key types."""
77
90
 
@@ -398,3 +411,82 @@ class SerializedImageOrientation(IntEnum):
398
411
  RIGHT_BOTTOM = 6
399
412
  LEFT_BOTTOM = 7
400
413
  LAST = LEFT_BOTTOM
414
+
415
+
416
+ class DatabaseCompressionType(IntEnum):
417
+ """Database Compression Types."""
418
+
419
+ UNCOMPRESSED = 0
420
+ ZSTD = 1
421
+ SNAPPY = 2
422
+
423
+
424
+ SQL_RECORDS_QUERY_BASE = textwrap.dedent(
425
+ """
426
+ SELECT
427
+ row_id,
428
+ object_store_id,
429
+ compression_type,
430
+ key,
431
+ value,
432
+ EXISTS (
433
+ SELECT 1
434
+ FROM blob_references
435
+ WHERE record_row_id = records.row_id
436
+ ) AS has_blobs
437
+ FROM records"""
438
+ ).strip()
439
+
440
+ SQL_RECORDS_QUERY = SQL_RECORDS_QUERY_BASE
441
+
442
+ SQL_RECORDS_BY_ID_QUERY = f"{SQL_RECORDS_QUERY_BASE} WHERE object_store_id = ?"
443
+
444
+ SQL_RECORDS_BY_NAME_QUERY = textwrap.dedent(
445
+ f"""
446
+ {SQL_RECORDS_QUERY_BASE}
447
+ JOIN object_stores ON records.object_store_id = object_stores.id
448
+ WHERE object_stores.name = ?"""
449
+ ).strip()
450
+
451
+ SQL_OBJECT_STORES_QUERY = textwrap.dedent(
452
+ """
453
+ SELECT
454
+ id,
455
+ name,
456
+ key_path,
457
+ auto_increment,
458
+ key_generator_current_number
459
+ FROM object_stores"""
460
+ ).strip()
461
+
462
+ SQL_BLOB_DATA_QUERY = textwrap.dedent(
463
+ """
464
+ SELECT
465
+ b.row_id,
466
+ b.object_type,
467
+ b.mime_type,
468
+ b.size_bytes,
469
+ b.file_name,
470
+ 0 AS chunk_index,
471
+ b.bytes
472
+ FROM blobs b
473
+ JOIN blob_references r ON b.row_id = r.blob_row_id
474
+ WHERE r.record_row_id = ?
475
+
476
+ UNION ALL
477
+
478
+ SELECT
479
+ c.blob_row_id AS row_id,
480
+ b.object_type,
481
+ b.mime_type,
482
+ b.size_bytes,
483
+ b.file_name,
484
+ c.chunk_index,
485
+ c.bytes
486
+ FROM overflow_blob_chunks c
487
+ JOIN blobs b ON c.blob_row_id = b.row_id
488
+ JOIN blob_references r ON b.row_id = r.blob_row_id
489
+ WHERE r.record_row_id = ?
490
+
491
+ ORDER BY row_id, chunk_index"""
492
+ ).strip()
@@ -24,6 +24,7 @@ from datetime import datetime
24
24
  from typing import (
25
25
  Any,
26
26
  BinaryIO,
27
+ ClassVar,
27
28
  Generator,
28
29
  Optional,
29
30
  Tuple,
@@ -36,7 +37,7 @@ from dfindexeddb import errors
36
37
  from dfindexeddb.indexeddb.chromium import blink, definitions
37
38
  from dfindexeddb.leveldb import record, utils
38
39
 
39
- T = TypeVar("T")
40
+ T = TypeVar("T", bound="BaseIndexedDBKey")
40
41
 
41
42
 
42
43
  @dataclass(frozen=True)
@@ -74,10 +75,9 @@ class KeyPrefix(utils.FromDecoderMixin):
74
75
  """
75
76
  offset, raw_prefix = decoder.ReadBytes(1)
76
77
 
77
- database_id_length = (raw_prefix[0] & 0xE0 >> 5) + 1
78
- object_store_id_length = (raw_prefix[0] & 0x1C >> 2) + 1
78
+ database_id_length = ((raw_prefix[0] & 0xE0) >> 5) + 1
79
+ object_store_id_length = ((raw_prefix[0] & 0x1C) >> 2) + 1
79
80
  index_id_length = (raw_prefix[0] & 0x03) + 1
80
-
81
81
  if database_id_length < 1 or database_id_length > 8:
82
82
  raise errors.ParserError("Invalid database ID length")
83
83
 
@@ -90,7 +90,6 @@ class KeyPrefix(utils.FromDecoderMixin):
90
90
  _, database_id = decoder.DecodeInt(database_id_length, signed=False)
91
91
  _, object_store_id = decoder.DecodeInt(object_store_id_length, signed=False)
92
92
  _, index_id = decoder.DecodeInt(index_id_length, signed=False)
93
-
94
93
  return cls(
95
94
  offset=base_offset + offset,
96
95
  database_id=database_id,
@@ -217,6 +216,95 @@ class IDBKey(utils.FromDecoderMixin):
217
216
  return cls(base_offset + offset, key_type, value)
218
217
 
219
218
 
219
+ @dataclass(frozen=True)
220
+ class SortableIDBKey(utils.FromDecoderMixin):
221
+ """A sortable IDBKey.
222
+
223
+ Attributes:
224
+ offset: the offset of the IDBKey.
225
+ type: the type of the IDBKey.
226
+ value: the value of the IDBKey.
227
+ """
228
+
229
+ offset: int = field(compare=False)
230
+ type: definitions.IDBKeyType
231
+ value: Union[list[Any], bytes, str, float, datetime, None]
232
+
233
+ _MAXIMUM_DEPTH = 2000
234
+
235
+ @classmethod
236
+ def FromDecoder(
237
+ cls,
238
+ decoder: utils.LevelDBDecoder,
239
+ base_offset: int = 0,
240
+ ) -> SortableIDBKey:
241
+ """Decodes a sortable IDBKey from the current position of a LevelDBDecoder.
242
+
243
+ Args:
244
+ decoder: the LevelDBDecoder.
245
+ base_offset: the base offset.
246
+
247
+ Returns:
248
+ The decoded SortableIDBKey.
249
+
250
+ Raises:
251
+ ParserError: on invalid key type or truncated data.
252
+ RecursionError: if maximum depth encountered.
253
+ """
254
+
255
+ def RecursiveParse(depth: int) -> Tuple[int, definitions.IDBKeyType, Any]:
256
+ """Recursively parses sortable IDBKeys.
257
+
258
+ Args:
259
+ depth: the current recursion depth.
260
+
261
+ Returns:
262
+ A tuple of the offset, the key type and the key value (where the value
263
+ can be bytes, str, float, datetime or a list of these types).
264
+
265
+ Raises:
266
+ ParserError: on invalid IDBKeyType or invalid array length during
267
+ parsing.
268
+ RecursionError: if maximum depth encountered during parsing.
269
+ """
270
+ if depth == cls._MAXIMUM_DEPTH:
271
+ raise RecursionError("Maximum recursion depth encountered")
272
+
273
+ value: Any = None
274
+ offset, ordered_type = decoder.DecodeUint8()
275
+ if ordered_type == definitions.OrderedIDBKeyType.NUMBER:
276
+ _, value = decoder.DecodeSortableDouble()
277
+ return offset, definitions.IDBKeyType.NUMBER, value
278
+ if ordered_type == definitions.OrderedIDBKeyType.DATE:
279
+ _, raw_date = decoder.DecodeSortableDouble()
280
+ return (
281
+ offset,
282
+ definitions.IDBKeyType.DATE,
283
+ datetime.utcfromtimestamp(raw_date / 1000.0),
284
+ )
285
+ if ordered_type == definitions.OrderedIDBKeyType.STRING:
286
+ _, value = decoder.DecodeSortableString()
287
+ return offset, definitions.IDBKeyType.STRING, value
288
+ if ordered_type == definitions.OrderedIDBKeyType.BINARY:
289
+ _, value = decoder.DecodeSortableBinary()
290
+ return offset, definitions.IDBKeyType.BINARY, value
291
+ if ordered_type == definitions.OrderedIDBKeyType.ARRAY:
292
+ value = []
293
+ while True:
294
+ _, next_byte = decoder.PeekBytes(1)
295
+ if next_byte[0] == definitions.SENTINEL:
296
+ decoder.ReadBytes(1)
297
+ break
298
+ _, _, item = RecursiveParse(depth + 1)
299
+ value.append(item)
300
+ return offset, definitions.IDBKeyType.ARRAY, value
301
+
302
+ raise errors.ParserError(f"Unknown ordered key type {ordered_type}")
303
+
304
+ offset, key_type, value = RecursiveParse(0)
305
+ return cls(base_offset + offset, key_type, value)
306
+
307
+
220
308
  @dataclass
221
309
  class IDBKeyPath(utils.FromDecoderMixin):
222
310
  """An IDBKeyPath.
@@ -383,6 +471,9 @@ class BaseIndexedDBKey:
383
471
  Args:
384
472
  decoder: the stream decoder
385
473
 
474
+ Returns:
475
+ The decoded value.
476
+
386
477
  Raises:
387
478
  NotImplementedError.
388
479
  """
@@ -392,7 +483,7 @@ class BaseIndexedDBKey:
392
483
  """Parses the value from raw bytes.
393
484
 
394
485
  Args:
395
- value_data: the raw value bytes.
486
+ value_data: the raw value data.
396
487
 
397
488
  Returns:
398
489
  The parsed value.
@@ -408,13 +499,15 @@ class BaseIndexedDBKey:
408
499
  decoder: utils.LevelDBDecoder,
409
500
  key_prefix: KeyPrefix,
410
501
  base_offset: int = 0,
411
- ) -> T: # pylint: disable=unused-variable
412
- """Decodes the remaining key data from the current decoder position.
502
+ ) -> T:
503
+ """Parses the key from the current position of the LevelDBDecoder.
504
+
505
+ To be implemented by subclasses.
413
506
 
414
507
  Args:
415
508
  decoder: the stream decoder.
416
- key_prefix: the decoded key_prefix.
417
- base_offset: the base offset.
509
+ key_prefix: the key prefix.
510
+ base_offset: the base offset of the key.
418
511
 
419
512
  Returns:
420
513
  The decoded key.
@@ -437,7 +530,7 @@ class BaseIndexedDBKey:
437
530
  """
438
531
  decoder = utils.LevelDBDecoder(stream)
439
532
  key_prefix = KeyPrefix.FromDecoder(decoder, base_offset=base_offset)
440
- return cls.FromDecoder( # type: ignore[no-any-return,attr-defined]
533
+ return cls.FromDecoder(
441
534
  decoder=decoder, key_prefix=key_prefix, base_offset=base_offset
442
535
  )
443
536
 
@@ -453,9 +546,7 @@ class BaseIndexedDBKey:
453
546
  The decoded key.
454
547
  """
455
548
  stream = io.BytesIO(raw_data)
456
- return cls.FromStream( # type: ignore[no-any-return,attr-defined]
457
- stream=stream, base_offset=base_offset
458
- )
549
+ return cls.FromStream(stream=stream, base_offset=base_offset)
459
550
 
460
551
 
461
552
  @dataclass
@@ -716,7 +807,11 @@ class GlobalMetaDataKey(BaseIndexedDBKey):
716
807
  """A GlobalMetaDataKey parser."""
717
808
 
718
809
  # pylint: disable=line-too-long
719
- METADATA_TYPE_TO_CLASS = {
810
+ METADATA_TYPE_TO_CLASS: ClassVar[
811
+ dict[ # pylint: disable=invalid-name
812
+ definitions.GlobalMetadataKeyType, type[BaseIndexedDBKey]
813
+ ]
814
+ ] = {
720
815
  definitions.GlobalMetadataKeyType.ACTIVE_BLOB_JOURNAL: ActiveBlobJournalKey,
721
816
  definitions.GlobalMetadataKeyType.DATA_VERSION: DataVersionKey,
722
817
  definitions.GlobalMetadataKeyType.DATABASE_FREE_LIST: DatabaseFreeListKey,
@@ -743,18 +838,7 @@ class GlobalMetaDataKey(BaseIndexedDBKey):
743
838
  decoder: utils.LevelDBDecoder,
744
839
  key_prefix: KeyPrefix,
745
840
  base_offset: int = 0,
746
- ) -> Union[
747
- ActiveBlobJournalKey,
748
- DataVersionKey,
749
- DatabaseFreeListKey,
750
- DatabaseNameKey,
751
- EarliestSweepKey,
752
- EarliestCompactionTimeKey,
753
- MaxDatabaseIdKey,
754
- RecoveryBlobJournalKey,
755
- SchemaVersionKey,
756
- ScopesPrefixKey,
757
- ]:
841
+ ) -> BaseIndexedDBKey:
758
842
  """Decodes the global metadata key.
759
843
 
760
844
  Raises:
@@ -766,9 +850,7 @@ class GlobalMetaDataKey(BaseIndexedDBKey):
766
850
  key_class = cls.METADATA_TYPE_TO_CLASS.get(metadata_type)
767
851
  if not key_class:
768
852
  raise errors.ParserError("Unknown metadata key type")
769
- return key_class.FromDecoder( # type: ignore[attr-defined,no-any-return]
770
- decoder, key_prefix, base_offset
771
- )
853
+ return key_class.FromDecoder(decoder, key_prefix, base_offset)
772
854
 
773
855
 
774
856
  @dataclass
@@ -1284,7 +1366,11 @@ class IndexedDbKey(BaseIndexedDBKey):
1284
1366
  A factory class for parsing IndexedDB keys.
1285
1367
  """
1286
1368
 
1287
- METADATA_TYPE_TO_CLASS = {
1369
+ METADATA_TYPE_TO_CLASS: ClassVar[
1370
+ dict[ # pylint: disable=invalid-name
1371
+ definitions.KeyPrefixType, Optional[type[BaseIndexedDBKey]]
1372
+ ]
1373
+ ] = {
1288
1374
  definitions.KeyPrefixType.BLOB_ENTRY: BlobEntryKey,
1289
1375
  definitions.KeyPrefixType.DATABASE_METADATA: DatabaseMetaDataKey,
1290
1376
  definitions.KeyPrefixType.EXISTS_ENTRY: ExistsEntryKey,
@@ -1307,14 +1393,7 @@ class IndexedDbKey(BaseIndexedDBKey):
1307
1393
  decoder: utils.LevelDBDecoder,
1308
1394
  key_prefix: KeyPrefix,
1309
1395
  base_offset: int = 0,
1310
- ) -> Union[
1311
- BlobEntryKey,
1312
- DatabaseMetaDataKey,
1313
- ExistsEntryKey,
1314
- GlobalMetaDataKey,
1315
- IndexDataKey,
1316
- ObjectStoreDataKey,
1317
- ]:
1396
+ ) -> BaseIndexedDBKey:
1318
1397
  """Decodes the IndexedDB key."""
1319
1398
  key_type = key_prefix.GetKeyPrefixType()
1320
1399
  key_class = cls.METADATA_TYPE_TO_CLASS.get(key_type)
@@ -1322,7 +1401,7 @@ class IndexedDbKey(BaseIndexedDBKey):
1322
1401
  raise errors.ParserError("Unknown KeyPrefixType")
1323
1402
  return key_class.FromDecoder(
1324
1403
  decoder=decoder,
1325
- key_prefix=key_prefix, # type: ignore[return-value]
1404
+ key_prefix=key_prefix,
1326
1405
  base_offset=base_offset,
1327
1406
  )
1328
1407
 
@@ -1481,8 +1560,8 @@ class IndexedDBExternalObject(utils.FromDecoderMixin):
1481
1560
 
1482
1561
 
1483
1562
  @dataclass
1484
- class IndexedDBRecord:
1485
- """An IndexedDB Record.
1563
+ class ChromiumIndexedDBRecord:
1564
+ """An IndexedDB Record parsed from LevelDB.
1486
1565
 
1487
1566
  Attributes:
1488
1567
  path: the source file path
@@ -1498,7 +1577,7 @@ class IndexedDBRecord:
1498
1577
  object_store_id: the object store ID.
1499
1578
  database_name: the name of the database, if available.
1500
1579
  object_store_name: the name of the object store, if available.
1501
- blob: the blob contents, if available.
1580
+ blobs: the list of blob paths and contents or error message, if available.
1502
1581
  raw_key: the raw key, if available.
1503
1582
  raw_value: the raw value, if available.
1504
1583
  """
@@ -1515,15 +1594,19 @@ class IndexedDBRecord:
1515
1594
  object_store_id: int
1516
1595
  database_name: Optional[str] = None
1517
1596
  object_store_name: Optional[str] = None
1518
- blob: Optional[bytes] = None
1597
+ blobs: Optional[list[tuple[str, Optional[Any]]]] = None
1519
1598
  raw_key: Optional[bytes] = None
1520
1599
  raw_value: Optional[bytes] = None
1521
1600
 
1522
1601
  @classmethod
1523
1602
  def FromLevelDBRecord(
1524
- cls, db_record: record.LevelDBRecord, parse_value: bool = True
1525
- ) -> IndexedDBRecord:
1526
- """Returns an IndexedDBRecord from a ParsedInternalKey."""
1603
+ cls,
1604
+ db_record: record.LevelDBRecord,
1605
+ parse_value: bool = True,
1606
+ include_raw_data: bool = False,
1607
+ blob_folder_reader: Optional[BlobFolderReader] = None,
1608
+ ) -> ChromiumIndexedDBRecord:
1609
+ """Returns an ChromiumIndexedDBRecord from a ParsedInternalKey."""
1527
1610
  idb_key = IndexedDbKey.FromBytes(
1528
1611
  db_record.record.key, base_offset=db_record.record.offset
1529
1612
  )
@@ -1533,14 +1616,30 @@ class IndexedDBRecord:
1533
1616
  else:
1534
1617
  idb_value = None
1535
1618
 
1619
+ blobs = []
1620
+ if isinstance(idb_value, IndexedDBExternalObject) and blob_folder_reader:
1621
+ for (
1622
+ blob_path_or_error,
1623
+ blob_data,
1624
+ ) in blob_folder_reader.ReadBlobsFromExternalObjectEntries(
1625
+ idb_key.key_prefix.database_id, idb_value.entries
1626
+ ):
1627
+ if blob_data:
1628
+ blob = blink.V8ScriptValueDecoder.FromBytes(blob_data)
1629
+ else:
1630
+ blob = None
1631
+ blobs.append((blob_path_or_error, blob))
1632
+
1536
1633
  return cls(
1537
1634
  path=db_record.path,
1538
1635
  offset=db_record.record.offset,
1539
1636
  key=idb_key,
1540
1637
  value=idb_value,
1541
- sequence_number=db_record.record.sequence_number
1542
- if hasattr(db_record.record, "sequence_number")
1543
- else None,
1638
+ sequence_number=(
1639
+ db_record.record.sequence_number
1640
+ if hasattr(db_record.record, "sequence_number")
1641
+ else None
1642
+ ),
1544
1643
  type=db_record.record.record_type,
1545
1644
  level=db_record.level,
1546
1645
  recovered=db_record.recovered,
@@ -1548,19 +1647,28 @@ class IndexedDBRecord:
1548
1647
  object_store_id=idb_key.key_prefix.object_store_id,
1549
1648
  database_name=None,
1550
1649
  object_store_name=None,
1551
- blob=None,
1552
- raw_key=db_record.record.key,
1553
- raw_value=db_record.record.value,
1650
+ blobs=blobs,
1651
+ raw_key=db_record.record.key if include_raw_data else None,
1652
+ raw_value=db_record.record.value if include_raw_data else None,
1554
1653
  )
1555
1654
 
1556
1655
  @classmethod
1557
1656
  def FromFile(
1558
- cls, file_path: pathlib.Path, parse_value: bool = True
1559
- ) -> Generator[IndexedDBRecord, None, None]:
1560
- """Yields IndexedDBRecords from a file."""
1657
+ cls,
1658
+ file_path: pathlib.Path,
1659
+ parse_value: bool = True,
1660
+ include_raw_data: bool = False,
1661
+ blob_folder_reader: Optional[BlobFolderReader] = None,
1662
+ ) -> Generator[ChromiumIndexedDBRecord, None, None]:
1663
+ """Yields ChromiumIndexedDBRecord from a file."""
1561
1664
  for db_record in record.LevelDBRecord.FromFile(file_path):
1562
1665
  try:
1563
- yield cls.FromLevelDBRecord(db_record, parse_value=parse_value)
1666
+ yield cls.FromLevelDBRecord(
1667
+ db_record,
1668
+ parse_value=parse_value,
1669
+ include_raw_data=include_raw_data,
1670
+ blob_folder_reader=blob_folder_reader,
1671
+ )
1564
1672
  except (
1565
1673
  errors.ParserError,
1566
1674
  errors.DecoderError,
@@ -1577,6 +1685,88 @@ class IndexedDBRecord:
1577
1685
  print(f"Traceback: {traceback.format_exc()}", file=sys.stderr)
1578
1686
 
1579
1687
 
1688
+ class BlobFolderReader:
1689
+ """A blob folder reader for Chrome/Chromium.
1690
+
1691
+ Attributes:
1692
+ folder_name (str): the source blob folder.
1693
+ """
1694
+
1695
+ def __init__(self, folder_name: pathlib.Path):
1696
+ """Initializes the BlobFolderReader.
1697
+
1698
+ Args:
1699
+ folder_name: the source blob folder.
1700
+
1701
+ Raises:
1702
+ ValueError: if folder_name is None or not a directory.
1703
+ """
1704
+ if not folder_name or not folder_name.is_dir():
1705
+ raise ValueError(f"{folder_name} is None or not a directory")
1706
+ self.folder_name = folder_name.absolute()
1707
+
1708
+ def ReadBlob(self, database_id: int, blob_id: int) -> tuple[str, bytes]:
1709
+ """Reads a blob from the blob folder.
1710
+
1711
+ Args:
1712
+ database_id: the database id of the blob to read.
1713
+ blob_id: the blob id to read.
1714
+
1715
+ Returns:
1716
+ A tuple of the blob path and contents.
1717
+
1718
+ Raises:
1719
+ FileNotFoundError: if the database directory or blob folder or blob not
1720
+ found.
1721
+ """
1722
+ directory_path = self.folder_name / f"{database_id:x}"
1723
+ if not directory_path.exists():
1724
+ raise FileNotFoundError(f"Database directory not found: {directory_path}")
1725
+
1726
+ blob_folder = directory_path / f"{(blob_id & 0xff00) >> 8:02x}"
1727
+ if not blob_folder.exists():
1728
+ raise FileNotFoundError(f"Blob folder not found: {blob_folder}")
1729
+
1730
+ blob_path = blob_folder / f"{blob_id:x}"
1731
+ if not blob_path.exists():
1732
+ raise FileNotFoundError(f"Blob ({blob_id}) not found: {blob_path}")
1733
+
1734
+ with open(blob_path, "rb") as f:
1735
+ return str(blob_path), f.read()
1736
+
1737
+ def ReadBlobsFromExternalObjectEntries(
1738
+ self, database_id: int, entries: list[ExternalObjectEntry]
1739
+ ) -> Generator[tuple[str, Optional[bytes]], None, None]:
1740
+ """Reads blobs from the blob folder.
1741
+
1742
+ Args:
1743
+ database_id: the database id.
1744
+ entries: the external object entries.
1745
+
1746
+ Yields:
1747
+ A tuple of blob path and contents or if the blob is not found, an error
1748
+ message and None.
1749
+ """
1750
+ for entry in entries:
1751
+ if (
1752
+ entry.object_type
1753
+ in (
1754
+ definitions.ExternalObjectType.BLOB,
1755
+ definitions.ExternalObjectType.FILE,
1756
+ )
1757
+ and entry.blob_number is not None
1758
+ ):
1759
+ try:
1760
+ yield self.ReadBlob(database_id, entry.blob_number)
1761
+ except FileNotFoundError as err:
1762
+ error_message = (
1763
+ f"Blob not found for ExternalObjectEntry at offset {entry.offset}"
1764
+ f": {err}"
1765
+ )
1766
+ print(error_message, file=sys.stderr)
1767
+ yield error_message, None
1768
+
1769
+
1580
1770
  class FolderReader:
1581
1771
  """A IndexedDB folder reader for Chrome/Chromium.
1582
1772
 
@@ -1595,15 +1785,25 @@ class FolderReader:
1595
1785
  """
1596
1786
  if not folder_name or not folder_name.is_dir():
1597
1787
  raise ValueError(f"{folder_name} is None or not a directory")
1598
- self.folder_name = folder_name
1788
+ self.folder_name = folder_name.absolute()
1789
+
1790
+ # Locate the correponding blob folder. The folder_name should be
1791
+ # <origin>.leveldb and the blob folder should be <origin>.blob
1792
+ if str(self.folder_name).endswith(".leveldb"):
1793
+ self.blob_folder_reader = BlobFolderReader(
1794
+ pathlib.Path(str(self.folder_name).replace(".leveldb", ".blob"))
1795
+ )
1796
+ else:
1797
+ self.blob_folder_reader = None # type: ignore[assignment]
1599
1798
 
1600
1799
  def GetRecords(
1601
1800
  self,
1602
1801
  use_manifest: bool = False,
1603
1802
  use_sequence_number: bool = False,
1604
1803
  parse_value: bool = True,
1605
- ) -> Generator[IndexedDBRecord, None, None]:
1606
- """Yield LevelDBRecords.
1804
+ include_raw_data: bool = False,
1805
+ ) -> Generator[ChromiumIndexedDBRecord, None, None]:
1806
+ """Yields ChromiumIndexedDBRecord.
1607
1807
 
1608
1808
  Args:
1609
1809
  use_manifest: True to use the current manifest in the folder as a means to
@@ -1613,15 +1813,18 @@ class FolderReader:
1613
1813
  parse_value: True to parse values.
1614
1814
 
1615
1815
  Yields:
1616
- IndexedDBRecord.
1816
+ ChromiumIndexedDBRecord.
1617
1817
  """
1618
1818
  leveldb_folder_reader = record.FolderReader(self.folder_name)
1619
1819
  for leveldb_record in leveldb_folder_reader.GetRecords(
1620
1820
  use_manifest=use_manifest, use_sequence_number=use_sequence_number
1621
1821
  ):
1622
1822
  try:
1623
- yield IndexedDBRecord.FromLevelDBRecord(
1624
- leveldb_record, parse_value=parse_value
1823
+ yield ChromiumIndexedDBRecord.FromLevelDBRecord(
1824
+ leveldb_record,
1825
+ parse_value=parse_value,
1826
+ include_raw_data=include_raw_data,
1827
+ blob_folder_reader=self.blob_folder_reader,
1625
1828
  )
1626
1829
  except (
1627
1830
  errors.ParserError,