mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 22:07:03 +01:00
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
169 lines
5.2 KiB
Python
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())
|