- No client-side encryption: plaintext content-addressed remote (SHA-256 names), no backup key, no cryptography dependency (server disk encryption is the trust model). - Filters back user data: images, archives, databases, PDFs accepted; 10 MiB cap; binary sniffing removed; temp names hardened (~$, #..#, .temp). - SQLite zero-error policy: backup-API snapshots + integrity_check, journal folding, locked/corrupt loud skips, verified restores. - Recovery: reindex from manifests, remote adopt, on-demand blob fetch, 5-day retention + thinning, GC, date-guarded remote purge, metrics. - Scheduler with WebDAV quota signal and 70% pressure backstop (floor kept). - 37 tests incl. live-monitor capture safety and DB safety.
143 lines
5.1 KiB
Python
143 lines
5.1 KiB
Python
"""Enterprise-safe snapshots of live SQLite databases.
|
|
|
|
A raw byte copy of a live (especially WAL-mode) SQLite file can be torn or
|
|
silently stale: the main file, -wal and -shm must be captured in one instant,
|
|
which a plain read cannot do. So any file with the SQLite magic is snapshotted
|
|
through the SQLite Online Backup API instead of read directly:
|
|
|
|
1. Open the source read-only (never triggers WAL recovery or checkpointing,
|
|
never takes a write lock, never mutates the live DB).
|
|
2. Copy page-by-page with ``Connection.backup()`` (restarts safely if the
|
|
source is being written to; retries while the source is locked).
|
|
3. Run ``PRAGMA integrity_check`` on the snapshot; only ``ok`` is stored.
|
|
4. Anything else (locked past the deadline, unreadable, corrupt) raises
|
|
``DatabaseUnsafe`` and nothing is versioned -- a loud skip beats a silent
|
|
corrupt version. Monitor/API surfaces it as ``database-locked`` /
|
|
``database-corrupt`` instead of storing garbage.
|
|
|
|
Journal files (``*-wal``, ``*-shm``, ``*-journal``) are never versioned on
|
|
their own (see filters: ``database-journal``); they are folded into the main
|
|
file's snapshot. For non-SQLite engines (postgres/mysql data files) only a
|
|
crash-consistent raw copy is possible -- dump-then-backup remains required.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import queue
|
|
import sqlite3
|
|
import tempfile
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
SQLITE_MAGIC = b"SQLite format 3\x00"
|
|
|
|
SNAPSHOT_TIMEOUT_SECONDS = 5.0
|
|
|
|
|
|
class DatabaseUnsafe(Exception):
|
|
"""Raised when no consistent snapshot can be produced. Never store partial data."""
|
|
|
|
def __init__(self, reason: str, detail: str = ""):
|
|
super().__init__(detail or reason)
|
|
self.reason = reason
|
|
self.detail = detail
|
|
|
|
|
|
def is_sqlite_image(data: bytes) -> bool:
|
|
return data[: len(SQLITE_MAGIC)] == SQLITE_MAGIC
|
|
|
|
|
|
def _integrity_ok(conn: sqlite3.Connection) -> bool:
|
|
row = conn.execute("PRAGMA integrity_check").fetchone()
|
|
return row is not None and row[0] == "ok"
|
|
|
|
|
|
def verify_sqlite_bytes(data: bytes) -> None:
|
|
"""Raise DatabaseUnsafe unless data is a fully consistent SQLite image."""
|
|
if not is_sqlite_image(data):
|
|
raise DatabaseUnsafe("not-a-database", "missing SQLite magic")
|
|
fd, tmp = tempfile.mkstemp(prefix="versiond-verify-", suffix=".sqlite")
|
|
try:
|
|
with os.fdopen(fd, "wb") as fh:
|
|
fh.write(data)
|
|
conn = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
|
|
try:
|
|
if not _integrity_ok(conn):
|
|
raise DatabaseUnsafe("database-corrupt", "integrity_check failed on pushed content")
|
|
finally:
|
|
conn.close()
|
|
except DatabaseUnsafe:
|
|
raise
|
|
except Exception as exc:
|
|
raise DatabaseUnsafe("database-unreadable", str(exc)) from exc
|
|
finally:
|
|
try:
|
|
os.unlink(tmp)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def snapshot_sqlite(path: str, max_bytes: int, timeout: float = SNAPSHOT_TIMEOUT_SECONDS) -> bytes:
|
|
"""Consistent, verified snapshot of a live SQLite DB file. Read-only on source.
|
|
|
|
The backup runs on a daemon worker thread bounded by ``timeout``: a source
|
|
locked past the deadline fails loudly (``database-locked``) instead of
|
|
blocking the monitor loop forever. SQLite connections are thread-local to
|
|
the worker.
|
|
"""
|
|
out: queue.Queue = queue.Queue(maxsize=1)
|
|
|
|
def _work() -> None:
|
|
try:
|
|
out.put((True, _snapshot_once(path, max_bytes)))
|
|
except Exception as exc: # noqa: BLE001 - ferried back to the caller
|
|
out.put((False, exc))
|
|
|
|
worker = threading.Thread(target=_work, daemon=True)
|
|
worker.start()
|
|
try:
|
|
ok, payload = out.get(timeout=timeout)
|
|
except queue.Empty as exc:
|
|
raise DatabaseUnsafe("database-locked", f"{path}: still locked after {timeout}s") from exc
|
|
if ok:
|
|
return payload
|
|
if isinstance(payload, DatabaseUnsafe):
|
|
raise payload
|
|
raise DatabaseUnsafe("database-unreadable", f"{path}: {payload}") from payload
|
|
|
|
|
|
def _snapshot_once(path: str, max_bytes: int) -> bytes:
|
|
fd, tmp = tempfile.mkstemp(prefix="versiond-db-", suffix=".sqlite")
|
|
os.close(fd)
|
|
try:
|
|
try:
|
|
src = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=1.0)
|
|
except Exception as exc:
|
|
raise DatabaseUnsafe("database-unreadable", f"{path}: {exc}") from exc
|
|
try:
|
|
dst = sqlite3.connect(tmp)
|
|
try:
|
|
src.backup(dst)
|
|
except sqlite3.OperationalError as exc:
|
|
raise DatabaseUnsafe("database-locked", f"{path}: {exc}") from exc
|
|
finally:
|
|
dst.close()
|
|
finally:
|
|
src.close()
|
|
conn = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
|
|
try:
|
|
if not _integrity_ok(conn):
|
|
raise DatabaseUnsafe("database-corrupt", f"integrity_check failed for {path}")
|
|
finally:
|
|
conn.close()
|
|
data = Path(tmp).read_bytes()
|
|
if len(data) > max_bytes:
|
|
raise DatabaseUnsafe("file-too-large", f"{len(data)} bytes")
|
|
return data
|
|
finally:
|
|
try:
|
|
os.unlink(tmp)
|
|
except OSError:
|
|
pass
|