mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 20:27:11 +01:00
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
197 lines
6.6 KiB
Python
197 lines
6.6 KiB
Python
"""HMAC-signed daily food log for diet_guard.
|
|
|
|
Each meal is stored as an individually HMAC-signed entry, reusing the shared
|
|
key at ``/etc/workout-locker/hmac.key`` -- the same key the screen locker uses
|
|
-- so the log is tamper-evident: editing the JSON to fake compliance
|
|
invalidates the signature. On a system without the shared key, entries are
|
|
written unsigned and still accepted on read, so the tool degrades gracefully
|
|
instead of silently losing the data it just wrote.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
import json
|
|
import logging
|
|
import os
|
|
from typing import TYPE_CHECKING
|
|
import uuid
|
|
|
|
from gatelock.log_integrity import (
|
|
compute_entry_hmac,
|
|
verify_entry_hmac,
|
|
)
|
|
|
|
from diet_guard._coerce import as_float
|
|
from diet_guard._constants import FOOD_LOG_FILE
|
|
|
|
if TYPE_CHECKING:
|
|
from diet_guard._estimator import Nutrition
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# On-disk shape: {"YYYY-MM-DD": [entry, entry, ...]}, newest entry last.
|
|
DayLog = dict[str, list[dict[str, object]]]
|
|
|
|
|
|
def now_local() -> datetime:
|
|
"""Return the current time as a timezone-aware local datetime."""
|
|
return datetime.now(tz=timezone.utc).astimezone()
|
|
|
|
|
|
def _today() -> str:
|
|
"""Return today's *local* date as ``YYYY-MM-DD``.
|
|
|
|
Local, not UTC: "what I ate today" is a local-calendar concept, and a meal
|
|
eaten late in the evening must not roll into tomorrow's budget.
|
|
"""
|
|
return now_local().date().isoformat()
|
|
|
|
|
|
def _entry_float(entry: dict[str, object], key: str) -> float:
|
|
"""Return ``entry[key]`` coerced to float (0.0 if missing/non-numeric).
|
|
|
|
``bool`` is rejected even though it subclasses ``int``: a boolean stored in
|
|
a calorie or macro field is meaningless and must not count as 1 gram.
|
|
|
|
Args:
|
|
entry: A stored log entry.
|
|
key: The numeric field name to read.
|
|
|
|
Returns:
|
|
The field as a float, or 0.0 when absent or not a real number.
|
|
"""
|
|
return as_float(entry.get(key))
|
|
|
|
|
|
def entry_kcal(entry: dict[str, object]) -> float:
|
|
"""Return an entry's calorie count as a float (0.0 if missing/invalid)."""
|
|
return _entry_float(entry, "kcal")
|
|
|
|
|
|
def _read_raw_log() -> DayLog:
|
|
"""Read the log file without verification (empty dict on any error)."""
|
|
if not FOOD_LOG_FILE.exists():
|
|
return {}
|
|
try:
|
|
with FOOD_LOG_FILE.open() as handle:
|
|
data = json.load(handle)
|
|
except (OSError, json.JSONDecodeError):
|
|
_logger.warning("Cannot read food log %s", FOOD_LOG_FILE)
|
|
return {}
|
|
if not isinstance(data, dict):
|
|
return {}
|
|
result: DayLog = {}
|
|
for key, value in data.items():
|
|
if isinstance(key, str) and isinstance(value, list):
|
|
result[key] = [item for item in value if isinstance(item, dict)]
|
|
return result
|
|
|
|
|
|
def _write_log(log: DayLog) -> None:
|
|
"""Persist the full log to disk, creating the data directory if needed.
|
|
|
|
Written atomically -- a temp file in the same directory, then
|
|
:func:`os.replace` -- so a concurrent reader (the gate now syncs while the
|
|
15-min timer may also be writing) never sees a half-written file and
|
|
mistakes a torn read for an empty log.
|
|
"""
|
|
FOOD_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp_path = FOOD_LOG_FILE.with_name(f"{FOOD_LOG_FILE.name}.{os.getpid()}.tmp")
|
|
try:
|
|
with tmp_path.open("w") as handle:
|
|
json.dump(log, handle, indent=2)
|
|
tmp_path.replace(FOOD_LOG_FILE)
|
|
finally:
|
|
tmp_path.unlink(missing_ok=True)
|
|
|
|
|
|
def _hmac_key_available() -> bool:
|
|
"""Return True if the shared HMAC key can be loaded for signing."""
|
|
return compute_entry_hmac({"_probe": True}) is not None
|
|
|
|
|
|
def _entry_is_valid(entry: dict[str, object]) -> bool:
|
|
"""Return True if an entry is untampered.
|
|
|
|
A signed entry must verify against the shared key. An unsigned entry is
|
|
accepted only when no key is available at all; an unsigned entry on a
|
|
system that *does* have a key means someone stripped the signature to
|
|
cheat, so it is rejected.
|
|
"""
|
|
if isinstance(entry.get("hmac"), str):
|
|
return verify_entry_hmac(entry)
|
|
return not _hmac_key_available()
|
|
|
|
|
|
def log_meal(
|
|
description: str,
|
|
nutrition: Nutrition,
|
|
slot: int | None = None,
|
|
*,
|
|
components: list[dict[str, object]] | None = None,
|
|
) -> dict[str, object]:
|
|
"""Append a signed entry for ``description`` to today's log.
|
|
|
|
Args:
|
|
description: The user's free-text meal description.
|
|
nutrition: Estimated nutrition for the portion eaten.
|
|
slot: The meal-slot hour this entry satisfies (e.g. ``12`` for the
|
|
12:00 checkpoint). When None the entry still counts toward the
|
|
day's calories but does not mark any slot as logged.
|
|
components: For a composite (multi-item) meal, each component's own
|
|
name and macros. Carried on the log entry itself -- not just the
|
|
food bank -- so a bank rebuilt purely by replaying the log (the
|
|
companion phone app's sync model) can recover every component's
|
|
standalone nutrition, not just the composite's summed total.
|
|
|
|
Returns:
|
|
The stored entry dict (carrying an ``hmac`` field when a key exists).
|
|
"""
|
|
entry: dict[str, object] = {
|
|
"id": str(uuid.uuid4()),
|
|
"time": now_local().isoformat(timespec="seconds"),
|
|
"desc": description,
|
|
"grams": nutrition.grams,
|
|
"kcal": nutrition.kcal,
|
|
"protein_g": nutrition.protein_g,
|
|
"carbs_g": nutrition.carbs_g,
|
|
"fat_g": nutrition.fat_g,
|
|
"source": nutrition.source,
|
|
}
|
|
if slot is not None:
|
|
entry["slot"] = slot
|
|
if components is not None:
|
|
entry["components"] = list(components)
|
|
signature = compute_entry_hmac(entry)
|
|
if signature is not None:
|
|
entry["hmac"] = signature
|
|
else:
|
|
_logger.warning("HMAC key unavailable - logging unsigned entry")
|
|
|
|
log = _read_raw_log()
|
|
log.setdefault(_today(), []).append(entry)
|
|
_write_log(log)
|
|
return entry
|
|
|
|
|
|
def load_log() -> DayLog:
|
|
"""Return the log with only valid, non-deleted entries retained.
|
|
|
|
A "deleted" entry is a tombstone left by :func:`undo_last_today`, not a
|
|
removal: it is kept on disk (and re-signed) rather than popped, so a sync
|
|
merge with another device can see the tombstone and not resurrect a
|
|
stale copy of the same entry. Readers simply filter it out here.
|
|
"""
|
|
raw = _read_raw_log()
|
|
verified: DayLog = {}
|
|
for day, entries in raw.items():
|
|
kept = [
|
|
entry
|
|
for entry in entries
|
|
if _entry_is_valid(entry) and not entry.get("deleted")
|
|
]
|
|
if kept:
|
|
verified[day] = kept
|
|
return verified
|