Expand SQLite3 data validation - #23
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #23 +/- ##
=====================================
Coverage 0.00% 0.00%
=====================================
Files 152 152
Lines 4716 4756 +40
=====================================
- Misses 4716 4756 +40
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Merging this PR will not alter performance🎉 Hooray!
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| 🆕 | test_benchmark_wal_checksum_validation[False] |
N/A | 153.7 ms | N/A |
| 🆕 | test_benchmark_wal_checksum_validation[True] |
N/A | 136.1 ms | N/A |
Comparing PimSanders:improvement/expand-wal-validation (ec7b77e) with main (62f6301)
Schamper
left a comment
There was a problem hiding this comment.
Maybe add a benchmark test too? I'll look at the actual checksum checking part later when I have a bit more time.
|
Take your time, I don't think I will be doing a whole lot of Dissect dev to coming weeks ... |
Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com>
Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com>
Schamper
left a comment
There was a problem hiding this comment.
Can you add a benchmark test too, so that we can track future changes to this algorithm?
Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com>
|
LGTM! The codspeed comment suggests that with validation is actually faster than without. Any idea why? Perhaps we should enable validation by default? 😂 |
|
Yeah uuuh not sure why that is the case now. Enabled it by default, it did need to be disabled for some tests. |
|
Claude found this: diff --git i/dissect/database/sqlite3/wal.py w/dissect/database/sqlite3/wal.py
index 04a538a..0a79899 100644
--- i/dissect/database/sqlite3/wal.py
+++ w/dissect/database/sqlite3/wal.py
@@ -39,7 +39,8 @@ class WAL:
raise InvalidDatabase("Invalid WAL header magic")
self.checksum_endian = "<" if self.header.magic == WAL_HEADER_MAGIC_LE else ">"
- self._checksum_struct = struct.Struct(f"{self.checksum_endian}2I")
+ # Checksum values are always stored in big-endian format
+ self._checksum_struct = struct.Struct(">2I")
self.frame = lru_cache(1024)(self.frame)
self.frame_size = len(c_sqlite3.wal_frame) + self.header.page_size
@@ -107,8 +108,8 @@ class WAL:
if len(frame_hdr_bytes) < len(c_sqlite3.wal_frame):
raise EOFError("Incomplete frame header while calculating checksum")
- # Checksum first 16 bytes of frame header
- seed = calculate_checksum(frame_hdr_bytes[:16], seed=seed, endian=self.checksum_endian)
+ # Checksum first 8 bytes of frame header (page number and page count)
+ seed = calculate_checksum(frame_hdr_bytes[:8], seed=seed, endian=self.checksum_endian)
# Read and checksum page data
page_data = self.fh.read(self.header.page_size)
diff --git i/tests/sqlite3/test_wal.py w/tests/sqlite3/test_wal.py
index 8c71f71..67a7559 100644
--- i/tests/sqlite3/test_wal.py
+++ w/tests/sqlite3/test_wal.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import io
from typing import TYPE_CHECKING
import pytest
@@ -59,26 +60,48 @@ def test_sqlite_wal_checkpoint(sqlite_db: Path, sqlite_wal: Path, db_as_path: bo
[pytest.param(True, id="wal_as_path"), pytest.param(False, id="wal_as_fh")],
)
def test_sqlite_wal_checksum_validation(sqlite_db: Path, sqlite_wal: Path, db_as_path: bool, wal_as_path: bool) -> None:
- # Test that the WAL checksum validation works as expected
- # When validate_checksums=True, only entries before the last checkpoint are visible
+ # With an intact WAL, checksum validation should not change the result:
+ # both modes show the live database state, matching real SQLite behaviour.
db = sqlite3.SQLite3(
sqlite_db if db_as_path else sqlite_db.open("rb"),
sqlite_wal if wal_as_path else sqlite_wal.open("rb"),
validate_checksums=True,
)
- _assert_valid_checksum(db)
+ _assert_live_state(db)
db.close()
- # When validate_checksums=False, entries after the last checkpoint are also visible
db = sqlite3.SQLite3(
sqlite_db if db_as_path else sqlite_db.open("rb"),
sqlite_wal if wal_as_path else sqlite_wal.open("rb"),
validate_checksums=False,
)
- _assert_invalid_checksum(db)
+ _assert_live_state(db)
+
+ db.close()
+
+
+def test_sqlite_wal_checksum_validation_corrupt(sqlite_db: Path, sqlite_wal: Path) -> None:
+ # Corrupt the stored checksum of the first frame of the current WAL generation.
+ # The WAL header is 32 bytes and a frame header is 24 bytes, with checksum1 at frame offset 16.
+ wal_data = bytearray(sqlite_wal.read_bytes())
+ wal_data[32 + 16] ^= 0xFF
+
+ # With validation, the corrupted frame and all frames after it are rejected,
+ # so the database reflects the state at the last checkpoint.
+ db = sqlite3.SQLite3(sqlite_db, io.BytesIO(bytes(wal_data)), validate_checksums=True)
+
+ _assert_checkpoint_state(db)
+
+ db.close()
+
+ # Without validation, the corrupted frames are still applied (salts match),
+ # so the post-checkpoint delete and update are visible.
+ db = sqlite3.SQLite3(sqlite_db, io.BytesIO(bytes(wal_data)), validate_checksums=False)
+
+ _assert_live_state(db)
db.close()
@@ -202,8 +225,8 @@ def _assert_checkpoint_3(s: sqlite3.SQLite3) -> None:
# Assertion functions for test_sqlite_wal_checksum_validation()
-def _assert_valid_checksum(s: sqlite3.SQLite3) -> None:
- # If the checksum validation is correct, all entries BEFORE the last checkpoint should be present
+def _assert_checkpoint_state(s: sqlite3.SQLite3) -> None:
+ # State as of the last checkpoint: the post-checkpoint delete and update are not applied
table = next(iter(s.tables()))
rows = list(table.rows())
@@ -244,8 +267,8 @@ def _assert_valid_checksum(s: sqlite3.SQLite3) -> None:
assert rows[10].value == 101
-def _assert_invalid_checksum(s: sqlite3.SQLite3) -> None:
- # If the checksum validation is incorrect, all entries AFTER the last checkpoint should be present
+def _assert_live_state(s: sqlite3.SQLite3) -> None:
+ # Live database state: the post-checkpoint delete and update are applied
table = next(iter(s.tables()))
rows = list(table.rows()) |
|
I was in a bit of a rush but basically it concluded that the current code considered every frame invalid, skipping all of them. Which is of course quite fast. |
This PR close #16 by expanding the data validation capabilities in SQLite3.
The SQLite3 WAL file can store multiple versions of the same frame, when reading only valid frames should be returned. The docs define a valid frame as follows:
The first check was already implemented, I have interpreted the second check as:
When initializing a database the option
validate_checksumcan be passed to use the new validation. I have chosen to only calculate the salts by default (just like before) as this will probably be good enough, and a lot faster. See the example below for the time impact: