diff --git a/docs/issue-166-validation.md b/docs/issue-166-validation.md
new file mode 100644
index 00000000..0864b085
--- /dev/null
+++ b/docs/issue-166-validation.md
@@ -0,0 +1,124 @@
+# Issue #166: offline decryption validation
+
+Validated on 2026-09-28 against upstream `1b516cf` plus this fix, on macOS arm64
+(macOS 26.3.1, Python 3.11.15). No private databases, keys, account identifiers,
+message content, or raw application logs are included in this report.
+
+## Real WeChat data
+
+A stable, private copy of an existing local account was used. The project's
+normal scanner selected **20 business databases**; search indexes and key-info
+files were excluded by the existing database filter. Main files plus sidecars
+copied for these databases totalled **1,332,165,592 bytes**.
+
+No original WeChat files were modified. Each database/sidecar pair was copied
+only when its before/after size, modification time and inode were unchanged.
+Input-copy SHA-256 checksums were also unchanged after each test. Runtime output
+and key persistence used a separate private temporary directory.
+
+| Check | Result |
+| --- | --- |
+| Production `GET /api/decrypt_stream` router through FastAPI TestClient | HTTP 200; completed; 20 successful, 0 failed |
+| Production `POST /api/decrypt` router over localhost HTTP | HTTP 200; completed; 20 successful, 0 failed |
+| Key persistence through the actual router | Successful on both routes |
+| Independent `PRAGMA integrity_check` on every output | 20/20 passed after each route |
+| `session.db` | Authenticated, decrypted and passed integrity checking; 7 tables |
+| WAL replay observed | 1 database, 4 committed frames, 3 applied pages |
+| Production chat sessions route with `source=decrypted` | HTTP 200; returned 5 sessions |
+| Production chat messages route with `source=decrypted` | HTTP 200; returned 5 messages |
+
+The other nonempty WAL files did not contain current committed frames selected
+by recovery; file size alone does not establish outstanding transactions. The
+unmodified real account did not require index rebuilding. Its successful export
+therefore does **not** independently reproduce the reporter's original index
+corruption. That failure and recovery are covered by the encrypted regression
+fixtures below.
+
+A second local account was discovered but not decrypted because none of its
+stored keys passed cross-database authentication. This is an untested account,
+not a successful test or a decryption regression.
+
+## Desktop startup follow-up
+
+The original expired-runtime blocker was resolved upstream in `1b516cf`, merged
+into this branch. The new official macOS source runtime is
+`macos-source-runtime-20260928-1790594550`, with expiry
+`2026-11-12T11:22:30Z`. The standard `npm run dev` entry point downloaded it,
+verified its pinned artifacts, accepted the `source-public` profile, and launched
+Nuxt and Electron. A subsequent launch verified and reused the cache. No expiry,
+signature or native-runtime checks were bypassed.
+
+The first launch exceeded the desktop's default 30-second readiness timeout
+while setting up a fresh Python environment. After dependencies were installed,
+a retry with a longer desktop startup timeout exposed a separate local wait:
+the native broker remained inside macOS `SecItemCopyMatching`, called by
+`load_or_create_device_identity`. The backend did not reach its health endpoint
+before the broker startup timeout. A process sample established this wait;
+it is not evidence that the renewed runtime is expired or incompatible.
+
+The user completed the macOS keychain prompt and the application's first-use
+agreement. The standard Electron-launched backend then returned HTTP 200 with
+`status=healthy`, and the actual Electron window displayed the application and
+existing chat data.
+
+### Issue-specific desktop backend regression
+
+The existing encrypted regression fixture generator was reused for two
+snapshots with the exact named-index entry-count error from #166. The same
+input bytes were tested against the unfixed code and the running desktop
+backend. The baseline checkout is `b70da36`; its decrypt implementation,
+SQLite diagnostics and decrypt router are unchanged through upstream `1b516cf`.
+
+| Same-input comparison | Unfixed code | Fixed desktop backend |
+| --- | --- | --- |
+| Page authentication | 3/3 pages, no HMAC warnings | Authenticated |
+| Named-index mismatch without WAL | `wrong # of entries in index SessionUnreadListTable_1_NameId_CreateTime`; session fails | Rebuilds only the affected index; full integrity check passes |
+| Same mismatch with a correcting committed WAL page | Same index failure; WAL omitted | 1 committed frame replayed; full integrity check passes without REINDEX |
+| Key persistence | Rejected because session did not verify | Saved after session/message authentication |
+| Records in repaired session fixture | Output rejected | Both expected records present |
+
+Each fixture included an authenticated message database. Both POST requests to
+the actual desktop backend completed with 2 successes and 0 failures. The WAL
+case also passed the actual SSE endpoint used by the decrypt page, ending in
+`complete`, 2 successes, 0 failures and `db_key_persisted=true`. Input hashes
+were unchanged. These are encrypted SQLite fixtures, not the reporter's
+unavailable 734-page original database.
+
+The already prepared real-account snapshot was then tested through this same
+Electron-launched backend: **20/20 successful, 0 failures, 20/20 independent
+integrity checks passed, key persisted, and source-copy hashes unchanged**.
+The POST request took 6.82 seconds. Explicit `source=decrypted` chat requests
+returned 5 sessions and 5 messages from these outputs. The visible Electron
+chat page was also inspected; private content and screenshots are not published.
+
+This is targeted regression testing against the complete desktop application's
+backend plus a visible desktop startup/chat check. It does not claim automated
+click-through of the entire first-use/key-capture/decrypt wizard. The earlier
+minimal-router HTTP tests remain supplementary evidence. No new account-key
+capture or modification of original WeChat databases was needed.
+
+The frontend production static build (`npm run generate`) passed, generating
+34 routes. After merging `1b516cf`, all 76 focused Python tests and 3 frontend
+feedback tests passed again.
+
+## Regression tests
+
+- 76 focused Python tests passed across offline WAL recovery, key modes, SSE,
+ decryption UI contracts, decrypted fallback and account validation.
+- 3 frontend feedback tests passed (`node --test frontend/tests/decrypt-feedback.test.mjs`).
+- `git diff --check` passed.
+
+`tests/test_offline_wal_recovery.py` uses real SQLite pages and encrypted
+fixtures, without mocking integrity diagnostics. Coverage includes:
+
+- The same named-index entry-count error reported in #166, with all encrypted
+ pages authenticating; recovery both from a committed WAL page and by rebuilding
+ only the affected indexes on a disposable output.
+- Little- and big-endian WAL checksums, repeated page writes, committed versus
+ uncommitted/partial tails, recycled salts, growth, truncation and VACUUM.
+- Invalid WAL headers, frame checksums, page HMACs and missing growth pages.
+- Refusing table corruption, changing sources and source/output aliases.
+- Preserving the prior output when validation or atomic publication fails, and
+ preventing an old output WAL from overriding the new snapshot.
+- Keeping key authentication independent of output integrity without accepting
+ a key that authenticates only one of the required database roles.
diff --git a/frontend/pages/decrypt.vue b/frontend/pages/decrypt.vue
index 9edf61aa..5d799ab3 100644
--- a/frontend/pages/decrypt.vue
+++ b/frontend/pages/decrypt.vue
@@ -1026,6 +1026,16 @@
+
+ 数据库解密详情({{ databaseFailures.length }} 个未完成)
+
+ -
+ {{ item.name }}:{{ item.error }}
+ (密钥认证已通过)
+
+
+
+
@@ -1117,7 +1127,7 @@ const {
const loading = ref(false)
const error = ref('')
const warning = ref('') // 警告,用于密钥提示
-const warningIsError = computed(() => /失败|错误|异常|中断/.test(String(warning.value || '')))
+const warningIsError = computed(() => !String(warning.value || '').startsWith('解密部分成功:') && /失败|错误|异常|中断/.test(String(warning.value || '')))
const currentStep = ref(0)
const mediaAccount = ref('')
const activeKeyAccount = ref('')
@@ -1144,7 +1154,7 @@ const imageKeyMemoryScanNote = computed(() => String(
|| platformCapabilities.value?.image_key_memory_scan_note
|| '图片密钥扫描原生资源缺失或安装不完整,请重新安装完整发行包。'
))
-const DB_KEY_PERSISTENCE_WARNING = '数据库密钥未通过完整实时库校验或无法安全保存;请重新获取并确认主要数据库解密成功,仍失败请检查数据目录权限。'
+const DB_KEY_PERSISTENCE_WARNING = '数据库密钥未通过 session/message 跨库认证或保存失败;请查看失败详情,确认账号密钥及数据目录写入权限。'
const guideDialog = reactive({
open: false,
eyebrow: '操作提示',
@@ -2012,8 +2022,15 @@ const cancelDbKeyAcquisition = () => {
}
const showDbKeyPersistenceWarning = (result) => {
+ if (result?.failure_count > 0 && result?.success_count > 0) {
+ warning.value = `解密部分成功:${result.success_count}/${result.total_databases} 个数据库可用;失败文件请查看解密详情,已成功的数据可继续使用。`
+ }
+ const repaired = Object.values(result?.account_results || {}).flatMap(account =>
+ Object.values(account.db_diagnostics || {}).filter(db => db.success && db.index_repair?.success).map(db => db.db_name)
+ )
+ if (repaired.length) warning.value = [warning.value, `已在解密输出副本中重建索引并通过完整性检查:${repaired.join('、')}。`].filter(Boolean).join(' ')
if (result?.db_key_persisted !== false) return
- warning.value = DB_KEY_PERSISTENCE_WARNING
+ warning.value = [warning.value, DB_KEY_PERSISTENCE_WARNING].filter(Boolean).join(" ")
logDecryptDebug('decrypt:db-key-persistence-warning', {
error_count: Array.isArray(result?.db_key_persistence_errors)
? result.db_key_persistence_errors.length
@@ -2525,6 +2542,12 @@ const getMediaDecryptConcurrency = () => {
// 解密结果存储
const decryptResult = ref(null)
+const databaseFailures = computed(() => Object.entries(decryptResult.value?.account_results || {}).flatMap(([account, result]) =>
+ Object.entries(result.db_diagnostics || {}).filter(([, db]) => db.success === false).map(([name, db]) => ({
+ id: `${account}/${name}`, name, authenticated: db.key_authenticated === true,
+ error: db.error === 'key_mismatch' ? '密钥与此数据库不匹配' : (db.error || '数据库完整性检查未通过')
+ }))
+))
// 验证表单
const validateForm = () => {
@@ -2657,6 +2680,7 @@ const handleDecrypt = async () => {
db_key_length: String(formData.key || '').trim().length
})
loading.value = true
+ decryptResult.value = null
error.value = ''
warning.value = ''
diff --git a/frontend/tests/decrypt-feedback.test.mjs b/frontend/tests/decrypt-feedback.test.mjs
new file mode 100644
index 00000000..b8f9a2f0
--- /dev/null
+++ b/frontend/tests/decrypt-feedback.test.mjs
@@ -0,0 +1,29 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import vm from 'node:vm'
+
+const source = readFileSync(new URL('../pages/decrypt.vue', import.meta.url), 'utf8')
+function feedback(result) {
+ const start = source.indexOf('const showDbKeyPersistenceWarning =')
+ const end = source.indexOf('\nconst runMacosLldbFallback', start)
+ const context = { result, warning: { value: '' }, DB_KEY_PERSISTENCE_WARNING: 'KEY_SAVE_WARNING', logDecryptDebug: () => {} }
+ vm.runInNewContext(`${source.slice(start, end)}\nshowDbKeyPersistenceWarning(result)`, context)
+ return context.warning.value
+}
+
+test('partial success with an authenticated key does not ask for key recapture', () => {
+ const message = feedback({ success_count:27, failure_count:1, total_databases:28, db_key_persisted:true })
+ assert.match(message, /解密部分成功:27\/28/)
+ assert.doesNotMatch(message, /KEY_SAVE_WARNING/)
+})
+
+test('recovered indexes are disclosed and key saving failures stay visible', () => {
+ const message = feedback({ db_key_persisted:false, account_results:{ account:{ db_diagnostics:{ session:{ db_name:'session.db', success:true, index_repair:{ success:true } } } } } })
+ assert.match(message, /重建索引.*session\.db/)
+ assert.match(message, /KEY_SAVE_WARNING/)
+})
+
+test('normal success has no warning', () => {
+ assert.equal(feedback({ db_key_persisted:true, success_count:28, failure_count:0 }), '')
+})
diff --git a/src/wechat_decrypt_tool/routers/decrypt.py b/src/wechat_decrypt_tool/routers/decrypt.py
index afb12f4c..59553967 100644
--- a/src/wechat_decrypt_tool/routers/decrypt.py
+++ b/src/wechat_decrypt_tool/routers/decrypt.py
@@ -211,12 +211,17 @@ def _database_diagnostic_role(diagnostic: dict[str, Any]) -> str:
def _database_diagnostic_verified(diagnostic: dict[str, Any]) -> bool:
return bool(
- diagnostic.get("success") is True
- and not bool(diagnostic.get("copied_as_sqlite"))
+ not bool(diagnostic.get("copied_as_sqlite"))
and str(diagnostic.get("key_mode") or "").strip()
in {"raw_enc_key", "sqlcipher_passphrase"}
- and int(diagnostic.get("failed_pages") or 0) == 0
- and str(diagnostic.get("diagnostic_status") or "").strip() == "ok"
+ # Key authentication is independent of WAL/page/index integrity.
+ # Retain compatibility with results produced before key_authenticated.
+ and (diagnostic.get("key_authenticated") is True or (
+ "key_authenticated" not in diagnostic
+ and diagnostic.get("success") is True
+ and int(diagnostic.get("failed_pages") or 0) == 0
+ and str(diagnostic.get("diagnostic_status") or "").strip() == "ok"
+ ))
)
diff --git a/src/wechat_decrypt_tool/sqlite_diagnostics.py b/src/wechat_decrypt_tool/sqlite_diagnostics.py
index 17005962..872d136b 100644
--- a/src/wechat_decrypt_tool/sqlite_diagnostics.py
+++ b/src/wechat_decrypt_tool/sqlite_diagnostics.py
@@ -182,3 +182,41 @@ def format_sqlite_diagnostics(diagnostics: Mapping[str, Any]) -> str:
continue
compact[str(key)] = value
return json.dumps(compact, ensure_ascii=False, sort_keys=True)
+
+
+def repair_sqlite_indexes(path: str | Path) -> dict[str, Any]:
+ """Repair index-only damage on a disposable output, never on the source.
+
+ Do not attempt salvage of table/page corruption. A full integrity check must
+ pass after rebuilding every affected named index before output is accepted.
+ """
+ import re
+
+ result: dict[str, Any] = {"attempted": False, "success": False}
+ conn = None
+ try:
+ conn = sqlite3.connect(str(path))
+ conn.execute("PRAGMA trusted_schema=OFF")
+ errors = [str(row[0]) for row in conn.execute("PRAGMA integrity_check(1000)")]
+ if not errors or errors == ["ok"] or len(errors) >= 1000:
+ return result
+ indexes = set()
+ for error in errors:
+ match = re.fullmatch(r"(?:wrong # of entries in index|row \d+ missing from index) (.+)", error)
+ if match is None:
+ return result
+ indexes.add(match.group(1))
+ names = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='index'")}
+ if not indexes or not indexes <= names:
+ return result
+ result.update(attempted=True, indexes=sorted(indexes), original_errors=errors[:5])
+ for name in sorted(indexes):
+ conn.execute(f"REINDEX {_quote_ident(name)}")
+ conn.commit()
+ result['success'] = conn.execute("PRAGMA integrity_check").fetchall() == [('ok',)]
+ except sqlite3.Error as exc:
+ result['error'] = _clean_error(exc)
+ finally:
+ if conn is not None:
+ conn.close()
+ return result
diff --git a/src/wechat_decrypt_tool/sqlite_wal.py b/src/wechat_decrypt_tool/sqlite_wal.py
new file mode 100644
index 00000000..5f65281d
--- /dev/null
+++ b/src/wechat_decrypt_tool/sqlite_wal.py
@@ -0,0 +1,89 @@
+"""Recover a stable SQLite/SQLCipher WAL snapshot without touching source files.
+
+Format: https://sqlite.org/fileformat2.html#walformat. SQLCipher computes WAL
+checksums over the encrypted page payload, before writing the frame to disk.
+"""
+from __future__ import annotations
+
+import struct
+from collections.abc import Callable
+
+
+def _checksum(data: bytes, endian: str, state: tuple[int, int] = (0, 0)) -> tuple[int, int]:
+ a, b = state
+ for x, y in struct.iter_unpack(endian + 'II', data):
+ a = (a + x + b) & 0xffffffff
+ b = (b + y + a) & 0xffffffff
+ return a, b
+
+
+def merge_wal_snapshot(
+ database: bytes,
+ wal: bytes,
+ page_size: int,
+ *,
+ verify_page: Callable[[bytes, int], bool] | None = None,
+) -> tuple[bytes, dict]:
+ """Apply only checksum-valid committed frames, respecting database truncation.
+
+ A salt mismatch marks the recycled tail of a WAL. Complete frames with a
+ bad checksum are rejected rather than silently exporting an older snapshot.
+ Incomplete/uncommitted trailing frames never become part of the output.
+ """
+ info = {'wal_bytes': len(wal), 'committed_frames': 0, 'applied_pages': 0,
+ 'ignored_tail_bytes': 0, 'database_pages': len(database) // page_size}
+ if not wal:
+ return database, info
+ if len(wal) < 32:
+ raise ValueError('WAL 文件头不完整,请退出微信后重试')
+ magic, version, wal_page_size = struct.unpack('>III', wal[:12])
+ if magic not in (0x377f0682, 0x377f0683) or version != 3007000:
+ raise ValueError('WAL 文件头或版本无效')
+ if wal_page_size != page_size or len(database) % page_size:
+ raise ValueError('WAL 与数据库页大小不匹配,或主数据库页不完整')
+ endian = '<' if magic == 0x377f0682 else '>'
+ state = _checksum(wal[:24], endian)
+ if state != struct.unpack('>II', wal[24:32]):
+ raise ValueError('WAL 文件头校验失败')
+ salt = wal[16:24]
+ frame_size = 24 + page_size
+ pending: dict[int, bytes] = {}
+ committed: dict[int, bytes] = {}
+ base_pages = len(database) // page_size
+ size = base_pages
+ commit_end = 32
+ for offset in range(32, len(wal) - frame_size + 1, frame_size):
+ header = wal[offset:offset + 24]
+ if header[8:16] != salt:
+ break # old frames left after WAL reset / preallocated zero tail
+ pgno, db_size = struct.unpack('>II', header[:8])
+ if pgno == 0 or pgno > 0xfffffffe:
+ raise ValueError('WAL 页号无效')
+ page = wal[offset + 24:offset + frame_size]
+ state = _checksum(page, endian, _checksum(header[:8], endian, state))
+ if state != struct.unpack('>II', header[16:24]):
+ raise ValueError('WAL 帧校验失败,请重新获取稳定的数据库副本')
+ pending[pgno] = page
+ if db_size:
+ committed.update(pending)
+ pending.clear()
+ committed = {n: p for n, p in committed.items() if n <= db_size}
+ base_pages = min(base_pages, db_size)
+ # Check before allocating: malformed sizes must not cause huge allocations.
+ extension = sum(n > base_pages for n in committed)
+ if db_size - base_pages != extension:
+ raise ValueError('WAL 提交缺少数据库扩展页')
+ size = db_size
+ commit_end = offset + frame_size
+ info['committed_frames'] = (commit_end - 32) // frame_size
+ info.update(applied_pages=len(committed), ignored_tail_bytes=len(wal) - commit_end,
+ database_pages=size)
+ if not info['committed_frames']:
+ return database, info
+ output = bytearray(database[:base_pages * page_size])
+ output.extend(b'\0' * (size * page_size - len(output)))
+ for pgno, page in committed.items():
+ if verify_page is not None and not verify_page(page, pgno):
+ raise ValueError(f'WAL 第 {pgno} 页 HMAC 校验失败')
+ output[(pgno - 1) * page_size:pgno * page_size] = page
+ return bytes(output), info
diff --git a/src/wechat_decrypt_tool/wechat_decrypt.py b/src/wechat_decrypt_tool/wechat_decrypt.py
index b7438746..510fce7e 100644
--- a/src/wechat_decrypt_tool/wechat_decrypt.py
+++ b/src/wechat_decrypt_tool/wechat_decrypt.py
@@ -15,6 +15,7 @@
import json
import struct
import time
+import tempfile
from pathlib import Path
from typing import Any
@@ -23,7 +24,8 @@
from .app_paths import get_output_databases_dir
from .database_filters import should_skip_source_database
-from .sqlite_diagnostics import collect_sqlite_diagnostics, sqlite_diagnostics_status
+from .sqlite_diagnostics import collect_sqlite_diagnostics, sqlite_diagnostics_status, repair_sqlite_indexes
+from .sqlite_wal import merge_wal_snapshot
# 注意:不再支持默认密钥,所有密钥必须通过参数传入
@@ -101,6 +103,7 @@ def _safe_file_snapshot(path: str | Path) -> dict[str, Any]:
{
"exists": True,
"size": int(st.st_size),
+ "inode": int(st.st_ino),
"mtime_ns": int(getattr(st, "st_mtime_ns", int(st.st_mtime * 1_000_000_000))),
}
)
@@ -115,6 +118,7 @@ def _safe_file_snapshot(path: str | Path) -> dict[str, Any]:
siblings[suffix] = {
"exists": True,
"size": int(st.st_size),
+ "inode": int(st.st_ino),
"mtime_ns": int(getattr(st, "st_mtime_ns", int(st.st_mtime * 1_000_000_000))),
}
except FileNotFoundError:
@@ -483,7 +487,7 @@ def build_decrypt_summary_message(*, success_count: int, total_databases: int, d
if success_count <= 0:
if diagnostic_warning_count > 0:
- return "解密失败:数据库校验未通过,密钥可能不匹配当前账号。"
+ return "解密失败:数据库校验未通过,请查看各文件的密钥认证、WAL 或完整性诊断。"
return "解密失败:未能成功解密任何数据库。"
if success_count < total_databases:
@@ -721,6 +725,9 @@ def decrypt_database(self, db_path: str, output_path: str) -> bool:
"source_changed_during_read": False,
"read_ms": 0,
"key_mode": "",
+ "key_authenticated": False,
+ "wal": {},
+ "index_repair": {},
"input_layout": {},
"expected_output_size": 0,
"output_header_debug": {},
@@ -729,6 +736,20 @@ def decrypt_database(self, db_path: str, output_path: str) -> bool:
"error": "",
}
self.last_result = result
+ working_output: Path | None = None
+
+ def _write_output(data: bytes) -> None:
+ nonlocal working_output
+ with tempfile.NamedTemporaryFile(dir=Path(output_path).parent, prefix=".decrypt-", suffix=".db", delete=False) as stream:
+ working_output = Path(stream.name)
+ # A materialized snapshot is standalone; do not leave WAL mode
+ # enabled and let readers create new sidecars beside the export.
+ if data.startswith(SQLITE_HEADER) and data[18:20] == b"\x02\x02":
+ stream.write(memoryview(data)[:18])
+ stream.write(b"\x01\x01")
+ stream.write(memoryview(data)[20:])
+ else:
+ stream.write(data)
def _append_failed_page(page_num: int, reason: str, error: str = "") -> None:
result["failure_reasons"][reason] = int(result["failure_reasons"].get(reason) or 0) + 1
@@ -754,17 +775,26 @@ def _finalize(success: bool, error: str = "") -> bool:
if error:
result["error"] = " ".join(str(error).split()).strip()
- output_file = Path(str(output_path))
- if output_file.exists():
+ output_file = working_output
+ if output_file is not None and output_file.exists():
try:
result["output_size"] = int(output_file.stat().st_size)
except Exception:
pass
diagnostics = collect_sqlite_diagnostics(output_file, quick_check=True)
+ if (normalized_success and diagnostics.get("quick_check_ok") is False
+ and result["key_authenticated"] and result["failed_pages"] == 0
+ and result["hmac_warning_pages"] == 0):
+ result["index_repair"] = repair_sqlite_indexes(output_file)
+ if result["index_repair"].get("success"):
+ diagnostics = collect_sqlite_diagnostics(output_file, quick_check=True)
+ result["output_size"] = output_file.stat().st_size
+ diagnostics["path"] = str(output_path)
result["diagnostics"] = diagnostics
result["diagnostic_status"] = sqlite_diagnostics_status(diagnostics)
result["output_header_debug"] = _read_plain_sqlite_header_debug(output_file)
+ result["output_header_debug"]["path"] = str(output_path)
if normalized_success:
failure_message = _build_decrypt_failure_message(result)
@@ -773,13 +803,21 @@ def _finalize(success: bool, error: str = "") -> bool:
result["success"] = False
if not result["error"]:
result["error"] = failure_message
- if output_file.exists():
+ if output_file is not None and output_file.exists():
try:
output_file.unlink()
except Exception as exc:
logger.warning("删除无效解密输出失败: %s, 错误: %s", output_file, exc)
+ if normalized_success and output_file is not None:
+ # Never let an old output WAL override a freshly decrypted main file.
+ if any(Path(str(output_path) + suffix).exists() for suffix in ("-wal", "-shm", "-journal")):
+ raise ValueError("输出数据库正在使用或遗留日志文件,请关闭读取连接后重试")
+ os.replace(output_file, output_path)
payload = {
+ "key_authenticated": result["key_authenticated"],
+ "wal": result["wal"],
+ "index_repair": result["index_repair"],
"db_name": result["db_name"],
"db_path": result["db_path"],
"output_path": result["output_path"],
@@ -823,6 +861,10 @@ def _finalize(success: bool, error: str = "") -> bool:
logger.info(f"开始解密数据库: {db_path}")
try:
+ if Path(db_path).resolve() == Path(output_path).resolve() or (
+ Path(output_path).exists() and os.path.samefile(db_path, output_path)
+ ):
+ raise ValueError("解密输出不能覆盖源数据库")
source_snapshot_before = _safe_file_snapshot(db_path)
result["source_snapshot_before"] = source_snapshot_before
logger.info(
@@ -840,6 +882,18 @@ def _finalize(success: bool, error: str = "") -> bool:
read_t0 = time.perf_counter()
with open(db_path, 'rb') as f:
encrypted_data = f.read()
+ wal_path = Path(str(db_path) + "-wal")
+ try:
+ wal_data = wal_path.read_bytes()
+ except FileNotFoundError:
+ wal_data = b""
+ journal_path = Path(str(db_path) + "-journal")
+ try:
+ with journal_path.open('rb') as journal:
+ if journal.read(8) == bytes.fromhex('d9d505f920a163d7'):
+ raise ValueError("源数据库存在待恢复的回滚日志,请先通过微信完成恢复")
+ except FileNotFoundError:
+ pass
result["read_ms"] = round((time.perf_counter() - read_t0) * 1000.0, 1)
source_snapshot_after = _safe_file_snapshot(db_path)
@@ -848,7 +902,13 @@ def _finalize(success: bool, error: str = "") -> bool:
after_size = int(source_snapshot_after.get("size") or 0)
before_mtime = int(source_snapshot_before.get("mtime_ns") or 0)
after_mtime = int(source_snapshot_after.get("mtime_ns") or 0)
- source_changed = bool(before_size != after_size or before_mtime != after_mtime)
+ source_changed = bool(
+ before_size != after_size or before_mtime != after_mtime
+ or source_snapshot_before.get("inode") != source_snapshot_after.get("inode")
+ or any(source_snapshot_before.get("siblings", {}).get(suffix) !=
+ source_snapshot_after.get("siblings", {}).get(suffix)
+ for suffix in ("-wal", "-journal"))
+ )
result["source_changed_during_read"] = source_changed
logger.info(
"[decrypt.pipeline] source_snapshot_after %s",
@@ -873,6 +933,9 @@ def _finalize(success: bool, error: str = "") -> bool:
after_mtime,
)
+ if source_changed:
+ return _finalize(False, "源数据库或 WAL 在读取期间发生变化,请退出微信后重试")
+
logger.info(f"读取文件大小: {len(encrypted_data)} bytes")
result["input_size"] = int(len(encrypted_data))
result["input_layout"] = {
@@ -906,8 +969,17 @@ def _finalize(success: bool, error: str = "") -> bool:
# 检查是否已经是解密的数据库
if encrypted_data.startswith(SQLITE_HEADER):
logger.info(f"文件已是SQLite格式,直接复制: {db_path}")
- with open(output_path, 'wb') as f:
- f.write(encrypted_data)
+ plain_page_size = int.from_bytes(encrypted_data[16:18], 'big')
+ if plain_page_size == 1:
+ plain_page_size = 65536
+ if plain_page_size < 512 or plain_page_size > 65536 or plain_page_size & (plain_page_size - 1):
+ raise ValueError("SQLite 页大小无效")
+ encrypted_data, result["wal"] = merge_wal_snapshot(encrypted_data, wal_data, plain_page_size)
+ if result["wal"]["committed_frames"]:
+ merged = bytearray(encrypted_data)
+ merged[28:32] = result["wal"]["database_pages"].to_bytes(4, "big")
+ encrypted_data = bytes(merged)
+ _write_output(encrypted_data)
result["copied_as_sqlite"] = True
return _finalize(True)
@@ -922,6 +994,7 @@ def _finalize(success: bool, error: str = "") -> bool:
enc_key, mac_key, key_mode = resolved_key_material
result["key_mode"] = key_mode
+ result["key_authenticated"] = True
logger.info("Page 1 HMAC verification passed: mode=%s path=%s", key_mode, db_path)
logger.info(
"[decrypt.pipeline] key_material_resolved %s",
@@ -938,6 +1011,11 @@ def _finalize(success: bool, error: str = "") -> bool:
),
)
+ encrypted_data, result["wal"] = merge_wal_snapshot(
+ encrypted_data, wal_data, PAGE_SIZE,
+ verify_page=lambda page, pgno: hmac.compare_digest(
+ page[-HMAC_SIZE:], _compute_page_hmac(mac_key, page, pgno)),
+ )
decrypted_data = bytearray()
total_pages = (len(encrypted_data) + PAGE_SIZE - 1) // PAGE_SIZE
successful_pages = 0
@@ -1042,9 +1120,11 @@ def _finalize(success: bool, error: str = "") -> bool:
result["successful_pages"] = int(successful_pages)
result["failed_pages"] = int(failed_pages)
- # 写入解密后的文件
- with open(output_path, 'wb') as f:
- f.write(decrypted_data)
+ # WAL commit size is authoritative even if page 1 was not in the WAL.
+ if result["wal"]["committed_frames"]:
+ decrypted_data[28:32] = result["wal"]["database_pages"].to_bytes(4, "big")
+ # 写入待校验的临时文件,验证成功后原子替换输出。
+ _write_output(decrypted_data)
logger.info(f"解密文件大小: {len(decrypted_data)} bytes")
if int(len(decrypted_data)) != int(result["expected_output_size"]):
@@ -1077,6 +1157,13 @@ def _finalize(success: bool, error: str = "") -> bool:
except Exception as e:
logger.error(f"解密失败: {db_path}, 错误: {e}")
return _finalize(False, str(e))
+ finally:
+ if working_output is not None:
+ for suffix in ("", "-wal", "-shm", "-journal"):
+ try:
+ Path(str(working_output) + suffix).unlink(missing_ok=True)
+ except OSError as exc:
+ logger.warning("清理解密临时文件失败: %s", exc)
def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> dict:
"""
diff --git a/tests/test_decrypt_image_keys_frontend.py b/tests/test_decrypt_image_keys_frontend.py
index b720ac29..eeb4198c 100644
--- a/tests/test_decrypt_image_keys_frontend.py
+++ b/tests/test_decrypt_image_keys_frontend.py
@@ -197,7 +197,7 @@ def test_saved_database_key_prefill_remains_available_before_macos_recapture():
def test_db_key_persistence_failure_warns_without_blocking_image_key_step():
source = read_decrypt_page()
- warning = "数据库密钥未通过完整实时库校验或无法安全保存;请重新获取并确认主要数据库解密成功,仍失败请检查数据目录权限。"
+ warning = "数据库密钥未通过 session/message 跨库认证或保存失败;请查看失败详情,确认账号密钥及数据目录写入权限。"
assert warning in source
assert "if (result?.db_key_persisted !== false) return" in source
assert source.count("showDbKeyPersistenceWarning(") == 2
diff --git a/tests/test_decrypt_stream_sse.py b/tests/test_decrypt_stream_sse.py
index 16dfeb84..00b8e8e4 100644
--- a/tests/test_decrypt_stream_sse.py
+++ b/tests/test_decrypt_stream_sse.py
@@ -491,7 +491,10 @@ def test_decrypt_stream_marks_invalid_output_as_failed(self):
self.assertEqual(events[-1].get("status"), "failed")
self.assertEqual(events[-1].get("success_count"), 0)
self.assertEqual(events[-1].get("failure_count"), 1)
- self.assertIn("密钥可能不匹配", str(events[-1].get("message") or ""))
+ self.assertIn("数据库校验未通过", str(events[-1].get("message") or ""))
+ diagnostic = next(iter(events[-1]["account_results"].values()))["db_diagnostics"]["MSG0.db"]
+ self.assertEqual(diagnostic["error"], "key_mismatch")
+ self.assertIs(diagnostic["key_authenticated"], False)
upsert_mock.assert_not_called()
out = root / "output" / "databases" / "wxid_bad" / "MSG0.db"
diff --git a/tests/test_offline_wal_recovery.py b/tests/test_offline_wal_recovery.py
new file mode 100644
index 00000000..dcc39812
--- /dev/null
+++ b/tests/test_offline_wal_recovery.py
@@ -0,0 +1,321 @@
+"""Real SQLite pages and encrypted fixtures for issue #166 (no mocked diagnostics)."""
+import hashlib
+from contextlib import closing
+import hmac
+import sqlite3
+import struct
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+
+import wechat_decrypt_tool.wechat_decrypt as decrypt
+from wechat_decrypt_tool.sqlite_wal import merge_wal_snapshot
+from wechat_decrypt_tool.routers.decrypt import _db_key_persistence_rejection
+
+KEY = bytes(range(32))
+SALT = bytes(range(16))
+INDEX = 'SessionUnreadListTable_1_NameId_CreateTime'
+
+
+def checksum(data, state=(0, 0), endian='<'):
+ values = struct.unpack(endian + 'I' * (len(data) // 4), data)
+ a, b = state
+ for i in range(0, len(values), 2):
+ a = (a + values[i] + b) % 2**32
+ b = (b + values[i + 1] + a) % 2**32
+ return a, b
+
+
+def wal_bytes(frames, endian='<'):
+ header = struct.pack('>IIIIII', 0x377f0682 if endian == '<' else 0x377f0683,
+ 3007000, 4096, 0, 123, 456)
+ state = checksum(header, endian=endian)
+ output = header + struct.pack('>II', *state)
+ for pgno, size, page in frames:
+ fields = struct.pack('>IIII', pgno, size, 123, 456)
+ state = checksum(fields[:8] + page, state, endian)
+ output += fields + struct.pack('>II', *state) + page
+ return output
+
+
+def plain_database(path):
+ with closing(sqlite3.connect(path)) as c:
+ c.execute('PRAGMA page_size=4096')
+ c.execute('VACUUM')
+ # Reserve SQLCipher's 80-byte IV/MAC region before creating any records.
+ raw = bytearray(path.read_bytes())
+ raw[20] = 80
+ raw[105:107] = (4096 - 80).to_bytes(2, 'big')
+ path.write_bytes(raw)
+ c = sqlite3.connect(path)
+ c.execute('CREATE TABLE records(id INTEGER PRIMARY KEY, name TEXT, time INT)')
+ c.execute(f'CREATE INDEX {INDEX} ON records(name,time)')
+ c.execute("INSERT INTO records VALUES(1,'before',1)")
+ c.commit()
+ return c
+
+
+@pytest.fixture(scope='module')
+def keys():
+ enc = decrypt._derive_sqlcipher_enc_key(KEY, SALT)
+ mac = decrypt._derive_mac_key(enc, SALT)
+ return enc, mac
+
+
+def encrypt_page(page, pgno, keys):
+ enc, mac = keys
+ iv = bytes(range(16, 32))
+ prefix = SALT if pgno == 1 else b''
+ start = 16 if pgno == 1 else 0
+ cipher = Cipher(algorithms.AES(enc), modes.CBC(iv)).encryptor()
+ data = prefix + cipher.update(page[start:4016]) + cipher.finalize() + iv
+ digest = hmac.new(mac, data[start:] + pgno.to_bytes(4, 'little'), hashlib.sha512).digest()
+ return data + digest
+
+
+def encrypt_database(data, keys):
+ return b''.join(encrypt_page(data[i:i+4096], i//4096+1, keys) for i in range(0, len(data), 4096))
+
+
+def run_decrypt(src, dst):
+ worker = decrypt.WeChatDatabaseDecryptor(KEY.hex())
+ ok = worker.decrypt_database(str(src), str(dst))
+ return ok, worker.last_result
+
+
+@pytest.mark.parametrize('encrypted', [False, True])
+def test_real_sqlite_wal_commits_are_exported(tmp_path, keys, encrypted):
+ src, out = tmp_path/'source.db', tmp_path/'output.db'
+ c = plain_database(src)
+ c.execute('PRAGMA journal_mode=WAL')
+ c.execute('PRAGMA wal_autocheckpoint=0')
+ c.execute("UPDATE records SET name='first commit'")
+ c.commit()
+ c.execute("UPDATE records SET name='latest commit'")
+ c.execute("INSERT INTO records VALUES(2,'second row',2)")
+ c.commit()
+ main, wal = src.read_bytes(), Path(str(src)+'-wal').read_bytes()
+ c.close()
+ if encrypted:
+ frames = []
+ for offset in range(32, len(wal), 4120):
+ pgno, size = struct.unpack('>II', wal[offset:offset+8])
+ frames.append((pgno, size, encrypt_page(wal[offset+24:offset+4120], pgno, keys)))
+ wal = wal_bytes(frames)
+ main = encrypt_database(main, keys)
+ src.write_bytes(main)
+ Path(str(src)+'-wal').write_bytes(wal)
+ ok, result = run_decrypt(src, out)
+ assert ok, result
+ assert result['wal']['committed_frames'] > 0
+ assert result['key_authenticated'] is encrypted
+ assert out.read_bytes()[18:20] == b'\x01\x01'
+ with closing(sqlite3.connect(out)) as conn:
+ assert conn.execute('SELECT name FROM records ORDER BY id').fetchall() == [('latest commit',), ('second row',)]
+ assert conn.execute('PRAGMA integrity_check').fetchall() == [('ok',)]
+ assert src.read_bytes() == main
+ assert Path(str(src)+'-wal').read_bytes() == wal
+
+
+def broken_index(tmp_path):
+ path = tmp_path/'fixture.db'
+ c = plain_database(path)
+ before = path.read_bytes()
+ index_page = c.execute("SELECT rootpage FROM sqlite_master WHERE type='index'").fetchone()[0]
+ c.execute("INSERT INTO records VALUES(2,'new',2)")
+ c.commit()
+ c.close()
+ after = path.read_bytes()
+ mixed = bytearray(after)
+ offset = (index_page-1)*4096
+ mixed[offset:offset+4096] = before[offset:offset+4096]
+ return bytes(mixed), after, index_page
+
+
+@pytest.mark.parametrize('with_wal', [False, True])
+def test_issue166_authenticated_index_mismatch_recovers(tmp_path, keys, with_wal):
+ mixed, good, pgno = broken_index(tmp_path)
+ src, out = tmp_path/'session.db', tmp_path/'out.db'
+ encrypted = encrypt_database(mixed, keys)
+ src.write_bytes(encrypted)
+ if with_wal:
+ page = good[(pgno-1)*4096:pgno*4096]
+ Path(str(src)+'-wal').write_bytes(wal_bytes([(pgno,len(good)//4096,encrypt_page(page,pgno,keys))]))
+ ok, result = run_decrypt(src, out)
+ assert ok, result
+ assert result['failed_pages'] == result['hmac_warning_pages'] == 0
+ assert result['key_authenticated']
+ if with_wal:
+ assert not result['index_repair'] # WAL fixed it, no REINDEX required.
+ else:
+ assert result['index_repair']['success']
+ assert result['index_repair']['indexes'] == [INDEX]
+ with closing(sqlite3.connect(out)) as c:
+ assert c.execute('SELECT COUNT(*) FROM records').fetchone() == (2,)
+ assert c.execute('PRAGMA integrity_check').fetchone() == ('ok',)
+ assert src.read_bytes() == encrypted
+
+
+@pytest.mark.parametrize('endian', ['<', '>'])
+def test_commit_boundaries_recycled_tail_and_truncation(endian):
+ a, b, c, d = [bytes([n])*4096 for n in range(4)]
+ wal = wal_bytes([(2,2,b), (3,3,c), (2,2,d), (2,0,a)], endian)
+ actual, info = merge_wal_snapshot(a+a+a, wal+b'\0'*4120, 4096)
+ assert actual == a+d
+ assert info['committed_frames'] == 3
+ assert info['ignored_tail_bytes'] == 8240
+
+
+@pytest.mark.parametrize('damage', ['header', 'frame', 'page_size', 'version', 'short', 'growth'])
+def test_invalid_wal_is_not_silently_ignored(damage):
+ data = b'0'*4096
+ wal = bytearray(wal_bytes([(1,1,data)]))
+ if damage == 'header': wal[24] ^= 1
+ if damage == 'frame': wal[-1] ^= 1
+ if damage == 'page_size': wal[8:12] = (8192).to_bytes(4,'big')
+ if damage == 'version': wal[4:8] = (0).to_bytes(4,'big')
+ if damage == 'short': wal = wal[:16]
+ if damage == 'growth': wal = wal_bytes([(1,0xfffffffe,data)])
+ with pytest.raises(ValueError): merge_wal_snapshot(data, bytes(wal), 4096)
+
+
+def test_uncommitted_and_partial_frames_do_not_change_snapshot():
+ a, b = b'a'*4096, b'b'*4096
+ actual, info = merge_wal_snapshot(a, wal_bytes([(1,1,b), (1,0,a)])[:-100], 4096)
+ assert actual == b
+ assert info['ignored_tail_bytes'] == 4020
+ assert merge_wal_snapshot(a, wal_bytes([(1,0,b)]),4096)[0] == a
+
+
+def test_valid_wal_checksum_does_not_bypass_page_hmac(tmp_path, keys):
+ src, out = tmp_path/'session.db', tmp_path/'out.db'
+ conn = plain_database(src); conn.close()
+ data = encrypt_database(src.read_bytes(), keys); src.write_bytes(data)
+ bad = bytearray(data[4096:8192]); bad[-1] ^= 1
+ Path(str(src)+'-wal').write_bytes(wal_bytes([(2,3,bytes(bad))]))
+ out.write_bytes(b'previous good output')
+ ok, result = run_decrypt(src, out)
+ assert not ok and 'HMAC' in result['error']
+ assert out.read_bytes() == b'previous good output'
+ assert not list(tmp_path.glob('.decrypt-*'))
+
+
+def test_changing_wal_is_rejected(tmp_path, monkeypatch):
+ src = tmp_path/'source.db'; conn = plain_database(src); conn.close()
+ original = decrypt._safe_file_snapshot
+ calls = 0
+ def snapshot(path):
+ nonlocal calls
+ result = original(path); calls += 1
+ if calls == 2: result['siblings']['-wal'] = {'exists': True, 'size': 1}
+ return result
+ monkeypatch.setattr(decrypt, '_safe_file_snapshot', snapshot)
+ ok, result = run_decrypt(src,tmp_path/'out.db')
+ assert not ok and result['source_changed_during_read']
+ assert not (tmp_path/'out.db').exists()
+
+
+def test_key_authentication_is_independent_of_output_integrity():
+ diagnostics = {name:{'db_name':name, 'key_authenticated':True,
+ 'key_mode':'sqlcipher_passphrase', 'success':False, 'diagnostic_status':'quick_check_failed'}
+ for name in ['session.db','message_0.db']}
+ assert _db_key_persistence_rejection({'db_diagnostics':diagnostics}) == ''
+ diagnostics['session.db']['key_authenticated'] = False
+ assert 'session' in _db_key_persistence_rejection({'db_diagnostics':diagnostics})
+
+
+def test_table_corruption_is_not_repaired_or_published(tmp_path, keys):
+ src = tmp_path/'session.db'; c = plain_database(src); c.close()
+ data = bytearray(src.read_bytes()); data[4096] = 0xff
+ src.write_bytes(encrypt_database(data,keys))
+ ok, result = run_decrypt(src,tmp_path/'out.db')
+ assert not ok
+ assert not result['index_repair'].get('attempted')
+ assert not (tmp_path/'out.db').exists()
+
+
+def test_partial_output_still_saves_cross_database_authenticated_key(monkeypatch):
+ import wechat_decrypt_tool.routers.decrypt as router
+ import wechat_decrypt_tool.wcdb_realtime as realtime
+ saved = []
+ monkeypatch.setattr(router, 'upsert_account_keys_in_store', lambda *a, **kw: saved.append((a,kw)))
+ monkeypatch.setattr(realtime.WCDB_REALTIME, 'disconnect', lambda account: None)
+ diags = {name: {'db_name':name, 'key_mode':'sqlcipher_passphrase', 'key_authenticated':True,
+ 'success':name != 'session.db', 'diagnostic_status':'quick_check_failed' if name == 'session.db' else 'ok'}
+ for name in ['session.db','message_0.db']}
+ assert router._persist_db_keys({'test_account':{'success':1,'db_diagnostics':diags}},KEY.hex()) == (True,[])
+ assert len(saved) == 1
+ diags['session.db']['key_authenticated'] = False
+ assert router._persist_db_keys({'test_account':{'success':1,'db_diagnostics':diags}},KEY.hex()) == (False,['test_account'])
+ assert len(saved) == 1
+
+
+def test_failed_publication_keeps_previous_output(tmp_path, monkeypatch):
+ src, out = tmp_path/'source.db', tmp_path/'output.db'
+ c = plain_database(src); c.close()
+ out.write_bytes(b'previous good output')
+ def fail(*args): raise PermissionError('output busy')
+ monkeypatch.setattr(decrypt.os,'replace',fail)
+ ok, result = run_decrypt(src,out)
+ assert not ok and 'output busy' in result['error']
+ assert out.read_bytes() == b'previous good output'
+ assert not list(tmp_path.glob('.decrypt-*'))
+
+
+@pytest.mark.parametrize('alias', ['same_path', 'hardlink', 'symlink'])
+def test_source_is_never_overwritten(tmp_path, alias):
+ src = tmp_path/'source.db'; c = plain_database(src); c.close()
+ original = src.read_bytes(); out = tmp_path/'out.db'
+ if alias == 'hardlink': out.hardlink_to(src)
+ elif alias == 'symlink':
+ try:
+ out.symlink_to(src)
+ except OSError:
+ pytest.skip('Creating symlinks is unavailable on this platform')
+ else: out = src
+ ok, result = run_decrypt(src,out)
+ assert not ok and '源数据库' in result['error']
+ assert src.read_bytes() == original
+
+
+def test_growth_after_truncation_cannot_reuse_discarded_pages():
+ a,b,c = b'a'*4096,b'b'*4096,b'c'*4096
+ with pytest.raises(ValueError, match='扩展页'):
+ merge_wal_snapshot(a+b+c,wal_bytes([(1,1,a),(3,3,c)]),4096)
+ result, info = merge_wal_snapshot(a+b+c,wal_bytes([(1,1,a),(2,0,c),(3,3,b)]),4096)
+ assert result == a+c+b
+
+
+def test_old_output_wal_cannot_override_new_output(tmp_path):
+ src, out = tmp_path/'source.db',tmp_path/'out.db'
+ c = plain_database(src); c.close()
+ out.write_bytes(b'previous good output')
+ Path(str(out)+'-wal').write_bytes(b'existing log')
+ ok, result = run_decrypt(src,out)
+ assert not ok and '输出数据库' in result['error']
+ assert out.read_bytes() == b'previous good output'
+ assert Path(str(out)+'-wal').read_bytes() == b'existing log'
+
+
+def test_real_sqlite_wal_growth_and_vacuum(tmp_path):
+ src, out = tmp_path/'source.db',tmp_path/'out.db'
+ c = plain_database(src)
+ c.execute('PRAGMA journal_mode=WAL')
+ c.execute('PRAGMA wal_autocheckpoint=0')
+ c.executemany('INSERT INTO records VALUES(?,?,?)', [(i, 'x'*1000, i) for i in range(2, 100)])
+ c.commit()
+ expected = c.execute('SELECT COUNT(*) FROM records').fetchone()
+ ok, result = run_decrypt(src,out)
+ assert ok, result
+ with closing(sqlite3.connect(out)) as reader:
+ assert reader.execute('SELECT COUNT(*) FROM records').fetchone() == expected
+ assert reader.execute('PRAGMA integrity_check').fetchone() == ('ok',)
+ c.execute('DELETE FROM records WHERE id > 2'); c.commit()
+ c.execute('VACUUM')
+ ok, result = run_decrypt(src,out)
+ assert ok, result
+ with closing(sqlite3.connect(out)) as reader:
+ assert reader.execute('SELECT COUNT(*) FROM records').fetchone() == (2,)
+ assert reader.execute('PRAGMA integrity_check').fetchone() == ('ok',)
+ c.close()