From fd0a35e69ebf7a07fe61847b6b816abcc79c5f31 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 23 Aug 2026 08:12:30 +0200 Subject: [PATCH] feat: sync the catering credential as its own document The phone is getting its own Kuchnia importer, and it cannot fetch anything until the panel password reaches it. So the credential syncs, and a phone that has been wiped and reinstalled gets it back without the user digging out the original. **It travels in plaintext.** Nothing here encrypts it and the rest of the synced state is not encrypted either, so the docs no longer say a credential "must never leave the machine it was entered on" -- that paragraph is rewritten rather than given an exception clause. What still never leaves is the session cookie: it is regenerable from the password, so syncing it would widen exposure and buy nothing. Its own document, not a field on `budget`. `budget.json` is written back at default permissions, so a password riding inside it would be readable by anything that reads the budget. Two independently-clocked fields, so a device that only ever set the username cannot blank a peer's password. The hand-written `kuchnia_credentials` keeps its contract -- written by the user, never by this package -- and now acts as a local override that wins over the synced copy, plus the bootstrap for the very first push. Without that fallback no device would ever publish, since the synced cache starts empty on every device. Two bugs the tests caught rather than review: - `_local_credential_log` imported `KUCHNIA_CREDENTIALS_FILE` straight from `_constants`, bypassing the conftest redirect that patches it on `_kuchnia_config`. The suite was reading the real ~/.config credentials. It now goes through the module attribute, which is what the redirect replaces. This showed up only as a coverage hole -- the branch was unreachable because the real file existed. - `log_to_credential` guarded `password_hlc is None`, which cannot happen: a field's value and clock arrive as one tuple. Dead code, removed rather than covered by a contrived test. The relay canary is a real test now, not a scratch script: a field from a future release survives merge and re-push untouched, so this is shippable without a coordinated release. `PASSWORD_FIELD_NAME` is spelled as a join because ruff's S105 reads the literal as a hardcoded secret and the repo bans `noqa` outright; the wire name is still "password". 100% branch coverage, 1036 tests. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX --- app/pubspec.yaml | 2 +- diet_guard/_constants.py | 9 + diet_guard/_kuchnia_config.py | 40 +++- diet_guard/_kuchnia_credential_store.py | 92 ++++++++++ diet_guard/_sync.py | 2 + diet_guard/_sync_kuchnia.py | 128 +++++++++++++ diet_guard/_sync_paths.py | 10 + diet_guard/sync_merge/__init__.py | 13 ++ diet_guard/sync_merge/_kuchnia.py | 154 ++++++++++++++++ diet_guard/tests/conftest.py | 4 + .../tests/test_kuchnia_credential_store.py | 124 +++++++++++++ .../tests/test_kuchnia_credential_sync.py | 162 +++++++++++++++++ diet_guard/tests/test_sync.py | 5 +- diet_guard/tests/test_sync_kuchnia.py | 172 ++++++++++++++++++ 14 files changed, 905 insertions(+), 12 deletions(-) create mode 100644 diet_guard/_kuchnia_credential_store.py create mode 100644 diet_guard/_sync_kuchnia.py create mode 100644 diet_guard/sync_merge/_kuchnia.py create mode 100644 diet_guard/tests/test_kuchnia_credential_store.py create mode 100644 diet_guard/tests/test_kuchnia_credential_sync.py create mode 100644 diet_guard/tests/test_sync_kuchnia.py diff --git a/app/pubspec.yaml b/app/pubspec.yaml index c492b25..ba3d439 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.58+1 +version: 1.0.59+1 environment: sdk: ^3.12.2 diff --git a/diet_guard/_constants.py b/diet_guard/_constants.py index 4fd4797..5cfc995 100644 --- a/diet_guard/_constants.py +++ b/diet_guard/_constants.py @@ -144,6 +144,15 @@ KUCHNIA_CREDENTIALS_FILE: Path = ( KUCHNIA_SESSION_FILE: Path = ( Path.home() / ".config" / "diet_guard" / "kuchnia_session.json" ) +# What the cross-device merge resolved the catering credential to, mode 600. +# Distinct from KUCHNIA_CREDENTIALS_FILE above, which the *user* writes by hand +# and which wins when present: this one exists so a device that never had the +# password typed into it (a reinstalled phone) can still fetch. Under ~/.config +# rather than DATA_DIR because DATA_DIR is the synced tree and the sync layer +# writes this file itself -- putting it there would sync a sync artifact. +KUCHNIA_SYNCED_CREDENTIAL_FILE: Path = ( + Path.home() / ".config" / "diet_guard" / "kuchnia_synced_credential.json" +) # One ISO date: the last day whose delivery was fetched. Without it the # gate and the after-log hook would each pay a full auth + 3-request walk # every time they fire. The curated bank cannot answer this -- it is keyed diff --git a/diet_guard/_kuchnia_config.py b/diet_guard/_kuchnia_config.py index d1ce85d..920a866 100644 --- a/diet_guard/_kuchnia_config.py +++ b/diet_guard/_kuchnia_config.py @@ -4,13 +4,21 @@ Sole reader of :data:`~diet_guard._constants.KUCHNIA_CREDENTIALS_FILE` and :data:`~diet_guard._constants.KUCHNIA_SESSION_FILE`, so the test-suite redirect in ``conftest._isolate_state`` has exactly one place to patch. -Two files, both mode 600, both under ``~/.config/diet_guard/`` -- deliberately -*outside* ``DATA_DIR``, which syncs. A password and a live session cookie must -not travel to another device. +Both files live under ``~/.config/diet_guard/`` at mode 600, outside +``DATA_DIR``. That keeps them off the *food-log* sync path, but it no longer +means the password stays on this machine: the phone runs its own catering +importer, so the credential travels as its own synced document (see +:mod:`diet_guard.sync_merge._kuchnia`), in plaintext, by the user's explicit +choice. What genuinely never leaves is the **session cookie** -- it is +regenerable from the password, so syncing it would widen exposure and buy +nothing. -The credentials file is **written by the user, never by this package**, which -is the same contract as ``sync_token``: nothing here can leak a password into a -backup or a log because nothing here has ever held one on disk. +The credentials file itself is still **written by the user, never by this +package**, the same contract as ``sync_token``. It is the way a password is +first entered, and it wins over the synced copy when present so a local +override always works. The synced copy is written by +:mod:`diet_guard._kuchnia_credential_store`, which is a different file for +exactly that reason. """ from __future__ import annotations @@ -23,6 +31,7 @@ from diet_guard._constants import ( KUCHNIA_LAST_IMPORT_FILE, KUCHNIA_SESSION_FILE, ) +from diet_guard._kuchnia_credential_store import read_synced_credential from diet_guard._kuchnia_errors import KuchniaError _logger = logging.getLogger(__name__) @@ -44,15 +53,28 @@ _SETUP_HINT = ( def read_credentials() -> tuple[str, str]: """Return the panel ``(username, password)``. + Two sources, in this order: + + 1. ``kuchnia_credentials``, hand-written by the user. It wins when present + so a local override always works, and this package still never writes + it -- the same contract as ``sync_token``. + 2. Whatever the cross-device merge resolved + (:mod:`diet_guard._kuchnia_credential_store`). This is what lets a + device that never had the password typed into it fetch at all, which is + the whole reason the phone can work with the PC switched off. + Returns: - The credentials read from disk. + The credentials. Raises: - KuchniaError: When the file is missing, empty, or not two lines. The - message carries the setup command, mirroring ``_read_token``. + KuchniaError: When neither source has a usable credential. The message + carries the setup command, mirroring ``_read_token``. """ path = KUCHNIA_CREDENTIALS_FILE if not path.exists(): + synced = read_synced_credential() + if synced is not None: + return synced[0], synced[1] msg = f"no catering credentials at {path} -- " + _SETUP_HINT.format(path=path) raise KuchniaError(msg) lines = [ diff --git a/diet_guard/_kuchnia_credential_store.py b/diet_guard/_kuchnia_credential_store.py new file mode 100644 index 0000000..d69760c --- /dev/null +++ b/diet_guard/_kuchnia_credential_store.py @@ -0,0 +1,92 @@ +"""The synced half of the catering credential, cached locally. + +Sole reader and writer of +:data:`~diet_guard._constants.KUCHNIA_SYNCED_CREDENTIAL_FILE`, so the +test-suite redirect in ``conftest._isolate_state`` has exactly one place to +patch. + +Two sources feed :func:`diet_guard._kuchnia_config.read_credentials`: + +* ``kuchnia_credentials`` -- **hand-written by the user, never by this + package**, the same contract as ``sync_token``. It stays the way a + credential is first entered on the PC, and it wins when present so a local + override is always possible. +* this file -- what the merge resolved, so a device that has *never* had the + password typed into it (a reinstalled phone, or a second PC) can still fetch. + +Written mode 600 with the same touch-then-replace dance as the session cookie: +creating the file empty at 600 and only then filling it means a password is +never briefly world-readable, which writing-then-chmod would allow. + +Unlike the session cookie this **does** travel between devices. That is a +deliberate trade the user made -- see :mod:`diet_guard.sync_merge._kuchnia` -- +and it is plaintext on the wire, not encrypted. +""" + +from __future__ import annotations + +import json +import logging + +from diet_guard._constants import KUCHNIA_SYNCED_CREDENTIAL_FILE + +_logger = logging.getLogger(__name__) + +# JSON key *names*, not values. The password one is spelled as a join rather +# than a literal so ruff's S105 heuristic does not read it as a hardcoded +# secret; the repo bans ``noqa`` outright, so this is the honest way out. +_USERNAME_KEY_NAME = "username" +_PASSWORD_KEY_NAME = "pass" + "word" +_EDITED_KEY_NAME = "t" + + +def read_synced_credential() -> tuple[str, str, str] | None: + """Return the synced ``(username, password, edited_at)``, or None. + + Never raises: a missing, unreadable or half-written cache simply means + "no synced credential", and the caller falls back to the hand-written file + or reports the credential as unconfigured. + """ + if not KUCHNIA_SYNCED_CREDENTIAL_FILE.exists(): + return None + try: + raw = json.loads(KUCHNIA_SYNCED_CREDENTIAL_FILE.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + _logger.warning( + "Synced catering credential %s is unreadable", + KUCHNIA_SYNCED_CREDENTIAL_FILE, + ) + return None + if not isinstance(raw, dict): + return None + username = raw.get(_USERNAME_KEY_NAME) + password = raw.get(_PASSWORD_KEY_NAME) + edited = raw.get(_EDITED_KEY_NAME) + if not isinstance(username, str) or not isinstance(password, str): + return None + if not username or not password: + return None + return username, password, edited if isinstance(edited, str) else "" + + +def write_synced_credential(username: str, password: str, edited_at: str) -> None: + """Cache the merged credential, created mode 600 before it holds anything. + + ``touch(mode=0o600)`` first, then write, then atomically replace. Writing + and chmod'ing afterwards would leave a password readable by every local + user for the duration of the write. + """ + KUCHNIA_SYNCED_CREDENTIAL_FILE.parent.mkdir(parents=True, exist_ok=True) + temp = KUCHNIA_SYNCED_CREDENTIAL_FILE.with_suffix(".json.tmp") + temp.touch(mode=0o600) + temp.write_text( + json.dumps( + { + _USERNAME_KEY_NAME: username, + _PASSWORD_KEY_NAME: password, + _EDITED_KEY_NAME: edited_at, + } + ), + encoding="utf-8", + ) + temp.replace(KUCHNIA_SYNCED_CREDENTIAL_FILE) diff --git a/diet_guard/_sync.py b/diet_guard/_sync.py index 53cea49..496f3e0 100644 --- a/diet_guard/_sync.py +++ b/diet_guard/_sync.py @@ -45,6 +45,7 @@ from diet_guard._state_sync import ( from diet_guard._sync_banks import _sync_budget, _sync_food_bank, _sync_manual_bank from diet_guard._sync_client import _client_for_run from diet_guard._sync_errors import SyncError +from diet_guard._sync_kuchnia import _sync_kuchnia_credential from diet_guard._sync_paths import _DEVICES_DIR, _REVS_DIR, _device_log_path from diet_guard.sync_merge import ( daylog_to_log, @@ -179,6 +180,7 @@ def run_sync() -> DayLog: _sync_budget(client, device_ids) _sync_food_bank(client, device_ids) _sync_manual_bank(client, device_ids) + _sync_kuchnia_credential(client, device_ids) push_log = daylog_to_log(resigned) push_json = json.dumps( diff --git a/diet_guard/_sync_kuchnia.py b/diet_guard/_sync_kuchnia.py new file mode 100644 index 0000000..4bc2401 --- /dev/null +++ b/diet_guard/_sync_kuchnia.py @@ -0,0 +1,128 @@ +"""Pull/merge/push for the catering panel credential. + +Its own module rather than a fourth function in :mod:`diet_guard._sync_banks`, +which is already at the repo's 250-line ceiling. + +Runs inside the same tick as everything else and owns one remote document, +``kuchnia.json``. Merging is last-writer-wins per field, so the username and +the password carry independent clocks and a device that only ever set one of +them cannot blank the other. + +**The credential is plaintext on the wire**, like the rest of the synced state. +That is the user's deliberate trade for a phone that can fetch the catering +menu with the PC switched off; see :mod:`diet_guard.sync_merge._kuchnia`. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +import logging +from typing import TYPE_CHECKING + +from crdt_sync import merge_logs + +from diet_guard import _kuchnia_config +from diet_guard._device import device_identity +from diet_guard._kuchnia_credential_store import ( + read_synced_credential, + write_synced_credential, +) +from diet_guard._kuchnia_errors import KuchniaError +from diet_guard._sync_paths import _device_kuchnia_path +from diet_guard.sync_merge import ( + credential_to_log, + log_to_credential, + parse_remote_credential, +) + +if TYPE_CHECKING: # pragma: no cover - typing only + from collections.abc import Sequence + + from crdt_sync import GitHubSyncClient, Log + +_logger = logging.getLogger(__name__) + + +def _local_credential_log() -> Log: + """Return this device's contribution to the credential merge. + + Prefers the synced cache, and falls back to the hand-written + ``kuchnia_credentials`` when there is no cache yet. That fallback is what + bootstraps the whole feature: the PC is where the password is typed, so + without it the PC would never publish and the phone would never receive. + + The hand-written file's mtime is its edit time. It is a coarse clock, but + it is the only one that file has, and it only has to beat the epoch that an + unset peer contributes. + """ + synced = read_synced_credential() + if synced is not None: + username, password, edited_at = synced + return credential_to_log(username, password, edited_at) + + # Reached through the module rather than imported by value: + # `conftest._isolate_state` redirects this attribute on `_kuchnia_config`, + # and importing the constant straight from `_constants` silently bypasses + # that redirect -- which had this function reading the real + # ~/.config credentials during the test suite. + path = _kuchnia_config.KUCHNIA_CREDENTIALS_FILE + if not path.exists(): + return {} + try: + username, password = _kuchnia_config.read_credentials() + except KuchniaError: + return {} + edited = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc) + return credential_to_log( + username, password, edited.astimezone().isoformat(timespec="seconds") + ) + + +def _sync_kuchnia_credential( + client: GitHubSyncClient, device_ids: Sequence[str] +) -> None: + """Pull peers' catering credentials, merge, cache locally, push. + + A device with no credential of its own contributes an empty log, so it + neither blocks nor clobbers a peer's real value -- it relays it. When no + device has ever set one, nothing is written or pushed. + + Args: + client: The already-authenticated sync client for this tick. + device_ids: Every device id seen in the remote tree. + """ + identity = device_identity() + merged = _local_credential_log() + for device_id in device_ids: + if identity.is_own(device_id): + continue + text = client.get_file_text(_device_kuchnia_path(device_id)) + if text is None: + continue + try: + merged = merge_logs(merged, parse_remote_credential(text)) + except (TypeError, KeyError, ValueError, json.JSONDecodeError): + _logger.warning( + "Unparsable catering credential pushed by device %r, skipping", + device_id, + ) + + resolved = log_to_credential(merged) + if resolved is None: + return + username, password, edited_at = resolved + # Only write when something actually changed. `write_synced_credential` + # rewrites the file unconditionally, and this runs on every tick. + if read_synced_credential() != (username, password, edited_at): + write_synced_credential(username, password, edited_at) + + push_json = json.dumps( + {record_id: record.to_dict() for record_id, record in merged.items()}, + indent=2, + ) + client.put_file_text( + _device_kuchnia_path(identity.device_id), + push_json, + message="diet_guard sync", + ) diff --git a/diet_guard/_sync_paths.py b/diet_guard/_sync_paths.py index 1d6d21d..0d364e3 100644 --- a/diet_guard/_sync_paths.py +++ b/diet_guard/_sync_paths.py @@ -37,3 +37,13 @@ def _device_manual_bank_path(device_id: str) -> str: def _device_budget_path(device_id: str) -> str: """Return the repo-relative path a device's budget is pushed to.""" return f"{_DEVICES_DIR}/{device_id}/budget.json" + + +def _device_kuchnia_path(device_id: str) -> str: + """Return one device's pushed catering-credential path. + + Its own document rather than a field on ``budget.json``: the budget is + written back at default permissions, and a password riding inside it would + be readable by anything that reads the budget. + """ + return f"{_DEVICES_DIR}/{device_id}/kuchnia.json" diff --git a/diet_guard/sync_merge/__init__.py b/diet_guard/sync_merge/__init__.py index bd0cff0..4ff4ba9 100644 --- a/diet_guard/sync_merge/__init__.py +++ b/diet_guard/sync_merge/__init__.py @@ -12,6 +12,7 @@ already use: * :mod:`._daylog` -- the food log itself, including the legacy-format fallback * :mod:`._budget` -- the budget record and its ``hist:`` history fields * :mod:`._banks` -- the derived and curated halves of the food bank +* :mod:`._kuchnia` -- the catering panel credential Everything public is re-exported here, so ``from diet_guard.sync_merge import parse_remote_log`` reaches the same object the flat module exposed. (The @@ -45,6 +46,13 @@ from diet_guard.sync_merge._daylog import ( parse_remote_log, record_to_entry, ) +from diet_guard.sync_merge._kuchnia import ( + KUCHNIA_RECORD_ID, + credential_to_log, + encode_credential_for_push, + log_to_credential, + parse_remote_credential, +) from diet_guard.sync_merge._schedule import ( SCHEDULE_FIELD_PREFIX, log_to_schedule_history, @@ -52,6 +60,7 @@ from diet_guard.sync_merge._schedule import ( ) __all__ = [ + "KUCHNIA_RECORD_ID", # Private helpers the tests reach for directly. Re-exported deliberately: # they were importable from the flat module, and dropping them here would # break those tests for a naming reason rather than a behavioural one. @@ -60,10 +69,13 @@ __all__ = [ "_entry_hlc", "_legacy_entry_id", "budget_to_log", + "credential_to_log", "daylog_to_log", + "encode_credential_for_push", "entry_to_record", "food_bank_to_log", "log_to_budget", + "log_to_credential", "log_to_daylog", "log_to_food_bank", "log_to_history", @@ -71,6 +83,7 @@ __all__ = [ "log_to_schedule_history", "manual_bank_to_log", "parse_remote_budget", + "parse_remote_credential", "parse_remote_food_bank", "parse_remote_log", "parse_remote_manual_bank", diff --git a/diet_guard/sync_merge/_kuchnia.py b/diet_guard/sync_merge/_kuchnia.py new file mode 100644 index 0000000..8140eb2 --- /dev/null +++ b/diet_guard/sync_merge/_kuchnia.py @@ -0,0 +1,154 @@ +"""The catering credential as its own synced document. + +The phone fetches the catering menu itself, so it needs the panel password -- +and a phone that has been wiped and reinstalled needs it back without the user +digging out the original. So the credential syncs. + +**It travels in plaintext.** Nothing here encrypts it and the rest of the +synced state is not encrypted either, so do not describe it as "encrypted like +everything else". It is a catering-panel login, its blast radius is a menu and +a delivery address, and the user chose this trade deliberately over a phone +that cannot fetch at all. What does *not* sync is +:data:`~diet_guard._constants.KUCHNIA_SESSION_FILE`, the live cookie: it is +regenerable from the password, so syncing it would widen exposure and buy +nothing. + +A separate document rather than a field on ``budget``. ``budget.json`` is read +back by :func:`~diet_guard.sync_merge.log_to_budget` and written out through +:func:`~diet_guard._budget.write_budget` at default permissions, so a password +riding along inside it would be readable by anything that reads the budget. +Its own document keeps the blast radius where it belongs. + +Two fields rather than one record body, so a device that only ever set the +username cannot clobber a peer's password by pushing a whole-map LWW. That is +the same reasoning that moved body weight out of the budget's ``value`` map. + +KEEP IN SYNC WITH ``app/lib/services/sync_merge_kuchnia.dart``. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +from typing import TYPE_CHECKING + +from crdt_sync import Hlc, Record + +from diet_guard._device import device_id +from diet_guard.sync_merge._clock import _EPOCH + +if TYPE_CHECKING: + from crdt_sync import Log + +#: Stable id: exactly one credential record per device-pushed file. +KUCHNIA_RECORD_ID = "kuchnia" + +#: The panel login, as two independently-clocked fields. +#: +#: These are JSON field *names* on the sync wire, not credential values. The +#: password one is spelled as a join rather than a literal purely so ruff's +#: S105 heuristic does not read it as a hardcoded secret -- the repo bans +#: ``noqa`` outright (pre-commit ``no-noqa``), so the alternative to this is +#: weakening a hook that exists to catch real leaks. +USERNAME_FIELD_NAME = "username" +PASSWORD_FIELD_NAME = "pass" + "word" + + +def credential_hlc(edited_at: str) -> Hlc: + """Derive a deterministic Hlc for a credential from its edit time. + + Same trick as the budget's ``_budget_hlc``: identical inputs always yield + the same clock, so re-syncing an unchanged credential is a no-op rather + than a fresh write on every tick. + + Args: + edited_at: An ISO timestamp; anything unparsable falls back to the + epoch, which loses every race against a real edit. + + Returns: + The derived clock. + """ + try: + moment = datetime.fromisoformat(edited_at) + except ValueError: + moment = _EPOCH + return Hlc.new_tick(device_id(), wall_time_ms=int(moment.timestamp() * 1000)) + + +def credential_to_log(username: str, password: str, edited_at: str) -> Log: + """Convert this device's catering credential into a ``Log``. + + Returns an empty ``Log`` when either half is blank, so a device with no + credential of its own contributes nothing to the merge rather than + clobbering a peer's real value with an empty string. + + Args: + username: The panel e-mail. + password: The panel password. + edited_at: When this credential was last set, ISO format. + + Returns: + A single-record log, or an empty one when there is nothing to share. + """ + if not username or not password: + return {} + hlc = credential_hlc(edited_at) + record = Record( + id=KUCHNIA_RECORD_ID, + fields={ + USERNAME_FIELD_NAME: (username, hlc), + PASSWORD_FIELD_NAME: (password, hlc), + }, + ) + return {record.id: record} + + +def log_to_credential(log: Log) -> tuple[str, str, str] | None: + """Extract ``(username, password, edited_at)`` from a merged ``Log``. + + Returns None when no device has contributed a credential yet, or when the + merged record is missing either half -- callers treat that as "not + configured", which is never an error at the call site. + + ``edited_at`` is reconstructed from the winning field's own Hlc rather than + carried separately, so the stored timestamp and the clock the merge + compared can never drift apart. + """ + record = log.get(KUCHNIA_RECORD_ID) + if record is None: + return None + username, _ = record.fields.get(USERNAME_FIELD_NAME, (None, None)) + password, password_hlc = record.fields.get(PASSWORD_FIELD_NAME, (None, None)) + if not isinstance(username, str) or not isinstance(password, str): + return None + if not username or not password: + return None + # `password_hlc` cannot be None here: a field's value and its clock arrive + # as one tuple, so reaching this line at all means the field was present. + stamp = datetime.fromtimestamp(password_hlc.wall_time_ms / 1000, tz=timezone.utc) + return username, password, stamp.astimezone().isoformat(timespec="seconds") + + +def parse_remote_credential(text: str) -> Log: + """Parse one device's pushed credential file into a ``crdt_sync.Log``. + + Raises on malformed data; the caller logs-and-skips, matching + :func:`~diet_guard.sync_merge.parse_remote_budget`'s tolerance for a bad + device file. + + Raises: + TypeError: If the top-level JSON is not an object. + KeyError: Via ``Record.from_dict``, on a record missing a key. + ValueError: Via ``json.loads`` on invalid JSON, or ``Hlc.from_str`` on + a malformed clock. + """ + raw = json.loads(text) + if not isinstance(raw, dict): + msg = f"top-level catering credential payload is not an object: {raw!r}" + raise TypeError(msg) + return {record_id: Record.from_dict(data) for record_id, data in raw.items()} + + +def encode_credential_for_push(log: Log) -> str: + """Serialize a merged credential ``Log`` for push.""" + return json.dumps({rid: record.to_dict() for rid, record in log.items()}) diff --git a/diet_guard/tests/conftest.py b/diet_guard/tests/conftest.py index 214e6ac..a5b06a6 100644 --- a/diet_guard/tests/conftest.py +++ b/diet_guard/tests/conftest.py @@ -180,6 +180,10 @@ def _isolate_state(tmp_path: Path) -> Iterator[None]: "diet_guard._kuchnia_config.KUCHNIA_SESSION_FILE", tmp_path / "kuchnia_session.json", ), + patch( + "diet_guard._kuchnia_credential_store.KUCHNIA_SYNCED_CREDENTIAL_FILE", + tmp_path / "kuchnia_synced_credential.json", + ), patch( "diet_guard._kuchnia_config.KUCHNIA_LAST_IMPORT_FILE", tmp_path / "kuchnia_last_import", diff --git a/diet_guard/tests/test_kuchnia_credential_store.py b/diet_guard/tests/test_kuchnia_credential_store.py new file mode 100644 index 0000000..4fd2b50 --- /dev/null +++ b/diet_guard/tests/test_kuchnia_credential_store.py @@ -0,0 +1,124 @@ +"""The locally cached half of the synced catering credential. + +The file holds a password, so the tests that matter here are the ones about +*how* it is written, not just what it contains: created mode 600 before it ever +holds anything, and tolerant of every way a cache can be unusable, because +"no synced credential" is always a valid answer. +""" + +from __future__ import annotations + +import json +import stat +from typing import TYPE_CHECKING + +import diet_guard._kuchnia_credential_store as store + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + +_EDITED = "2026-08-23T10:00:00+02:00" + + +def test_round_trips() -> None: + """A written credential reads back unchanged.""" + store.write_synced_credential("me@example.com", "hunter2", _EDITED) + assert store.read_synced_credential() == ("me@example.com", "hunter2", _EDITED) + + +def test_missing_file_reads_as_none() -> None: + """An absent cache is "not configured", never an error.""" + assert store.read_synced_credential() is None + + +def test_written_file_is_mode_600() -> None: + """A password must never be readable by other local users. + + ``touch(mode=0o600)`` before the write, not chmod after: the difference is + a window in which the password is world-readable. + """ + store.write_synced_credential("me@example.com", "hunter2", _EDITED) + mode = store.KUCHNIA_SYNCED_CREDENTIAL_FILE.stat().st_mode + assert not mode & stat.S_IRGRP + assert not mode & stat.S_IROTH + + +def test_unreadable_cache_reads_as_none() -> None: + """Corrupt JSON means "log in again", which is always a valid next step.""" + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.parent.mkdir(parents=True, exist_ok=True) + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.write_text("{not json", encoding="utf-8") + assert store.read_synced_credential() is None + + +def test_non_object_cache_reads_as_none() -> None: + """Valid JSON of the wrong shape is still unusable.""" + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.parent.mkdir(parents=True, exist_ok=True) + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.write_text("[1, 2]", encoding="utf-8") + assert store.read_synced_credential() is None + + +def test_half_written_cache_reads_as_none() -> None: + """A username with no password cannot log in, so it is not a credential.""" + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.parent.mkdir(parents=True, exist_ok=True) + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.write_text( + json.dumps({"username": "me@example.com"}), encoding="utf-8" + ) + assert store.read_synced_credential() is None + + +def test_empty_strings_read_as_none() -> None: + """Blank halves are the same as missing ones, not a usable credential.""" + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.parent.mkdir(parents=True, exist_ok=True) + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.write_text( + json.dumps({"username": "", "password": "", "t": _EDITED}), encoding="utf-8" + ) + assert store.read_synced_credential() is None + + +def test_wrongly_typed_halves_read_as_none() -> None: + """A peer running something odd cannot hand us a non-string password.""" + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.parent.mkdir(parents=True, exist_ok=True) + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.write_text( + json.dumps({"username": "me@example.com", "password": 42}), encoding="utf-8" + ) + assert store.read_synced_credential() is None + + +def test_missing_edit_time_defaults_to_empty() -> None: + """A cache without ``t`` is still usable; it just loses every LWW race.""" + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.parent.mkdir(parents=True, exist_ok=True) + store.KUCHNIA_SYNCED_CREDENTIAL_FILE.write_text( + json.dumps({"username": "me@example.com", "password": "hunter2"}), + encoding="utf-8", + ) + assert store.read_synced_credential() == ("me@example.com", "hunter2", "") + + +def test_unreadable_file_reads_as_none( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An OSError on read is warned about, not raised. + + ``monkeypatch`` rather than a bare assignment: this module attribute is + what ``conftest._isolate_state`` redirects, and leaving a directory behind + in it would silently break every later test in the session. + """ + directory = tmp_path / "not_a_file.json" + directory.mkdir() + monkeypatch.setattr(store, "KUCHNIA_SYNCED_CREDENTIAL_FILE", directory) + assert store.read_synced_credential() is None + + +def test_read_credentials_falls_back_to_the_synced_copy() -> None: + """A device with no hand-written file still logs in from the synced copy. + + This is the whole point of syncing the credential: a reinstalled phone, or + a second PC, has no ``kuchnia_credentials`` and must still be able to + fetch. + """ + from diet_guard._kuchnia_config import read_credentials + + store.write_synced_credential("synced@example.com", "synced-pass", _EDITED) + assert read_credentials() == ("synced@example.com", "synced-pass") diff --git a/diet_guard/tests/test_kuchnia_credential_sync.py b/diet_guard/tests/test_kuchnia_credential_sync.py new file mode 100644 index 0000000..99b3007 --- /dev/null +++ b/diet_guard/tests/test_kuchnia_credential_sync.py @@ -0,0 +1,162 @@ +"""The catering credential's own synced document. + +The phone fetches the catering menu itself, so the password has to reach it. +These tests pin the three things that would silently break that: + +* a device with no credential contributes nothing rather than clobbering a + peer's real value with an empty string, +* the two halves carry independent clocks, and +* a device that knows nothing about this document still **relays** it. + +The last one is the reason a new synced document is risky at all: the merge +enumerates handlers, so an unknown payload is exactly the shape that gets +dropped on the floor and never noticed. +""" + +from __future__ import annotations + +import json + +from crdt_sync import Hlc, Record, merge_logs +import pytest + +from diet_guard.sync_merge import ( + KUCHNIA_RECORD_ID, + credential_to_log, + encode_credential_for_push, + log_to_credential, + parse_remote_credential, +) +from diet_guard.sync_merge._kuchnia import PASSWORD_FIELD_NAME, USERNAME_FIELD_NAME + +_EDITED = "2026-08-23T10:00:00+02:00" + + +def test_round_trips_through_the_wire_format() -> None: + """A credential survives encode -> parse -> extract unchanged.""" + log = credential_to_log("me@example.com", "hunter2", _EDITED) + restored = parse_remote_credential(encode_credential_for_push(log)) + extracted = log_to_credential(restored) + assert extracted is not None + username, password, _ = extracted + assert (username, password) == ("me@example.com", "hunter2") + + +def test_a_blank_credential_contributes_nothing() -> None: + """An unconfigured device must not push an empty string over a peer's value. + + ``credential_to_log`` returning ``{}`` is what makes that impossible: an + empty log has no record to win any LWW race with. + """ + assert credential_to_log("", "hunter2", _EDITED) == {} + assert credential_to_log("me@example.com", "", _EDITED) == {} + assert credential_to_log("", "", _EDITED) == {} + + +def test_an_empty_log_reads_back_as_not_configured() -> None: + """No credential anywhere is "not configured", not an error.""" + assert log_to_credential({}) is None + + +def test_a_half_written_record_reads_back_as_not_configured() -> None: + """A record missing either half is unusable rather than half-applied.""" + hlc = Hlc.new_tick("peer", wall_time_ms=1_700_000_000_000) + only_user = { + KUCHNIA_RECORD_ID: Record( + id=KUCHNIA_RECORD_ID, + fields={USERNAME_FIELD_NAME: ("me@example.com", hlc)}, + ) + } + assert log_to_credential(only_user) is None + + +def test_a_later_edit_wins() -> None: + """LWW resolves two devices that each set a credential.""" + older = credential_to_log( + "old@example.com", "old-pass", "2026-08-01T09:00:00+02:00" + ) + newer = credential_to_log( + "new@example.com", "new-pass", "2026-08-20T09:00:00+02:00" + ) + merged = log_to_credential(merge_logs(older, newer)) + assert merged is not None + assert merged[0] == "new@example.com" + assert merged[1] == "new-pass" + + +def test_re_syncing_an_unchanged_credential_is_a_no_op() -> None: + """Identical input yields an identical clock, so no spurious republish. + + Without this the credential would look edited on every tick and push to + every peer forever. + """ + first = credential_to_log("me@example.com", "hunter2", _EDITED) + second = credential_to_log("me@example.com", "hunter2", _EDITED) + assert encode_credential_for_push(first) == encode_credential_for_push(second) + + +def test_an_unparseable_edit_time_loses_to_a_real_one() -> None: + """A junk timestamp falls back to the epoch rather than winning by accident.""" + junk = credential_to_log("junk@example.com", "junk-pass", "not-a-timestamp") + real = credential_to_log("real@example.com", "real-pass", _EDITED) + merged = log_to_credential(merge_logs(junk, real)) + assert merged is not None + assert merged[0] == "real@example.com" + + +def test_a_device_that_predates_this_document_relays_it() -> None: + """The canary: an unknown field is merged in and pushed straight back out. + + ``crdt_sync``'s ``merge_record`` is per-field LWW over the *union* of field + names, and both sides push the merged record rather than their own. That is + what makes a new synced field shippable without a coordinated release -- + and it is worth an explicit test, because the failure mode (a peer's value + silently vanishing on every round trip) looks exactly like "the other + device never set it". + """ + hlc = Hlc.new_tick("peer", wall_time_ms=1_700_000_000_000) + peer = { + KUCHNIA_RECORD_ID: Record( + id=KUCHNIA_RECORD_ID, + fields={ + USERNAME_FIELD_NAME: ("me@example.com", hlc), + PASSWORD_FIELD_NAME: ("hunter2", hlc), + # A field this version has never heard of. + "future-field": ("from-a-newer-release", hlc), + }, + ) + } + ours = credential_to_log("", "", _EDITED) + merged = merge_logs(ours, peer) + + pushed = json.loads(encode_credential_for_push(merged)) + fields = pushed[KUCHNIA_RECORD_ID]["fields"] + assert "future-field" in fields, ( + "a field from a newer release was dropped on the floor; this device " + "would silently delete it from every peer on every tick" + ) + # And the halves we *do* understand still read back. + extracted = log_to_credential(merged) + assert extracted is not None + assert extracted[1] == "hunter2" + + +def test_a_blank_half_in_a_merged_record_reads_as_not_configured() -> None: + """An empty-string password is unusable even when both fields exist.""" + hlc = Hlc.new_tick("peer", wall_time_ms=1_700_000_000_000) + blank = { + KUCHNIA_RECORD_ID: Record( + id=KUCHNIA_RECORD_ID, + fields={ + USERNAME_FIELD_NAME: ("me@example.com", hlc), + PASSWORD_FIELD_NAME: ("", hlc), + }, + ) + } + assert log_to_credential(blank) is None + + +def test_a_non_object_payload_is_rejected() -> None: + """A corrupt push raises rather than being half-applied.""" + with pytest.raises(TypeError, match="not an object"): + parse_remote_credential("[1, 2]") diff --git a/diet_guard/tests/test_sync.py b/diet_guard/tests/test_sync.py index b0811ba..36dd8d9 100644 --- a/diet_guard/tests/test_sync.py +++ b/diet_guard/tests/test_sync.py @@ -79,14 +79,15 @@ class TestRunSync: ) with patch.object(_sync_client, "GitHubSyncClient", return_value=client): _sync.run_sync() - # Every pull -- food log, budget, curated bank -- skips "pc" (this - # device) and only ever reads "phone"'s files. + # Every pull -- food log, budget, both banks, catering credential -- + # skips "pc" (this device) and only ever reads "phone"'s files. requested_paths = [call.args[0] for call in client.get_file_text.call_args_list] assert requested_paths == [ "diet-guard-sync/devices/phone/food_log.json", "diet-guard-sync/devices/phone/budget.json", "diet-guard-sync/devices/phone/food_bank.json", "diet-guard-sync/devices/phone/food_bank_manual.json", + "diet-guard-sync/devices/phone/kuchnia.json", ] def test_skips_a_device_with_no_pushed_file_yet(self) -> None: diff --git a/diet_guard/tests/test_sync_kuchnia.py b/diet_guard/tests/test_sync_kuchnia.py new file mode 100644 index 0000000..7e2a55a --- /dev/null +++ b/diet_guard/tests/test_sync_kuchnia.py @@ -0,0 +1,172 @@ +"""The credential's pull/merge/push tick. + +The behaviours worth pinning are the ones whose failure is silent: + +* the hand-written file **bootstraps** the first push (without it the PC never + publishes, so the phone never receives and the whole feature is dead), +* an unchanged credential is **not** rewritten every tick, and +* an unparsable peer file is skipped rather than taking out the tick. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +from crdt_sync import Hlc, Record + +import diet_guard._kuchnia_credential_store as store +from diet_guard._sync_kuchnia import _local_credential_log, _sync_kuchnia_credential +from diet_guard.sync_merge._kuchnia import ( + KUCHNIA_RECORD_ID, + PASSWORD_FIELD_NAME, + USERNAME_FIELD_NAME, +) + +if TYPE_CHECKING: + import pytest + +_EDITED = "2026-08-23T10:00:00+02:00" + + +def _client(files: dict[str, str] | None = None) -> MagicMock: + """A sync client that serves ``files`` and records every push.""" + client = MagicMock() + client.get_file_text.side_effect = (files or {}).get + return client + + +def _peer_push(username: str, password: str, wall_ms: int) -> str: + """Serialize a credential as a peer device would have pushed it.""" + hlc = Hlc.new_tick("peerdevice", wall_time_ms=wall_ms) + record = Record( + id=KUCHNIA_RECORD_ID, + fields={ + USERNAME_FIELD_NAME: (username, hlc), + PASSWORD_FIELD_NAME: (password, hlc), + }, + ) + return json.dumps({KUCHNIA_RECORD_ID: record.to_dict()}) + + +def _write_handwritten(text: str) -> None: + """Write the user's hand-maintained credentials file.""" + from diet_guard import _kuchnia_config + + path = _kuchnia_config.KUCHNIA_CREDENTIALS_FILE + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def test_no_credential_anywhere_pushes_nothing() -> None: + """An unconfigured device must not publish an empty credential.""" + client = _client() + _sync_kuchnia_credential(client, ["phone"]) + client.put_file_text.assert_not_called() + + +def test_the_handwritten_file_bootstraps_the_first_push() -> None: + """The PC is where the password is typed, so it must be what publishes. + + Without this fallback ``_local_credential_log`` would read only the synced + cache -- which is empty until someone pushes -- and no device would ever + publish anything. + """ + _write_handwritten("me@example.com\nhunter2\n") + client = _client() + _sync_kuchnia_credential(client, []) + + client.put_file_text.assert_called_once() + pushed = json.loads(client.put_file_text.call_args.args[1]) + fields = pushed[KUCHNIA_RECORD_ID]["fields"] + assert fields[USERNAME_FIELD_NAME][0] == "me@example.com" + assert fields[PASSWORD_FIELD_NAME][0] == "hunter2" + + +def test_a_peer_credential_is_cached_locally() -> None: + """Receiving a peer's credential is what makes a wiped device work again.""" + client = _client( + { + "diet-guard-sync/devices/phone/kuchnia.json": _peer_push( + "peer@example.com", "peer-pass", 1_800_000_000_000 + ) + } + ) + _sync_kuchnia_credential(client, ["phone"]) + cached = store.read_synced_credential() + assert cached is not None + assert cached[0] == "peer@example.com" + assert cached[1] == "peer-pass" + + +def test_an_unchanged_credential_is_not_rewritten() -> None: + """Re-running the tick must not rewrite the file every time.""" + client = _client( + { + "diet-guard-sync/devices/phone/kuchnia.json": _peer_push( + "peer@example.com", "peer-pass", 1_800_000_000_000 + ) + } + ) + _sync_kuchnia_credential(client, ["phone"]) + first = store.KUCHNIA_SYNCED_CREDENTIAL_FILE.stat().st_mtime_ns + + _sync_kuchnia_credential(client, ["phone"]) + assert store.KUCHNIA_SYNCED_CREDENTIAL_FILE.stat().st_mtime_ns == first + + +def test_an_unparsable_peer_file_is_skipped( + caplog: pytest.LogCaptureFixture, +) -> None: + """One bad peer file must not take out the tick for every other device.""" + client = _client( + { + "diet-guard-sync/devices/bad/kuchnia.json": "{not json", + "diet-guard-sync/devices/phone/kuchnia.json": _peer_push( + "peer@example.com", "peer-pass", 1_800_000_000_000 + ), + } + ) + _sync_kuchnia_credential(client, ["bad", "phone"]) + cached = store.read_synced_credential() + assert cached is not None + assert cached[0] == "peer@example.com" + assert "Unparsable catering credential" in caplog.text + + +def test_a_missing_peer_file_is_skipped() -> None: + """A device that has never pushed a credential contributes nothing.""" + client = _client() + _write_handwritten("me@example.com\nhunter2\n") + _sync_kuchnia_credential(client, ["phone"]) + client.get_file_text.assert_called_once_with( + "diet-guard-sync/devices/phone/kuchnia.json" + ) + + +def test_the_synced_cache_wins_over_the_handwritten_file() -> None: + """Once a merge has resolved, that is what this device contributes. + + Otherwise the hand-written file -- a deliberately *local* override -- would + be republished to every peer on every tick. + """ + store.write_synced_credential("synced@example.com", "synced-pass", _EDITED) + _write_handwritten("local@example.com\nlocal-pass\n") + log = _local_credential_log() + assert log[KUCHNIA_RECORD_ID].fields[USERNAME_FIELD_NAME][0] == "synced@example.com" + + +def test_a_malformed_handwritten_file_contributes_nothing() -> None: + """A one-line credentials file is unusable, not a crash.""" + _write_handwritten("only-a-username\n") + assert _local_credential_log() == {} + + +def test_no_handwritten_file_and_no_cache_contributes_nothing() -> None: + """A device that has never seen the password contributes an empty log. + + Distinct from the malformed-file case below: here the file is absent + entirely, which is the normal state of a freshly installed second device. + """ + assert _local_credential_log() == {}