mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 18:53:59 +01:00
Two things the pool needed to be real rather than merely correct.
**freedays no longer depends on gatelock.** It imported
gatelock.log_integrity for ~15 lines of HMAC, but importing gatelock runs
its __init__, which imports tkinter for the lock-window machinery. So a
headless policy library had acquired a hard dependency on a GUI toolkit,
and `import freedays` failed anywhere without Tk:
ImportError: libtk8.6.so: cannot open shared object file
A vmbox run caught it. The host masked it completely -- Tk is installed
here for the lockers -- and so did CI, whose own comment claimed "no
display needed" while the job would have needed libtk. freedays/_hmac.py
now does the signing with hashlib and hmac.
The format is deliberately unchanged (HMAC-SHA256 over compact
sorted-key JSON), so nothing has to be re-signed: all 8 entries already
in the real audit trail, written by gatelock's implementation, verify
against the new one. That was checked, not assumed.
This also dissolves the version diamond that shaped the app rollout:
freedays now pins only crdt-sync, so it can never conflict with whatever
gatelock tag an app carries.
**The pool now syncs on a schedule.** A `sync-quiet` subcommand plus a
systemd user timer every 15 minutes, because "it syncs" was otherwise
only true if you typed `freedays sync` yourself -- a day marked on the
phone would sit there. sync-quiet exits 0 when there is no network or no
credential, on purpose: a unit that goes red every time the laptop is
offline is a unit you learn to ignore, and a real bug still reaches the
journal through the library's logging.
install.sh gives freedays its own venv rather than borrowing an app's --
the pool is shared by five apps, so binding its CLI to one of them would
make "is freedays installed?" depend on something unrelated, and
uninstalling that app would take the command with it.
Verified in vmbox first (systemd), then on this host: the timer is
enabled and scheduled, and the service *finishes* rather than failing
when there is no remote.
seed_session.py gains freedays, without which the one command that can
give the pool a Firebase session would reject it outright (--app is
choices-validated) and the pool would stay local-only with nothing
saying why. That file was already exactly at the 250-line cap, so the
app list and its rationale move to tool/_seeded_apps.py.
freedays: 109 tests, 100% branch coverage. crdt-sync: 433 pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
71 lines
2.4 KiB
Python
71 lines
2.4 KiB
Python
"""Append-only, HMAC-signed trail of every mark and release.
|
|
|
|
Nothing reads this to make a decision -- the CRDT log is the truth, and this
|
|
file is deliberately not consulted by :mod:`freedays._api`. It exists so that
|
|
"when did I take that day, and from which device" is answerable later, and so
|
|
that a hand-edited entry is detectable rather than invisible.
|
|
|
|
Signing is best-effort on purpose. If the key is missing the entry is still
|
|
written, unsigned: an unsigned audit line is a much smaller problem than a
|
|
free day that could not be claimed because a key file was absent.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
import json
|
|
import logging
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from freedays._day import to_iso
|
|
from freedays._hmac import generate_key, sign
|
|
from freedays._paths import Paths, resolve_paths
|
|
|
|
if TYPE_CHECKING:
|
|
from datetime import date
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def record_event(
|
|
action: str,
|
|
day: date,
|
|
*,
|
|
actor: str,
|
|
reason: str = "",
|
|
paths: Paths | None = None,
|
|
) -> None:
|
|
"""Append one signed line describing a change to the pool.
|
|
|
|
Args:
|
|
action: ``"mark"`` or ``"release"``.
|
|
day: The day that changed.
|
|
actor: The device id that made the change.
|
|
reason: Optional free text supplied by the person.
|
|
paths: Where the trail and its signing key live.
|
|
"""
|
|
resolved = resolve_paths(paths)
|
|
entry: dict[str, Any] = {
|
|
"action": action,
|
|
"day": to_iso(day),
|
|
"actor": actor,
|
|
"reason": reason,
|
|
"at": datetime.now(tz=UTC).isoformat(),
|
|
}
|
|
key_target = resolved.hmac_key
|
|
if not key_target.exists():
|
|
# First run on this machine. Generating it here rather than in an
|
|
# installer means the trail is signed from its very first entry,
|
|
# with no setup step that can be skipped.
|
|
generate_key(key_target)
|
|
entry["hmac"] = sign(entry, key_file=key_target)
|
|
target = resolved.audit
|
|
try:
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
with target.open("a", encoding="utf-8") as handle:
|
|
handle.write(json.dumps(entry, sort_keys=True) + "\n")
|
|
except OSError as exc:
|
|
# An unwritable audit trail must never be the reason a day cannot be
|
|
# taken -- the caller has already committed the change to the log.
|
|
_logger.warning("could not append to the free-day audit trail: %s", exc)
|