testsAndMisc/linux_configuration/scripts/single_use/features/setup_syncyomi_guard.sh
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

124 lines
3.6 KiB
Bash
Executable File

#!/bin/bash
# ============================================================================
# Install the SyncYomi library integrity guard as a user systemd timer.
#
# TachiyomiSY 1.13.2 can finish a backup restore, report success, and have
# written almost nothing (upstream jobobby04/TachiyomiSY#1634, #1638). The app
# then syncs that near-empty library up, replacing the good server copy. On
# 2026-08-09 the real library survived only as stale pages in the SQLite WAL.
#
# The guard cannot fix the app. It makes the silent case loud, and keeps
# restorable .tachibk snapshots so recovery never again depends on WAL forensics.
# ============================================================================
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
REPO_ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)"
readonly REPO_ROOT
readonly UNIT_DIR="${HOME}/.config/systemd/user"
readonly SERVICE_NAME="syncyomi-guard.service"
readonly TIMER_NAME="syncyomi-guard.timer"
readonly SNAPSHOT_DIR="${HOME}/syncyomi/snapshots"
readonly STATE_DIR="${HOME}/.local/share/syncyomi_guard"
readonly DB_PATH="${HOME}/syncyomi/config/syncyomi.db"
log_info() { echo "[*] $*"; }
log_ok() { echo "[+] $*"; }
die() {
echo "[!] $*" >&2
exit 1
}
usage() {
echo "Usage: ${SCRIPT_NAME} [--uninstall]"
echo " Installs a user timer that verifies the SyncYomi payload every 30 min."
exit 0
}
require_python() {
command -v python3 >/dev/null 2>&1 ||
die "python3 not found — install it with: sudo pacman -S python"
}
# The guard reads the database through sudo-less paths, so it must be able to
# open it. A root-owned db (the container writes as root) is readable only if
# the mode allows it; check now rather than failing silently on the first timer.
check_readable() {
[[ -f ${DB_PATH} ]] || die "no SyncYomi database at ${DB_PATH}"
if ! head -c 1 "${DB_PATH}" >/dev/null 2>&1; then
die "cannot read ${DB_PATH} as $(whoami) — run: sudo chmod o+r '${DB_PATH}'"
fi
}
write_units() {
mkdir -p "${UNIT_DIR}" "${SNAPSHOT_DIR}" "${STATE_DIR}"
cat >"${UNIT_DIR}/${SERVICE_NAME}" <<EOF
# Generated by ${SCRIPT_NAME} — do not edit by hand.
[Unit]
Description=Verify the SyncYomi library payload has not silently collapsed
Documentation=https://github.com/jobobby04/TachiyomiSY/issues/1634
[Service]
Type=oneshot
WorkingDirectory=${REPO_ROOT}
Environment=PYTHONPATH=${REPO_ROOT}
ExecStart=/usr/bin/python3 -m python_pkg.syncyomi_guard \\
--db ${DB_PATH} \\
--state-dir ${STATE_DIR} \\
--snapshot-dir ${SNAPSHOT_DIR}
EOF
cat >"${UNIT_DIR}/${TIMER_NAME}" <<EOF
# Generated by ${SCRIPT_NAME} — do not edit by hand.
[Unit]
Description=Periodic SyncYomi library integrity check
[Timer]
OnCalendar=*:00/30
Persistent=true
[Install]
WantedBy=timers.target
EOF
log_ok "Wrote ${UNIT_DIR}/${SERVICE_NAME} and ${TIMER_NAME}"
}
install_timer() {
require_python
check_readable
write_units
systemctl --user daemon-reload
systemctl --user enable --now "${TIMER_NAME}"
log_ok "Timer enabled (every 30 minutes)"
log_info "Running one check now"
if systemctl --user start "${SERVICE_NAME}"; then
systemctl --user status "${SERVICE_NAME}" --no-pager -n 10 || true
else
die "first run failed — inspect: journalctl --user -u ${SERVICE_NAME}"
fi
}
uninstall_timer() {
systemctl --user disable --now "${TIMER_NAME}" 2>/dev/null || true
rm -f "${UNIT_DIR}/${SERVICE_NAME}" "${UNIT_DIR}/${TIMER_NAME}"
systemctl --user daemon-reload
log_ok "Removed timer and service (snapshots in ${SNAPSHOT_DIR} kept)"
}
main() {
case "${1:-}" in
--uninstall) uninstall_timer ;;
-h | --help) usage ;;
"") install_timer ;;
*) die "Unknown option: $1" ;;
esac
}
main "$@"