testsAndMisc/python_pkg/syncyomi_guard/payload.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

180 lines
6.0 KiB
Python

"""Minimal protobuf reader for the Tachiyomi/Mihon ``Backup`` message.
Deliberately dependency-free. The alternative is vendoring Mihon's ``.proto``
files and a protobuf runtime, which would couple this guard to the app's schema
version — the guard has to keep working when upstream adds a field, because its
whole job is to run unattended and be trustworthy on the day something breaks.
Only the top-level field numbers are interpreted, and every unknown field is
skipped by wire type. Field numbers come from Mihon's ``BackupProto``:
===== ====================== =========================================
Field Meaning Why the guard cares
===== ====================== =========================================
1 ``backupManga`` The library. A collapse here is the alarm.
2 ``backupCategories`` Lost silently on 2026-08-09 while manga survived.
16 ``chapters`` (nested) Reading progress; catches a shallow restore.
101 ``backupSources`` Present even in a degraded payload, so it is
*not* evidence of health on its own.
===== ====================== =========================================
"""
from __future__ import annotations
from dataclasses import dataclass
_WIRE_VARINT = 0
_WIRE_64BIT = 1
_WIRE_LEN = 2
_WIRE_32BIT = 5
_FIELD_MANGA = 1
_FIELD_CATEGORIES = 2
_FIELD_CHAPTERS = 16
_FIELD_SOURCES = 101
# A varint wider than this is corruption, not a large number. Guards the decode
# loop against spinning on malformed input.
_MAX_VARINT_BYTES = 10
class PayloadError(ValueError):
"""Raised when the payload is not a decodable ``Backup`` message."""
@dataclass(frozen=True)
class PayloadStats:
"""What a payload actually contains, as opposed to what it claims.
``size_bytes`` is kept because it is the cheapest smoke test, but it is
never the deciding signal: the 2026-08-09 stub was a perfectly well-formed
protobuf message, just an almost empty one.
"""
size_bytes: int
manga: int
categories: int
chapters: int
sources: int
def describe(self) -> str:
"""Render a one-line human summary for logs and notifications."""
return (
f"{self.manga} manga, {self.chapters} chapters, "
f"{self.categories} categories, {self.sources} sources "
f"({self.size_bytes} bytes)"
)
def _read_varint(buf: bytes, pos: int) -> tuple[int, int]:
"""Read a base-128 varint, returning the value and the new offset."""
result = 0
shift = 0
start = pos
while pos < len(buf):
if pos - start >= _MAX_VARINT_BYTES:
msg = f"varint at offset {start} is unterminated"
raise PayloadError(msg)
byte = buf[pos]
pos += 1
result |= (byte & 0x7F) << shift
if not byte & 0x80:
return result, pos
shift += 7
msg = f"varint at offset {start} runs past end of payload"
raise PayloadError(msg)
def _skip_field(buf: bytes, pos: int, wire_type: int) -> int:
"""Advance past one non-length-delimited field, returning the new offset.
Length-delimited fields never reach here: both callers must inspect their
contents, so they handle that wire type inline.
"""
if wire_type == _WIRE_VARINT:
_, pos = _read_varint(buf, pos)
return pos
if wire_type == _WIRE_64BIT:
return pos + 8
if wire_type == _WIRE_32BIT:
return pos + 4
msg = f"unsupported wire type {wire_type} at offset {pos}"
raise PayloadError(msg)
def _count_chapters(entry: bytes) -> int:
"""Count ``chapters`` submessages inside one ``BackupManga`` entry.
Chapters are field 16, not field 3 — field 3 is the title. Getting this
wrong yields exactly one "chapter" per manga, which looks plausible enough
to pass a careless review, so the distinction is load-bearing.
"""
chapters = 0
pos = 0
while pos < len(entry):
key, pos = _read_varint(entry, pos)
field_number = key >> 3
wire_type = key & 0x07
if wire_type == _WIRE_LEN:
length, pos = _read_varint(entry, pos)
end = pos + length
if end > len(entry):
msg = "chapter field overruns manga entry"
raise PayloadError(msg)
if field_number == _FIELD_CHAPTERS:
chapters += 1
pos = end
else:
pos = _skip_field(entry, pos, wire_type)
return chapters
def decode_payload(blob: bytes) -> PayloadStats:
"""Decode a ``Backup`` payload into verifiable counts.
The payload must be consumed exactly: trailing bytes mean the message was
truncated or spliced, which is precisely the corruption a page-level
recovery can produce, so it is treated as an error rather than ignored.
Raises:
PayloadError: If the payload is empty, malformed, or not fully consumed.
"""
if not blob:
msg = "payload is empty"
raise PayloadError(msg)
manga = categories = chapters = sources = 0
pos = 0
while pos < len(blob):
key, pos = _read_varint(blob, pos)
field_number = key >> 3
wire_type = key & 0x07
if wire_type == _WIRE_LEN:
length, pos = _read_varint(blob, pos)
end = pos + length
if end > len(blob):
msg = f"field {field_number} overruns payload"
raise PayloadError(msg)
if field_number == _FIELD_MANGA:
manga += 1
chapters += _count_chapters(blob[pos:end])
elif field_number == _FIELD_CATEGORIES:
categories += 1
elif field_number == _FIELD_SOURCES:
sources += 1
pos = end
else:
pos = _skip_field(blob, pos, wire_type)
if pos != len(blob):
msg = f"payload not fully consumed ({pos} of {len(blob)} bytes)"
raise PayloadError(msg)
return PayloadStats(
size_bytes=len(blob),
manga=manga,
categories=categories,
chapters=chapters,
sources=sources,
)