diff --git a/gatelock/README.md b/gatelock/README.md index a79592d..9ef74b1 100644 --- a/gatelock/README.md +++ b/gatelock/README.md @@ -72,6 +72,14 @@ leetcode-guard and book-guard all call it; the policy itself stays in wake-alarm. Path and retry budget are arguments so each consumer's tests keep redirecting their own constant. +`gatelock.night_lockdown.evening_lockdown_today` answers "has tonight's +evening night-lockdown already happened?" from night-lockdown's +`/var/lib/night-lockdown/locked_at_epoch` (local date today, local time +>= 12:00; the 04:01 morning re-lock does not count). Earner gates +(screen-locker's workout lock, leetcode-guard, book-guard) opt in and do not +re-arm for the rest of such a day; diet-guard does not. Missing or garbage +means "no lockdown" (garbage warns), so the gate locks as before. + ## Development ```bash diff --git a/gatelock/gatelock/night_lockdown.py b/gatelock/gatelock/night_lockdown.py new file mode 100644 index 0000000..550731c --- /dev/null +++ b/gatelock/gatelock/night_lockdown.py @@ -0,0 +1,90 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Has tonight's evening night-lockdown already happened? + +night-lockdown (digital-wellbeing, ``night-lockdown-enter.sh``) stamps +``/var/lib/night-lockdown/locked_at_epoch`` with a unix epoch every time it +locks, and never removes it. Earner gates (workout, LeetCode, reading) that +opt in call :func:`evening_lockdown_today` and do not re-arm their lock for +the rest of a day whose evening lockdown already fired: once a late credit +lifted that lockdown, re-locking behind a task gate the same evening would +make the finished task impossible to submit. + +Only an EVENING lock counts -- the stamp's local date is today and its local +time is at or after 12:00. The 04:01 re-lock of the morning window is the +night that began yesterday, not tonight's. + +Fail open to "no lockdown" (the gate locks as before): a missing stamp is the +normal state on a fresh machine and stays quiet; an unreadable or garbage +stamp is a warning, because then the signal itself is broken. + +Opt-in by design: a gate that is not an earner (diet-guard) never calls this. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, time +import logging +from pathlib import Path +from typing import Final + +_logger = logging.getLogger(__name__) + +NIGHT_LOCKDOWN_STAMP: Final = Path("/var/lib/night-lockdown/locked_at_epoch") +"""Where night-lockdown-enter.sh writes the epoch of its latest lock.""" + +EVENING_FROM: Final = time(12, 0) +"""A same-day lock at or after this local time is the evening lockdown.""" + + +def _read_stamp(path: Path) -> datetime | None: + """The stamp as a local instant, or None when absent or unusable.""" + try: + text = path.read_text(encoding="ascii") + except FileNotFoundError: + _logger.debug("No night-lockdown stamp at %s", path) + return None + except (OSError, UnicodeDecodeError) as exc: + _logger.warning( + "Cannot read night-lockdown stamp %s (%s); treating as no lockdown", + path, + exc, + ) + return None + try: + return datetime.fromtimestamp(int(text.strip()), tz=UTC).astimezone() + except (ValueError, OverflowError, OSError) as exc: + _logger.warning( + "Night-lockdown stamp %s is not a unix epoch (%r: %s); " + "treating as no lockdown", + path, + text[:40], + exc, + ) + return None + + +def evening_lockdown_today( + path: Path = NIGHT_LOCKDOWN_STAMP, + now: datetime | None = None, +) -> datetime | None: + """When today's evening lockdown locked, or None if it has not yet. + + Truthy exactly when the stamp's local date is ``now``'s and its local + time is at or after :data:`EVENING_FROM` (and not in the future), so it + reads as a predicate while still giving callers the time to quote. + """ + now = (now or datetime.now(tz=UTC)).astimezone() + locked_at = _read_stamp(path) + if locked_at is None: + return None + if locked_at.date() != now.date() or locked_at.time() < EVENING_FROM: + return None + if locked_at > now: + _logger.warning( + "Night-lockdown stamp %s is in the future (%s > %s); ignoring it", + path, + locked_at.isoformat(), + now.isoformat(), + ) + return None + return locked_at diff --git a/gatelock/gatelock/tests/test_night_lockdown.py b/gatelock/gatelock/tests/test_night_lockdown.py new file mode 100644 index 0000000..ba6b115 --- /dev/null +++ b/gatelock/gatelock/tests/test_night_lockdown.py @@ -0,0 +1,108 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""gatelock.night_lockdown -- has tonight's evening lockdown already happened?""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +import logging +from typing import TYPE_CHECKING + +import pytest + +from gatelock.night_lockdown import NIGHT_LOCKDOWN_STAMP, evening_lockdown_today + +if TYPE_CHECKING: + from pathlib import Path + +NOW = datetime(2026, 10, 17, 22, 30).astimezone() + + +@pytest.fixture +def stamp(tmp_path: Path) -> Path: + """Where the tests write the stamp -- never the real root-owned one.""" + return tmp_path / "locked_at_epoch" + + +def _write_local(stamp: Path, when: str) -> None: + """Stamp the epoch of a LOCAL wall-clock time, as night-lockdown does.""" + epoch = int(datetime.fromisoformat(when).astimezone().timestamp()) + stamp.write_text(f"{epoch}\n") + + +def test_default_path_is_the_one_night_lockdown_writes() -> None: + assert str(NIGHT_LOCKDOWN_STAMP) == "/var/lib/night-lockdown/locked_at_epoch" + + +def test_evening_lock_today_counts(stamp: Path) -> None: + _write_local(stamp, "2026-10-17T18:00") + got = evening_lockdown_today(stamp, NOW) + assert got == datetime(2026, 10, 17, 18, 0).astimezone() + + +def test_morning_relock_today_does_not_count( + stamp: Path, caplog: pytest.LogCaptureFixture +) -> None: + # The 04:01 re-lock is the night that started yesterday, not tonight's. + _write_local(stamp, "2026-10-17T04:01") + with caplog.at_level(logging.DEBUG, logger="gatelock.night_lockdown"): + assert evening_lockdown_today(stamp, NOW) is None + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + +def test_noon_is_the_first_evening_minute(stamp: Path) -> None: + _write_local(stamp, "2026-10-17T12:00") + assert evening_lockdown_today(stamp, NOW) is not None + + +def test_just_before_noon_is_still_morning(stamp: Path) -> None: + _write_local(stamp, "2026-10-17T11:59:59") + assert evening_lockdown_today(stamp, NOW) is None + + +def test_yesterdays_evening_lock_does_not_count(stamp: Path) -> None: + _write_local(stamp, "2026-10-16T21:00") + assert evening_lockdown_today(stamp, NOW) is None + + +def test_lock_later_than_now_does_not_count(stamp: Path) -> None: + # Clock trouble: a stamp from the future is not a lockdown that happened. + _write_local(stamp, "2026-10-17T23:00") + assert evening_lockdown_today(stamp, NOW) is None + + +def test_missing_file_is_quiet(stamp: Path, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.DEBUG, logger="gatelock.night_lockdown"): + assert evening_lockdown_today(stamp, NOW) is None + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + +@pytest.mark.parametrize("content", ["garbage", "", " \n", "12.5", "1e9"]) +def test_garbage_warns( + stamp: Path, caplog: pytest.LogCaptureFixture, content: str +) -> None: + stamp.write_text(content) + with caplog.at_level(logging.WARNING, logger="gatelock.night_lockdown"): + assert evening_lockdown_today(stamp, NOW) is None + assert any(r.levelno == logging.WARNING for r in caplog.records) + + +def test_absurd_epoch_warns(stamp: Path, caplog: pytest.LogCaptureFixture) -> None: + stamp.write_text("9" * 30) + with caplog.at_level(logging.WARNING, logger="gatelock.night_lockdown"): + assert evening_lockdown_today(stamp, NOW) is None + assert any(r.levelno == logging.WARNING for r in caplog.records) + + +def test_unreadable_warns(stamp: Path, caplog: pytest.LogCaptureFixture) -> None: + stamp.mkdir() # reading a directory is an OSError that is not "missing" + with caplog.at_level(logging.WARNING, logger="gatelock.night_lockdown"): + assert evening_lockdown_today(stamp, NOW) is None + assert any(r.levelno == logging.WARNING for r in caplog.records) + + +def test_now_defaults_to_the_local_clock(stamp: Path) -> None: + # A stamp a minute ago counts exactly when "a minute ago" is today, >=12:00. + ago = datetime.now(tz=UTC).astimezone() - timedelta(minutes=1) + stamp.write_text(str(int(ago.timestamp()))) + expected = ago.date() == (ago + timedelta(minutes=1)).date() and ago.hour >= 12 + assert (evening_lockdown_today(stamp) is not None) is expected diff --git a/gatelock/pyproject.toml b/gatelock/pyproject.toml index 6946c0e..5b9187e 100644 --- a/gatelock/pyproject.toml +++ b/gatelock/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "gatelock" -version = "0.9.0" +version = "0.10.0" description = "Shared per-output lock-window + HMAC log-integrity backend for blocking-overlay apps" requires-python = ">=3.14" dependencies = [] # pure stdlib — tkinter is bundled with Python