mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 16:03:03 +02:00
Add the diet_guard package: a screen-locking meal-logging gate that fires on 4-hour slots (08/12/16/20) and records calories/macros, persisting an autocompleting food bank. - Trigger fix: the systemd timer fires at session start (Persistent=true) before lightdm has written ~/.Xauthority, so the gate crashed with a TclError instead of locking the screen. Add wait_for_display() / _display_is_ready() in _gatelock.py and wire it into _cli._cmd_gate so the gate retries on the next tick instead of crashing; add Environment=XAUTHORITY=%h/.Xauthority to the service as belt-and-suspenders. - Food-bank hardening: a transiently corrupt food_bank.json was warned about on every keystroke and then silently overwritten (data loss). _read_bank now quarantines it via _quarantine_corrupt_bank() (warn-once + timestamped backup) before starting fresh. - Multi-item meals: new _meal.py (MealItem, meal_total, MEAL_SOURCE), remember_meal() + _upsert() in _foodbank.py, and a "+ Add item" control in the gate that logs both the individual items and the composite meal. - Bundle resolve_nutrition's manual macros into a ManualMacros dataclass to stay within the argument-count limit. diet_guard at 100% branch coverage; full pre-commit suite passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
318 lines
11 KiB
Python
318 lines
11 KiB
Python
"""Hidden, tamper-hardened daily calorie budget for diet_guard.
|
|
|
|
This mirrors how ``phone_focus_mode`` hides the home GPS coordinates: the real
|
|
number never lives in committed source and is never printed. It is computed
|
|
once from biometrics at ``init`` time, written to a runtime-only file in the
|
|
XDG data dir (git-ignored, exactly like the phone's ``config_secrets.sh``), and
|
|
locked with ``chattr +i`` so changing it costs a deliberate ``sudo`` step.
|
|
|
|
Honest threat model (the same one the phone accepts):
|
|
|
|
* The point is **friction in a weak moment**, not cryptographic secrecy. The
|
|
Mifflin-St Jeor formula is right here in source and you know your own weight,
|
|
so a determined you can always recompute the number -- just as a determined
|
|
you can root the phone and read the coordinates.
|
|
* The shared HMAC key at ``/etc/workout-locker/hmac.key`` is world-readable, so
|
|
the signature defeats a *naive* edit (and detects disk corruption) but not
|
|
someone who reads the key and re-signs. The signature is tamper-*evidence*,
|
|
not a tamper-*lock*.
|
|
* The real lock is ``chattr +i``: removing the immutable bit needs root, which
|
|
is the actual speed bump. The strongest layer of all is simply that the
|
|
value is never displayed, so there is no on-screen anchor to fixate on.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
from dataclasses import dataclass
|
|
import hmac
|
|
import json
|
|
import logging
|
|
|
|
from python_pkg.diet_guard._constants import BUDGET_FILE
|
|
from python_pkg.shared.log_integrity import compute_entry_hmac
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# Schema version stored inside the sealed blob, so a future format change can be
|
|
# detected rather than silently misread. v2 adds the optional body weight (``w``)
|
|
# used to derive a protein target; v1 seals (budget only) still read correctly.
|
|
_SEAL_VERSION = 2
|
|
|
|
# A medically sane lower bound. Even an aggressive deficit must not seal a
|
|
# starvation-level target, so the computed value is floored here.
|
|
_MIN_SANE_BUDGET = 1200
|
|
|
|
# Daily protein target for an active adult holding muscle on a deficit, in grams
|
|
# per kg of body weight. Used only to show a target in the dashboard; it has no
|
|
# part in the sealed calorie budget maths.
|
|
PROTEIN_G_PER_KG = 1.8
|
|
|
|
# Probe payload used only to check whether the shared HMAC key can be loaded.
|
|
_KEY_PROBE: dict[str, object] = {"_probe": True}
|
|
|
|
|
|
class BudgetError(Exception):
|
|
"""Base class for all budget-access failures."""
|
|
|
|
|
|
class BudgetNotInitializedError(BudgetError):
|
|
"""Raised when no sealed budget exists yet (``init`` never run)."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize with a fixed, side-effect-free message."""
|
|
super().__init__("daily budget has not been initialized")
|
|
|
|
|
|
class BudgetSealBrokenError(BudgetError):
|
|
"""Raised when the sealed budget is unreadable, corrupt, or tampered."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize with a fixed, side-effect-free message."""
|
|
super().__init__("daily budget seal is broken (tampered or corrupted)")
|
|
|
|
|
|
class BudgetLockedError(BudgetError):
|
|
"""Raised when the budget file is immutable and cannot be rewritten."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize with a fixed, side-effect-free message."""
|
|
super().__init__("daily budget file is locked (chattr +i)")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Biometrics:
|
|
"""Body metrics that feed the Mifflin-St Jeor budget formula.
|
|
|
|
Grouped into one value object so the budget calculation stays under the
|
|
repo's five-argument lint ceiling and so the inputs travel together.
|
|
|
|
Attributes:
|
|
weight_kg: Body mass in kilograms.
|
|
height_cm: Height in centimetres.
|
|
age_years: Age in years.
|
|
is_male: True for the male BMR constant (+5), False for female (-161).
|
|
"""
|
|
|
|
weight_kg: float
|
|
height_cm: float
|
|
age_years: float
|
|
is_male: bool
|
|
|
|
|
|
def mifflin_st_jeor_bmr(bio: Biometrics) -> float:
|
|
"""Return resting metabolic rate via the Mifflin-St Jeor equation.
|
|
|
|
Args:
|
|
bio: The person's body metrics.
|
|
|
|
Returns:
|
|
Basal metabolic rate in kcal/day.
|
|
"""
|
|
base = 10.0 * bio.weight_kg + 6.25 * bio.height_cm - 5.0 * bio.age_years
|
|
return base + 5.0 if bio.is_male else base - 161.0
|
|
|
|
|
|
def compute_target_budget(
|
|
bio: Biometrics,
|
|
*,
|
|
activity_factor: float,
|
|
deficit_kcal: float,
|
|
) -> int:
|
|
"""Return the daily kcal target: TDEE minus a deficit, floored for safety.
|
|
|
|
TDEE (total daily energy expenditure) is the BMR scaled by an activity
|
|
factor; subtracting a deficit yields a target that drives gradual loss.
|
|
|
|
Args:
|
|
bio: The person's body metrics.
|
|
activity_factor: Multiplier for daily activity (e.g. 1.2 sedentary,
|
|
1.375 light, 1.55 moderate, 1.725 very active).
|
|
deficit_kcal: Calories subtracted from TDEE for weight loss.
|
|
|
|
Returns:
|
|
The target budget in kcal, never below ``_MIN_SANE_BUDGET``.
|
|
"""
|
|
bmr = mifflin_st_jeor_bmr(bio)
|
|
tdee = bmr * activity_factor
|
|
target = round(tdee - deficit_kcal)
|
|
return max(target, _MIN_SANE_BUDGET)
|
|
|
|
|
|
def _hmac_key_available() -> bool:
|
|
"""Return True if the shared HMAC key can be loaded for signing."""
|
|
return compute_entry_hmac(_KEY_PROBE) is not None
|
|
|
|
|
|
def is_initialized() -> bool:
|
|
"""Return True if a sealed budget file exists on disk."""
|
|
return BUDGET_FILE.exists()
|
|
|
|
|
|
def lock_command() -> str:
|
|
"""Return the shell command that makes the sealed budget immutable."""
|
|
return f"sudo chattr +i {BUDGET_FILE}"
|
|
|
|
|
|
def unlock_command() -> str:
|
|
"""Return the shell command that clears the immutable bit before re-init."""
|
|
return f"sudo chattr -i {BUDGET_FILE}"
|
|
|
|
|
|
def seal_budget(value: int, *, weight_kg: float | None = None) -> None:
|
|
"""Write ``value`` to the runtime budget file, base64-wrapped and signed.
|
|
|
|
The value is JSON-encoded, base64-wrapped (so a casual ``cat`` shows no
|
|
recognizable number) and HMAC-signed (so a naive edit is detectable). The
|
|
file is *not* made immutable here -- that needs root; the caller prints
|
|
:func:`lock_command` for the user (or install.sh) to run.
|
|
|
|
Args:
|
|
value: The computed daily budget in kcal.
|
|
weight_kg: Body weight in kg to store alongside the budget, so a protein
|
|
target can later be derived. Optional; omitting it seals a
|
|
budget-only blob that reads back with no protein target.
|
|
|
|
Raises:
|
|
BudgetLockedError: If the existing file is immutable (run
|
|
:func:`unlock_command` first).
|
|
"""
|
|
inner: dict[str, object] = {"v": _SEAL_VERSION, "b": int(value)}
|
|
if weight_kg is not None:
|
|
inner["w"] = round(float(weight_kg), 1)
|
|
blob = json.dumps(inner, sort_keys=True, separators=(",", ":")).encode()
|
|
record: dict[str, object] = {"data": base64.b64encode(blob).decode("ascii")}
|
|
signature = compute_entry_hmac(inner)
|
|
if signature is not None:
|
|
record["hmac"] = signature
|
|
else:
|
|
_logger.warning("HMAC key unavailable - sealing budget unsigned")
|
|
|
|
BUDGET_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
with BUDGET_FILE.open("w") as handle:
|
|
json.dump(record, handle)
|
|
except PermissionError as exc:
|
|
raise BudgetLockedError from exc
|
|
|
|
|
|
def _decode_inner(record: object) -> dict[str, object]:
|
|
"""Return the inner payload dict from a parsed seal record.
|
|
|
|
Raises:
|
|
BudgetSealBrokenError: If the record shape or base64 is invalid.
|
|
"""
|
|
if not isinstance(record, dict):
|
|
raise BudgetSealBrokenError
|
|
data = record.get("data")
|
|
if not isinstance(data, str):
|
|
raise BudgetSealBrokenError
|
|
try:
|
|
inner = json.loads(base64.b64decode(data, validate=True))
|
|
except (binascii.Error, ValueError) as exc:
|
|
raise BudgetSealBrokenError from exc
|
|
if not isinstance(inner, dict):
|
|
raise BudgetSealBrokenError
|
|
return inner
|
|
|
|
|
|
def _verify_signature(record: dict[str, object], inner: dict[str, object]) -> None:
|
|
"""Check the seal's HMAC, mirroring the food log's degradation rules.
|
|
|
|
A present signature must verify. A missing signature is tolerated only on a
|
|
system with no key at all; a stripped signature where a key *is* available
|
|
means someone removed it to cheat.
|
|
|
|
Raises:
|
|
BudgetSealBrokenError: If the signature is missing-but-keyed, or wrong.
|
|
"""
|
|
stored = record.get("hmac")
|
|
if isinstance(stored, str):
|
|
expected = compute_entry_hmac(inner)
|
|
if expected is None or not hmac.compare_digest(stored, expected):
|
|
raise BudgetSealBrokenError
|
|
return
|
|
if _hmac_key_available():
|
|
raise BudgetSealBrokenError
|
|
|
|
|
|
def _load_verified_inner() -> dict[str, object]:
|
|
"""Read, decode, and integrity-check the sealed blob, returning its payload.
|
|
|
|
Returns:
|
|
The inner payload dict (carrying ``b`` and, for v2 seals, ``w``).
|
|
|
|
Raises:
|
|
BudgetNotInitializedError: If no budget has been sealed yet.
|
|
BudgetSealBrokenError: If the file is corrupt, mis-typed, or tampered.
|
|
"""
|
|
if not BUDGET_FILE.exists():
|
|
raise BudgetNotInitializedError
|
|
try:
|
|
with BUDGET_FILE.open() as handle:
|
|
record = json.load(handle)
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
raise BudgetSealBrokenError from exc
|
|
inner = _decode_inner(record)
|
|
# ``record`` is a dict here: _decode_inner rejects any non-dict already.
|
|
_verify_signature(record, inner)
|
|
return inner
|
|
|
|
|
|
def daily_budget() -> int:
|
|
"""Return the sealed daily budget, verifying integrity first.
|
|
|
|
This is the only way callers obtain the number, and they must use it for an
|
|
internal decision (over/under) without printing it.
|
|
|
|
Returns:
|
|
The daily kcal budget.
|
|
|
|
Raises:
|
|
BudgetNotInitializedError: If no budget has been sealed yet.
|
|
BudgetSealBrokenError: If the file is corrupt, mis-typed, or tampered.
|
|
"""
|
|
inner = _load_verified_inner()
|
|
value = inner.get("b")
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
raise BudgetSealBrokenError
|
|
return value
|
|
|
|
|
|
def budget_weight() -> float | None:
|
|
"""Return the body weight stored with the budget, or None if unavailable.
|
|
|
|
Returns:
|
|
The stored weight in kg, or None for a pre-v2 (budget-only) seal.
|
|
|
|
Raises:
|
|
BudgetNotInitializedError: If no budget has been sealed yet.
|
|
BudgetSealBrokenError: If the file is corrupt, mis-typed, or tampered.
|
|
"""
|
|
inner = _load_verified_inner()
|
|
value = inner.get("w")
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
return None
|
|
return float(value)
|
|
|
|
|
|
def protein_target_g() -> float | None:
|
|
"""Return the daily protein target in grams, or None if it cannot be derived.
|
|
|
|
Derived from the stored body weight at :data:`PROTEIN_G_PER_KG`. Returns
|
|
None -- rather than raising -- whenever the target is simply unavailable (no
|
|
budget sealed, a pre-v2 seal without weight, or a broken seal), so the
|
|
dashboard can show calories and quietly omit the protein line.
|
|
|
|
Returns:
|
|
The protein target in grams, or None when weight is unknown.
|
|
"""
|
|
try:
|
|
weight = budget_weight()
|
|
except BudgetError:
|
|
return None
|
|
if weight is None:
|
|
return None
|
|
return round(weight * PROTEIN_G_PER_KG, 1)
|