diet-guard/diet_guard/_state_sync.py
Krzysztof kuhy Rudnicki af461d3157 Split _state.py and _tk_fakes.py; make the food-log redirect fail closed
Eighth batch of the 250-line sweep (over-cap count 28 -> 25).

  _state.py       366 -> 197  (+ _state_today.py, _state_sync.py)
  test_state.py   400 -> 221  (+ test_state_today.py, test_state_sync.py)
  _tk_fakes.py    368 -> 167  (+ _tk_fakes_containers.py, _tk_fakes_canvas.py)

_state.py is the file conftest patches FOOD_LOG_FILE on, so the split is
constrained rather than free: `FOOD_LOG_FILE` and the two functions that open
it stay put, and the derived read model (_state_today) and the sync/undo
helpers (_state_sync) reach the file only through _state's private
_read_raw_log/_write_log. Callers were rewired to import from the new modules
directly rather than via a circular re-export.

That constraint is now enforced instead of merely documented. Moving a reader
of FOOD_LOG_FILE into a sibling would leave conftest's patch pointing at a
name nothing reads: check_patch_targets.py still exits 0 (the path resolves)
and the runtime is unchanged (a file write, not a network call), so the suite
would go green while writing to the real ~/.local/share/diet_guard and the
next sync tick would publish it. test_state_redirect.py asserts both halves --
only _constants and _state may name the constant, and the redirect
demonstrably moves a real write off the live path. Verified it fails closed by
adding a FOOD_LOG_FILE reader to _state_today: it named that module exactly.

_tk_fakes.py keeps _FAKE_TK/_FAKE_TTK and gains an explicit __all__, since the
re-exports are load-bearing and ruff would otherwise strip them as unused. The
identity canary from the previous commit covers the split: breaking one entry
in the assembly (Canvas=object) fails 8 gate tests loudly rather than silently
building real widgets.

Verified: 706 Python tests / 100% branch coverage, check_patch_targets 0,
pre-commit clean, ~/.local/share/diet_guard byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
2026-08-15 20:21:26 +02:00

112 lines
3.9 KiB
Python

"""Raw-log access, re-signing, and undo for the signed food log.
Split out of :mod:`._state` to keep both files under the repo's 250-line
limit. These are the *write*-side and sync-side helpers: unverified reads
(:func:`read_raw_log`), the merge write-back (:func:`write_raw_log`), the
per-entry re-sign the sync tick applies to every persisted entry
(:func:`resign_entry`), and the tombstoning undo.
They reach the file only through :mod:`._state`'s private ``_read_raw_log`` /
``_write_log``, which is where ``FOOD_LOG_FILE`` stays -- ``tests/conftest.py``
redirects the log by patching ``diet_guard._state.FOOD_LOG_FILE``, so a copy
of that constant here would be a patch the tests no longer reach. The names
are re-exported from :mod:`._state` for the existing call sites.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from gatelock.log_integrity import compute_entry_hmac
from diet_guard._state import _read_raw_log, _today, _write_log
if TYPE_CHECKING:
from diet_guard._state import DayLog
__all__ = [
"read_raw_log",
"resign_entry",
"undo_last_today",
"write_raw_log",
]
def read_raw_log() -> DayLog:
"""Return the log exactly as stored, including tombstoned/invalid entries.
Public counterpart of :func:`_read_raw_log`, for the sync orchestration
(:mod:`diet_guard._sync`), which must see tombstones to merge them (the
filtered :func:`load_log` drops them) and must not discard an entry that
fails verification just because a phone-origin copy was never signed.
"""
return _read_raw_log()
def write_raw_log(log: DayLog) -> None:
"""Persist ``log`` verbatim, overwriting the file on disk.
Public counterpart of :func:`_write_log`, for :mod:`diet_guard._sync` to
write back a merged log after re-signing it.
"""
_write_log(log)
def resign_entry(entry: dict[str, object]) -> dict[str, object]:
"""Return a copy of ``entry`` with a freshly computed ``hmac``.
Strips any existing signature first, mirroring :func:`undo_last_today`:
a signature computed on another device (or none, if the phone -- which
never holds the shared key -- produced this entry) cannot be trusted
as-is, and recomputing is the only way :func:`_entry_is_valid` will
accept it back on the next read. A no-op (signature-wise) when no HMAC
key is available locally, matching :func:`log_meal`'s degrade-gracefully
behavior.
Args:
entry: A log entry, signed or not.
Returns:
A new dict equal to ``entry`` except for its ``hmac`` field.
"""
resigned = dict(entry)
resigned.pop("hmac", None)
signature = compute_entry_hmac(resigned)
if signature is not None:
resigned["hmac"] = signature
return resigned
def undo_last_today() -> dict[str, object] | None:
"""Tombstone today's most recently logged, not-yet-undone entry.
Marks the entry ``deleted`` in place and re-signs it, rather than
physically removing it: a sync merge with another device only ever
*adds* entries it hasn't seen before, so a physical delete here would be
silently resurrected the next time that device's stale copy is pulled
back in. The tombstone travels with the entry instead, and every reader
(:func:`load_log`, the food-bank rebuild) already skips it.
Operates on the raw log so a mistaken entry can always be undone, even
one that would not pass verification.
Returns:
The tombstoned entry, or None if nothing undoable was logged today.
"""
log = _read_raw_log()
today = _today()
entries = log.get(today)
if not entries:
return None
for entry in reversed(entries):
if entry.get("deleted"):
continue
entry["deleted"] = True
entry.pop("hmac", None)
signature = compute_entry_hmac(entry)
if signature is not None:
entry["hmac"] = signature
_write_log(log)
return entry
return None