testsAndMisc/python_pkg/syncyomi_guard/__main__.py
Krzysztof kuhy Rudnicki cef72fbab0 syncyomi_guard: refuse a collapsed payload as the new good copy
TachiyomiSY 1.13.2 can report a restore as SUCCESS while writing almost
nothing, then sync that near-empty library up and replace the only good
copy. On 2026-08-09 a 14 MB payload holding 2182 manga became a 267 KB
stub, and the real library survived only as stale pages in the SQLite
write-ahead log.

The upstream bug is Kotlin in an APK and cannot be fixed here, so this
targets the fixable half: decode the payload, count what is actually in
it, and refuse to treat a collapse as normal drift. A pure is-it-zero
check would have caught this incident but not the harder case upstream
describes, where a restore keeps some entries and drops the rest, so the
threshold is proportional. Categories get their own rule because all
2182 manga survived the round trip and all 18 categories did not.

Adds the setup script and a runbook covering the WAL-page salvage used
to recover the library on the day.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQLwdquDCNmWtvgi3Lk72J
2026-08-10 15:33:18 +02:00

169 lines
5.2 KiB
Python

"""Command line entry point for the SyncYomi integrity guard.
Exit codes are the contract; a systemd timer and a human both read them:
* ``0`` — payload healthy (or a first-run baseline was recorded)
* ``1`` — payload collapsed, or it could not be read or decoded
A read failure is deliberately *not* a pass. The whole point of the guard is to
be believed on the day something is wrong, and "the database was missing so I
said OK" is the fail-open shape that lets a bad copy become the only copy.
"""
from __future__ import annotations
import argparse
from pathlib import Path
import shutil
import subprocess
import sys
from python_pkg.syncyomi_guard.payload import PayloadError, decode_payload
from python_pkg.syncyomi_guard.store import (
StoreError,
load_baseline,
read_payload,
save_baseline,
write_snapshot,
)
from python_pkg.syncyomi_guard.verdict import Status, Thresholds, Verdict, compare
_DEFAULT_DB = Path("/home/kuhy/syncyomi/config/syncyomi.db")
_DEFAULT_STATE = Path.home() / ".local/share/syncyomi_guard"
_DEFAULT_SNAPSHOTS = Path.home() / "syncyomi/snapshots"
_DEFAULT_KEEP = 14
def _notify(title: str, body: str) -> None:
"""Best-effort desktop notification; never fails the run.
The exit code is the real signal. A notification is a convenience, so a
machine without ``notify-send`` (or a run with no session bus) must not turn
a healthy check into a failure.
"""
binary = shutil.which("notify-send")
if binary is None:
return
try:
subprocess.run(
[binary, "--urgency=critical", title, body],
check=False,
timeout=10,
)
except (OSError, subprocess.SubprocessError):
return
def _build_parser() -> argparse.ArgumentParser:
"""Construct the argument parser."""
parser = argparse.ArgumentParser(
prog="syncyomi-guard",
description=(
"Verify the SyncYomi library payload has not silently collapsed, "
"and snapshot it as a restorable .tachibk when it is healthy."
),
)
parser.add_argument(
"--db",
type=Path,
default=_DEFAULT_DB,
help=f"SyncYomi SQLite database (default: {_DEFAULT_DB})",
)
parser.add_argument(
"--state-dir",
type=Path,
default=_DEFAULT_STATE,
help=f"where the baseline is stored (default: {_DEFAULT_STATE})",
)
parser.add_argument(
"--snapshot-dir",
type=Path,
default=_DEFAULT_SNAPSHOTS,
help=f"where .tachibk snapshots are written (default: {_DEFAULT_SNAPSHOTS})",
)
parser.add_argument(
"--keep",
type=int,
default=_DEFAULT_KEEP,
help=f"how many snapshots to retain (default: {_DEFAULT_KEEP})",
)
parser.add_argument(
"--max-drop",
type=float,
default=Thresholds().max_drop_ratio,
help="fail if any count drops by more than this fraction (default: 0.10)",
)
parser.add_argument(
"--no-snapshot",
action="store_true",
help="check only; do not write a snapshot or update the baseline",
)
parser.add_argument(
"--accept",
action="store_true",
help=(
"record the current payload as the new baseline even if it looks "
"collapsed (use after a deliberate library purge)"
),
)
return parser
def _report(verdict: Verdict) -> None:
"""Write the verdict to the appropriate stream.
A silent guard is a useless one: the journal line is how a human finds out
what the timer saw, so the counts are written on every run, not only on
failures.
"""
if verdict.is_failure:
sys.stderr.write(f"COLLAPSED: {verdict.reason}\n")
if verdict.previous is not None:
sys.stderr.write(f" previous: {verdict.previous.describe()}\n")
sys.stderr.write(f" current: {verdict.current.describe()}\n")
elif verdict.status is Status.FIRST_RUN:
sys.stdout.write(f"baseline recorded: {verdict.current.describe()}\n")
else:
sys.stdout.write(f"ok: {verdict.reason}\n")
def main(argv: list[str] | None = None) -> int:
"""Run the guard, returning the process exit code."""
args = _build_parser().parse_args(argv)
try:
blob = read_payload(args.db)
stats = decode_payload(blob)
except (StoreError, PayloadError) as exc:
sys.stderr.write(f"FAILED: {exc}\n")
_notify("SyncYomi guard: cannot verify", str(exc))
return 1
try:
thresholds = Thresholds(max_drop_ratio=args.max_drop)
except ValueError as exc:
sys.stderr.write(f"FAILED: {exc}\n")
return 1
verdict = compare(stats, load_baseline(args.state_dir), thresholds)
_report(verdict)
if verdict.is_failure and not args.accept:
_notify(
"SyncYomi library collapsed",
f"{verdict.reason}. Snapshot NOT overwritten; recover from "
f"{args.snapshot_dir}.",
)
return 1
if not args.no_snapshot:
snapshot = write_snapshot(args.snapshot_dir, blob, args.keep)
save_baseline(args.state_dir, stats)
sys.stdout.write(f"snapshot: {snapshot}\n")
return 0
if __name__ == "__main__":
sys.exit(main())