mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 20:27:11 +01:00
Four asks, plus the fixes two review passes turned up. Removals (all unused): - "Repeat last meal" button, and the reward/temptation-bundling prompt it was the sole trigger for. Replaced with a post-log progress card showing kcal against budget, macros, and the adherence streak. - The multi-item meal builder, on both the gate and the phone. The stored `components` field stays: nothing writes it now, but historical composite entries and the food bank still read it and it is part of the wire format. - Meal photos entirely: 13 files, `imagePath`, the photo filter, the wrapper's /blobs/ route, image_picker, and the CAMERA permission. Reminder false alarms had three real causes, all fixed: - Logging never reconciled notifications, so a reminder sat on screen up to 15 minutes after the meal was logged -- on the phone itself. - The background check never pulled, so a PC-logged meal nagged forever until the app was opened. Now mirrors _cli_gate._should_lock: cheap local check, network only when a slot looks due, then re-check. - Meals before 08:00 / after 22:00 were attributed to no slot at all. New slot_for_log/slotForLog clamps them to the first/last slot, byte-identical across Python and Dart. Deliberately separate from elapsed_slots, which would otherwise make every slot fall due at 23:00. Budget history, so lowering the budget stops rewriting the past: - Forward-only (effective_from, kcal) entries; each day is judged against the budget in force then. Verified live: setting 2000 left a 36-day adherence streak and the whole calendar pixel-identical. - Rides as hist:<date> fields on the existing budget record. crdt_sync merges per-field over the union of field names and both devices push the merged log, so a device that predates the feature relays them untouched -- no coordinated rollout, no second document. - The seed guard tests emptiness, not file presence: _sync can write an empty history after merging with a pre-feature peer, and a presence check would then never seed again, silently reclassifying every past day. One shared source of truth for every piece of data: - Body weight now travels as its own `weight` CRDT field. Inside `value` it was collateral damage of whole-map LWW -- any phone budget edit deleted it, and the protein target with it. - Both halves of the food bank sync. The curated half (foods added without eating them) was the last device-local state. The derived half merges with count as the clock, so LWW means max-count-wins; the log stays authoritative for membership, because a CRDT union never shrinks and a peer's stale copy would otherwise resurrect a deleted food permanently. - Bank adapters honour tombstones, and Dart parses bank records defensively: a cast failure there is an Error, not an Exception, so one malformed remote record would have broken phone sync permanently. _test_guard.py blocks any pathlib write to real user state while pytest is in the process, whatever conftest is loaded. A throwaway test run outside the package conftest destroyed a real GitHub token during this work; this makes that class of accident impossible rather than merely discouraged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
111 lines
4.2 KiB
Python
111 lines
4.2 KiB
Python
"""Make it impossible for a test to write to real user state.
|
|
|
|
``diet_guard/tests/conftest.py`` redirects every known state path into
|
|
``tmp_path``, but that only protects tests that *load that conftest*. On
|
|
2026-07-26 a throwaway test written into a scratch directory ran outside it,
|
|
wrote to the real ``~/.config/diet_guard/sync_token``, and destroyed the
|
|
user's GitHub PAT; the same session also created stray files under
|
|
``~/.local/share/diet_guard/``.
|
|
|
|
A redirect is a convention -- something a new test can forget or sidestep.
|
|
This is a guard: while pytest is in the process, any ``pathlib.Path`` write
|
|
under the real diet_guard data or config directory raises immediately,
|
|
whatever conftest is or is not in play, and whatever module forgot to be
|
|
patched. Reads are left alone, so a test may still assert that a real path
|
|
is absent.
|
|
|
|
Scope, honestly: it covers ``pathlib.Path`` -- which is what every diet_guard
|
|
writer uses -- not builtin ``open()``, ``os.replace`` or ``shutil``. It is a
|
|
hardened barrier across the paths this codebase actually takes, not a
|
|
kernel-level sandbox.
|
|
|
|
Inert outside pytest: production never imports pytest, so the single
|
|
``sys.modules`` check below is the whole cost.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pathlib
|
|
import sys
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
|
|
def _resolved(path: pathlib.Path) -> pathlib.Path:
|
|
"""Resolve ``path`` as far as the filesystem allows, never raising."""
|
|
try:
|
|
return path.expanduser().resolve()
|
|
except (OSError, RuntimeError, ValueError):
|
|
return path
|
|
|
|
|
|
# Resolved once, from the real home -- deliberately NOT from _constants, whose
|
|
# values a test may legitimately have patched to a temp path. Resolving here
|
|
# matters: the candidate paths are resolved too, so if $HOME (or .local, or
|
|
# .local/share) is a symlink an unresolved root would never match and the
|
|
# guard would silently fail *open* -- the worst possible failure for a safety
|
|
# net.
|
|
_PROTECTED = (
|
|
_resolved(pathlib.Path.home() / ".local" / "share" / "diet_guard"),
|
|
_resolved(pathlib.Path.home() / ".config" / "diet_guard"),
|
|
)
|
|
|
|
|
|
class RealUserStateWriteError(RuntimeError):
|
|
"""Raised when a test tries to write to the user's real diet_guard state."""
|
|
|
|
def __init__(self, path: pathlib.Path) -> None:
|
|
"""Name the offending path and how to fix the test."""
|
|
super().__init__(
|
|
f"test tried to write real user state at {path}. Point the relevant "
|
|
"path at tmp_path (see diet_guard/tests/conftest.py's "
|
|
"_isolate_state) instead of writing the real file.",
|
|
)
|
|
|
|
|
|
def _is_protected(path: pathlib.Path) -> bool:
|
|
"""Return True if ``path`` lies inside a real user-state directory."""
|
|
resolved = _resolved(pathlib.Path(path))
|
|
return any(resolved.is_relative_to(root) for root in _PROTECTED)
|
|
|
|
|
|
# Marks an already-wrapped method, so installing twice is a no-op without
|
|
# needing module-level mutable state.
|
|
_MARKER = "_diet_guard_write_guarded"
|
|
|
|
|
|
def _guard(name: str, is_write: Callable[..., bool]) -> None:
|
|
"""Wrap ``pathlib.Path.<name>`` so protected writes raise.
|
|
|
|
Idempotent per method: a second call finds the marker and returns, so the
|
|
wrapper can never be stacked on itself.
|
|
"""
|
|
original = getattr(pathlib.Path, name)
|
|
if getattr(original, _MARKER, False):
|
|
return
|
|
|
|
def wrapper(self: pathlib.Path, *args: object, **kwargs: object) -> object:
|
|
if is_write(*args, **kwargs) and _is_protected(self):
|
|
raise RealUserStateWriteError(self)
|
|
return original(self, *args, **kwargs)
|
|
|
|
wrapper.__name__ = name
|
|
setattr(wrapper, _MARKER, True)
|
|
setattr(pathlib.Path, name, wrapper)
|
|
|
|
|
|
def install() -> None:
|
|
"""Install the guard, but only when running under pytest.
|
|
|
|
Safe to call repeatedly: each :func:`_guard` is individually idempotent.
|
|
"""
|
|
if "pytest" not in sys.modules:
|
|
return
|
|
_guard("open", lambda mode="r", *_a, **_k: any(c in mode for c in "wxa+"))
|
|
# ``mkdir`` is guarded too: creating the real state directory is half of
|
|
# what the 2026-07-26 accident did, and every writer calls it first.
|
|
for name in ("write_text", "write_bytes", "unlink", "rename", "replace", "mkdir"):
|
|
_guard(name, lambda *_a, **_k: True)
|