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
120 lines
4.0 KiB
Python
120 lines
4.0 KiB
Python
"""Tests for the ``Backup`` protobuf reader.
|
|
|
|
The guarantee under test is that the counts are *true*. A decoder that silently
|
|
undercounts is worse than no guard at all, because it would report OK while the
|
|
library drained away.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from python_pkg.syncyomi_guard.payload import PayloadError, decode_payload
|
|
from python_pkg.syncyomi_guard.tests.conftest import (
|
|
backup,
|
|
encode_varint,
|
|
length_delimited,
|
|
manga,
|
|
tag,
|
|
varint_field,
|
|
)
|
|
|
|
|
|
def test_counts_every_top_level_field(healthy_payload: bytes) -> None:
|
|
stats = decode_payload(healthy_payload)
|
|
assert stats.manga == 10
|
|
assert stats.chapters == 30
|
|
assert stats.categories == 4
|
|
assert stats.sources == 2
|
|
assert stats.size_bytes == len(healthy_payload)
|
|
|
|
|
|
def test_chapters_come_from_field_16_not_the_title(stub_payload: bytes) -> None:
|
|
"""A title-only manga has zero chapters, not one.
|
|
|
|
Field 3 is the title and field 16 is a chapter. Counting field 3 yields
|
|
exactly one chapter per manga, which looks plausible in aggregate.
|
|
"""
|
|
stats = decode_payload(manga(url="/manga/a", chapters=0, title="A Long Title"))
|
|
assert stats.manga == 1
|
|
assert stats.chapters == 0
|
|
assert decode_payload(stub_payload).manga == 0
|
|
|
|
|
|
def test_the_2026_08_09_stub_reports_an_empty_library(stub_payload: bytes) -> None:
|
|
"""Sources present, library absent — the payload that overwrote the good one."""
|
|
stats = decode_payload(stub_payload)
|
|
assert stats.manga == 0
|
|
assert stats.chapters == 0
|
|
assert stats.categories == 0
|
|
assert stats.sources == 2
|
|
|
|
|
|
def test_unknown_fields_of_every_wire_type_are_skipped() -> None:
|
|
"""Upstream may add fields; the guard must not break when it does."""
|
|
payload = (
|
|
manga(url="/manga/a", chapters=1)
|
|
+ varint_field(900, 12345)
|
|
+ tag(901, 5)
|
|
+ b"\x00\x01\x02\x03"
|
|
+ tag(902, 1)
|
|
+ b"\x00\x01\x02\x03\x04\x05\x06\x07"
|
|
+ length_delimited(903, b"opaque")
|
|
)
|
|
stats = decode_payload(payload)
|
|
assert stats.manga == 1
|
|
assert stats.chapters == 1
|
|
|
|
|
|
def test_unknown_wire_type_inside_a_manga_entry_is_skipped() -> None:
|
|
"""Exercises the non-length branch of the per-entry chapter scan."""
|
|
body = length_delimited(2, b"/manga/a") + varint_field(8, 1) + tag(9, 5)
|
|
payload = length_delimited(1, body + b"\x00\x01\x02\x03")
|
|
assert decode_payload(payload).manga == 1
|
|
|
|
|
|
def test_empty_payload_is_rejected() -> None:
|
|
with pytest.raises(PayloadError, match="empty"):
|
|
decode_payload(b"")
|
|
|
|
|
|
def test_truncated_length_prefix_is_rejected() -> None:
|
|
"""A field claiming more bytes than exist is corruption, not a short read."""
|
|
payload = tag(1, 2) + encode_varint(500) + b"too short"
|
|
with pytest.raises(PayloadError, match="overruns"):
|
|
decode_payload(payload)
|
|
|
|
|
|
def test_truncated_chapter_inside_a_manga_is_rejected() -> None:
|
|
body = length_delimited(2, b"/manga/a") + tag(16, 2) + encode_varint(400) + b"xx"
|
|
with pytest.raises(PayloadError, match="overruns manga entry"):
|
|
decode_payload(length_delimited(1, body))
|
|
|
|
|
|
def test_unterminated_varint_is_rejected() -> None:
|
|
with pytest.raises(PayloadError, match="unterminated"):
|
|
decode_payload(b"\xff" * 12)
|
|
|
|
|
|
def test_varint_running_off_the_end_is_rejected() -> None:
|
|
with pytest.raises(PayloadError, match="past end"):
|
|
decode_payload(b"\xff\xff")
|
|
|
|
|
|
def test_unsupported_wire_type_is_rejected() -> None:
|
|
with pytest.raises(PayloadError, match="unsupported wire type"):
|
|
decode_payload(tag(1, 3))
|
|
|
|
|
|
def test_trailing_bytes_are_rejected() -> None:
|
|
"""Splice damage from page-level recovery must not decode as success."""
|
|
payload = manga(url="/manga/a") + tag(2, 1) + b"\x00" * 4
|
|
with pytest.raises(PayloadError, match="not fully consumed"):
|
|
decode_payload(payload)
|
|
|
|
|
|
def test_describe_is_human_readable() -> None:
|
|
text = decode_payload(backup(manga_count=2, chapters_each=1)).describe()
|
|
assert "2 manga" in text
|
|
assert "2 chapters" in text
|