mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 16:33:49 +01:00
The budget was binary: 8h with a counted workout, 6h without. There was no base/bonus structure anywhere -- resolve_budget_seconds returned one of two absolute config values, and the "2h bonus" was emergent arithmetic (8 - 6) that appeared in no line of code. A second earner could not be bolted onto that shape without coupling the two. It is now a sum: 5h floor + 2h workout + 1h LeetCode, so a day is worth 5, 6, 7 or 8 hours. Note the deliberate tightening -- a workout-only day drops 8h to 7h, and a day that earns nothing drops 6h to 5h. The two earners are read independently and added. That is what "must not interfere with the workout" means in code: an unreadable LeetCode ledger leaves a workout day at 7h, not 5h, and there is a test asserting exactly that. LeetCode is read from leetcode-guard's ledger directly -- it answers even when leetcode-guard is not running, which is most of the day -- and falls back to its loopback endpoint. Credits are signature-checked; _leetcode_ledger reproduces gatelock's canonicalisation in eight lines because gatelock lives in the user's site-packages and this daemon runs as root, and a pinned vector fails loudly if that ever drifts. Failing to get an answer grants nothing AND raises an incident: desktop notification, warning log, and a line in leetcode_bonus_incidents.log (gitignored, so it never becomes an uncommitted change), rate-limited to once per reason per day. A silently missing hour looks exactly like an unearned one. resolve_budget_seconds is *renamed*, not wrapped: two functions would recreate the second resolution seam this module's docstring warns about. rules_for calls it once and carries the breakdown on PlaytimeRules, so /api/budget reports why today is what it is without resolving again. Known limitation, accepted: leetcode-guard only writes credits from its lock window's poll loop, so a voluntary extra solve after the day is settled never reaches the ledger. Verified live against real services -- all four cases (5/6/7/8h), the endpoint fallback, the fail-closed path with its notification, and the rate limit. 1782 tests, 100% branch coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fp6zWH9zW1AkGdDyKjrbi8
143 lines
4.9 KiB
Python
143 lines
4.9 KiB
Python
"""Reading leetcode-guard's ledger for today's accepted submissions.
|
|
|
|
Credits are verified against the shared HMAC key, reproducing gatelock's
|
|
canonicalisation in eight lines rather than importing it: gatelock is installed
|
|
into the user's site-packages and this daemon runs as root, so the import is
|
|
not available. ``test_leetcode_ledger.py`` pins the canonicalisation to a fixed
|
|
vector so the duplication cannot drift silently.
|
|
|
|
Only ``credit`` entries count. A ``seen`` entry is first-run seeding worth
|
|
zero, and a ``charge`` proves only that the day was *settled* -- which also
|
|
happens from banked credit, from the escape hatch and from a classified
|
|
outage, so it can never stand in for a solve.
|
|
|
|
The solve time is ``detail["submitted_at"]``, LeetCode's own timestamp, not the
|
|
entry's ``day``: ``day`` is stamped by the *harvesting* run, so a problem
|
|
solved at 23:50 and harvested the next morning carries the next day's key.
|
|
|
|
Every unreadable state returns ``None``, never ``False``. "Cannot check" is not
|
|
"not solved", and only one of the two is worth waking the user about.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, time
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any, Final
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# The key every locker signs its state with. Root-owned, world-readable.
|
|
HMAC_KEY_FILE: Final = Path("/etc/workout-locker/hmac.key")
|
|
|
|
_CREDIT: Final = "credit"
|
|
|
|
|
|
def _verified(entry: dict[str, Any], key: bytes) -> bool:
|
|
"""Whether a ledger entry's HMAC signature is genuine.
|
|
|
|
Args:
|
|
entry: The full entry, including its ``hmac`` field.
|
|
key: The shared signing key.
|
|
|
|
Returns:
|
|
Whether the signature matches. An unverified credit is worth an hour of
|
|
gaming time, so a forged one must not count.
|
|
"""
|
|
stored = entry.get("hmac")
|
|
if not isinstance(stored, str):
|
|
return False
|
|
body = {k: v for k, v in entry.items() if k != "hmac"}
|
|
payload = json.dumps(body, sort_keys=True, separators=(",", ":"))
|
|
expected = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()
|
|
return hmac.compare_digest(stored, expected)
|
|
|
|
|
|
def _landed_today(entry: dict[str, Any], *, window: tuple[float, float]) -> bool:
|
|
"""Whether this credit's accepted submission happened today, locally.
|
|
|
|
Args:
|
|
entry: One verified credit entry.
|
|
window: Local midnight and now, as unix seconds.
|
|
|
|
Returns:
|
|
Whether the solve falls inside today. ``detail.submitted_at`` is
|
|
LeetCode's own timestamp; ``day`` is the *harvest* day and runs late, so
|
|
it is only a fallback -- it can miss a late solve, never invent one.
|
|
"""
|
|
detail = entry.get("detail")
|
|
raw = detail.get("submitted_at") if isinstance(detail, dict) else None
|
|
if raw is not None:
|
|
try:
|
|
stamp = float(str(raw))
|
|
except ValueError:
|
|
logger.warning(
|
|
"LeetCode credit %r has an unparsable submitted_at (%r); "
|
|
"falling back to its day key",
|
|
entry.get("entry_id"),
|
|
raw,
|
|
)
|
|
else:
|
|
start, end = window
|
|
return start <= stamp <= end
|
|
today = datetime.now().astimezone().date().isoformat()
|
|
return entry.get("day") == today
|
|
|
|
|
|
def _today_window() -> tuple[float, float]:
|
|
"""Local midnight and now, as unix seconds.
|
|
|
|
Returns:
|
|
The inclusive window a solve must fall in to count for today.
|
|
"""
|
|
moment = datetime.now().astimezone()
|
|
midnight = datetime.combine(moment.date(), time.min, moment.tzinfo)
|
|
return midnight.timestamp(), moment.timestamp()
|
|
|
|
|
|
def read_ledger_solved_today(path: Path) -> bool | None:
|
|
"""Read leetcode-guard's ledger and count today's verified credits.
|
|
|
|
Args:
|
|
path: The ledger file.
|
|
|
|
Returns:
|
|
True or False when the ledger could be read and verified, ``None`` when
|
|
it could not -- which is never "nothing was solved".
|
|
"""
|
|
try:
|
|
key = HMAC_KEY_FILE.read_bytes().strip()
|
|
except OSError as exc:
|
|
logger.warning("Cannot read the integrity key at %s (%s)", HMAC_KEY_FILE, exc)
|
|
return None
|
|
if not key:
|
|
logger.warning("Integrity key at %s is empty", HMAC_KEY_FILE)
|
|
return None
|
|
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except OSError as exc:
|
|
logger.warning("Cannot read the LeetCode ledger at %s (%s)", path, exc)
|
|
return None
|
|
except ValueError as exc:
|
|
logger.warning("LeetCode ledger at %s is not valid JSON (%s)", path, exc)
|
|
return None
|
|
|
|
rows = raw.get("entries") if isinstance(raw, dict) else None
|
|
if not isinstance(rows, list):
|
|
logger.warning("LeetCode ledger at %s has no entries array", path)
|
|
return None
|
|
|
|
window = _today_window()
|
|
return any(
|
|
isinstance(row, dict)
|
|
and row.get("kind") == _CREDIT
|
|
and _verified(row, key)
|
|
and _landed_today(row, window=window)
|
|
for row in rows
|
|
)
|