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

94 lines
2.9 KiB
Python

"""Shared protobuf builders for the guard's tests.
The tests construct real ``Backup`` wire format rather than mocking the decoder.
A mock would happily agree with a decoder that counts the wrong field number,
which is the specific bug this package had while being written (chapters are
field 16; field 3 is the title, and counting it yields exactly one "chapter"
per manga).
"""
from __future__ import annotations
import pytest
_WIRE_VARINT = 0
_WIRE_LEN = 2
def encode_varint(value: int) -> bytes:
"""Encode ``value`` as a base-128 varint."""
out = bytearray()
while True:
byte = value & 0x7F
value >>= 7
if value:
out.append(byte | 0x80)
else:
out.append(byte)
return bytes(out)
def tag(field_number: int, wire_type: int) -> bytes:
"""Encode a protobuf field tag."""
return encode_varint((field_number << 3) | wire_type)
def length_delimited(field_number: int, payload: bytes) -> bytes:
"""Encode a length-delimited field."""
return tag(field_number, _WIRE_LEN) + encode_varint(len(payload)) + payload
def varint_field(field_number: int, value: int) -> bytes:
"""Encode a varint field."""
return tag(field_number, _WIRE_VARINT) + encode_varint(value)
def chapter(url: str = "/chapter/x") -> bytes:
"""Build one ``chapters`` submessage (field 16 of ``BackupManga``)."""
return length_delimited(16, length_delimited(1, url.encode()))
def manga(*, url: str = "/manga/x", chapters: int = 0, title: str = "T") -> bytes:
"""Build one ``BackupManga`` entry (field 1 of ``Backup``)."""
body = length_delimited(2, url.encode()) + length_delimited(3, title.encode())
body += b"".join(chapter(f"{url}/c{i}") for i in range(chapters))
return length_delimited(1, body)
def category(name: str) -> bytes:
"""Build one ``BackupCategory`` entry (field 2 of ``Backup``)."""
return length_delimited(2, length_delimited(1, name.encode()))
def source(name: str = "MangaDex") -> bytes:
"""Build one ``BackupSource`` entry (field 101 of ``Backup``)."""
return length_delimited(101, length_delimited(1, name.encode()))
def backup(
*,
manga_count: int = 0,
chapters_each: int = 0,
categories: int = 0,
sources: int = 0,
) -> bytes:
"""Build a whole ``Backup`` message with the requested shape."""
parts = [
manga(url=f"/manga/{i}", chapters=chapters_each) for i in range(manga_count)
]
parts += [category(f"cat{i}") for i in range(categories)]
parts += [source(f"src{i}") for i in range(sources)]
return b"".join(parts)
@pytest.fixture
def healthy_payload() -> bytes:
"""A payload standing in for a normal library."""
return backup(manga_count=10, chapters_each=3, categories=4, sources=2)
@pytest.fixture
def stub_payload() -> bytes:
"""The 2026-08-09 failure shape: sources survive, the library does not."""
return backup(manga_count=0, categories=0, sources=2)