96 lines
4.0 KiB
Python
96 lines
4.0 KiB
Python
"""Zero-write proof for a Firebird database (READ-ONLY tool).
|
|
|
|
python scripts/prove_zero_write.py snapshot <FDB> <out.json> # before the run
|
|
python scripts/prove_zero_write.py compare <before.json> <after.json>
|
|
|
|
A snapshot records: (1) database-level DML counters (MON$RECORD_STATS inserts/updates/deletes), (2) a SHA-256 over every row/column of
|
|
C000025 (and C000004), (3) row counts of C000025/C000004/C000030/NFCE/NFCE_ITENS, (4) the file SHA-256 and size. `compare` reports whether
|
|
the DML counters, table digests and counts are identical. Only SELECT statements are executed. The file SHA-256 is informational only: any attach
|
|
(even an idle read-only one) touches header/page metadata, so it is not part of the verdict.
|
|
"""
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def load_env():
|
|
f = ROOT / ".env"
|
|
if f.exists():
|
|
for line in f.read_text(encoding="utf-8").splitlines():
|
|
if "=" in line and not line.startswith("#"):
|
|
k, _, v = line.partition("=")
|
|
os.environ.setdefault(k.strip(), v.strip())
|
|
|
|
|
|
def table_digest(cur, table):
|
|
cur.execute(f"SELECT * FROM {table} ORDER BY 1")
|
|
h = hashlib.sha256()
|
|
n = 0
|
|
for row in cur.fetchall():
|
|
h.update(repr([v.read() if hasattr(v, "read") else v for v in row]).encode("utf-8", "replace"))
|
|
n += 1
|
|
return {"rows": n, "sha256": h.hexdigest()}
|
|
|
|
|
|
def snapshot(path):
|
|
import fdb
|
|
conn = fdb.connect(database=path, user=os.environ["FB_USER"], password=os.environ["FB_PASSWORD"], fb_library_name=os.environ.get("FIREBIRD_CLIENT_LIBRARY"),
|
|
isolation_level=fdb.ISOLATION_LEVEL_READ_COMMITED_RO)
|
|
try:
|
|
cur = conn.cursor()
|
|
stats = None
|
|
try:
|
|
cur.execute("SELECT r.MON$RECORD_INSERTS, r.MON$RECORD_UPDATES, r.MON$RECORD_DELETES FROM MON$RECORD_STATS r JOIN MON$DATABASE d ON d.MON$STAT_ID = r.MON$STAT_ID")
|
|
row = cur.fetchone()
|
|
stats = {"inserts": row[0], "updates": row[1], "deletes": row[2]}
|
|
except Exception as e: # noqa: BLE001
|
|
stats = {"unavailable": type(e).__name__}
|
|
digests = {t: table_digest(cur, t) for t in ("C000025", "C000004")}
|
|
counts = {}
|
|
for t in ("C000030", "NFCE", "NFCE_ITENS", "C000061", "C000032"):
|
|
try:
|
|
cur.execute(f"SELECT COUNT(*) FROM {t}")
|
|
counts[t] = cur.fetchone()[0]
|
|
except Exception: # noqa: BLE001
|
|
counts[t] = None
|
|
finally:
|
|
conn.close()
|
|
h = hashlib.sha256()
|
|
with open(path, "rb") as f:
|
|
for block in iter(lambda: f.read(8 * 1024 * 1024), b""):
|
|
h.update(block)
|
|
return {"database": Path(path).name, "dml_counters": stats, "table_digests": digests, "row_counts": counts, "file_sha256": h.hexdigest(), "file_size": Path(path).stat().st_size}
|
|
|
|
|
|
def compare(before, after):
|
|
res = {
|
|
"dml_counters_identical": before["dml_counters"] == after["dml_counters"],
|
|
"table_digests_identical": before["table_digests"] == after["table_digests"],
|
|
"row_counts_identical": before["row_counts"] == after["row_counts"],
|
|
"file_sha256_identical": before["file_sha256"] == after["file_sha256"],
|
|
"dml_counters": {"before": before["dml_counters"], "after": after["dml_counters"]},
|
|
}
|
|
res["ZERO_WRITE_PROVEN"] = res["table_digests_identical"] and res["row_counts_identical"] and res["dml_counters_identical"]
|
|
return res
|
|
|
|
|
|
def main(argv):
|
|
load_env()
|
|
if argv[0] == "snapshot":
|
|
Path(argv[2]).write_text(json.dumps(snapshot(argv[1]), indent=1), encoding="utf-8")
|
|
print("snapshot written")
|
|
elif argv[0] == "compare":
|
|
r = compare(json.loads(Path(argv[1]).read_text(encoding="utf-8")), json.loads(Path(argv[2]).read_text(encoding="utf-8")))
|
|
print(json.dumps(r, indent=1))
|
|
raise SystemExit(0 if r["ZERO_WRITE_PROVEN"] else 1)
|
|
else:
|
|
raise SystemExit(__doc__)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main(sys.argv[1:])
|