steam-backlog-enforcer/steam_backlog_enforcer/_playtime_block.py
Krzysztof kuhy Rudnicki b5b4b35c07 Split total-block, playtime and MCP by responsibility
Each of these was one file doing several jobs, so they are split along
what they actually do rather than at arbitrary line offsets:

- _total_block: the lock file and the status derived from it stay in
  _total_block; making Steam *not installed* moves to _total_block_purge,
  making it *unreachable* to _total_block_hosts / _total_block_iptables.
  The generic pacman verbs (owner, installed, remove) become _pacman,
  since nothing about them is total-block-specific. Domain and process
  name tables become data modules.
- _playtime: budget state and persistence (_playtime_state), pure
  transitions (_playtime_budget), cmdline process detection
  (_playtime_procs), the warn/cutoff/kill ladder (_playtime_cutoff),
  desktop notifications (_playtime_notify), process-tree walking and
  killing (_playtime_kill), mountinfo parsing (_playtime_mountinfo) and
  the shared privileged-command helper (_playtime_run).
- _mcp: read-only tools (_mcp_query) split from mutating ones
  (_mcp_actions, _mcp_gaming_time), so "what can this server change?" is
  answerable from one file. _mcp keeps the server object and entry point.

No re-export shims: callers import from the module that defines the name.
That keeps a stale patch target loud (AttributeError) instead of letting
it pass while intercepting nothing.

`ruff check --select F821,F401` is run after every extraction -- an earlier
mechanical attempt moved functions without the module-level names they
referenced, which imported fine and only failed when called (349 errors),
so it was reverted and redone by hand.

The autouse fixtures in conftest are now imported by name and held by
__all__; ruff --unsafe-fixes had deleted them as unused, since pytest
resolves fixtures by name.

1324 tests pass at 100.00% coverage in the clean venv; `main status` is
byte-identical to a worktree at HEAD. 49 -> 39 files over the cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPpmMpTSkp8UziRc5u8B7P
2026-08-23 20:52:08 +02:00

245 lines
8.4 KiB
Python

"""Make Steam and game launchers unlaunchable once the daily budget is spent.
The block is a **read-only bind mount** of a refusal stub over every launcher
entry point. Nothing else here is negotiable, and future changes must not
substitute an alternative:
* ``chmod 000`` and rename-aside both mutate persistent on-disk state that
outlives this process. A crash mid-block leaves Steam unexecutable with no
automatic recovery.
* ``guardctl file-guard`` is an anti-*drift* keeper, not a swapper: it snapshots
the target as canonical and copies canonical back over the target. Pointing it
at a stub would permanently overwrite ``/usr/bin/steam``.
A bind mount mutates zero bytes of the real binary, ``umount`` is a perfect
restore, and a reboot clears every mount unconditionally. That last property is
the whole safety argument: the worst case is "reboot fixes it".
Four targets, because the first three are bypasses of one another —
``/usr/bin/steam`` is a two-line ``exec /usr/lib/steam/steam "$@"`` wrapper, and
Steam ships its own executable bootstrap inside the user's home directory.
``/proc/self/mountinfo`` is the authority on what is currently masked, never the
saved state. That is what makes the block self-healing: a stale mount is
released even if the state file was deleted, and a mount lost to a reboot is
re-applied on the next tick.
**The daemon must keep a shared mount namespace.** Adding ``PrivateMounts=``,
``PrivateTmp=`` or ``ProtectSystem=`` to the systemd unit would make these
mounts invisible outside the service, silently turning the block into a no-op
while the counter kept running. :func:`mounts_are_visible` checks for exactly
that at runtime.
"""
from __future__ import annotations
import logging
from pathlib import Path
import shutil
from steam_backlog_enforcer._playtime_mountinfo import _mountpoints
from steam_backlog_enforcer._playtime_run import _run
logger = logging.getLogger(__name__)
BLOCK_TARGETS: tuple[Path, ...] = (
# Every steam.desktop action uses this path; it is only a wrapper.
Path("/usr/bin/steam"),
# The real bootstrap. Named directly rather than via the
# /usr/lib/steam/steam symlink because mount(2) resolves symlinks, so
# mounting the link would mount here anyway under a misleading name.
Path("/usr/lib/steam/bin_steam.sh"),
# Steam's self-updating bootstrap, owned by the user and independently
# executable.
Path.home() / ".local" / "share" / "Steam" / "steam.sh",
Path("/usr/bin/lutris"),
)
# The stub lives on root-owned tmpfs, NOT in CONFIG_DIR. A bind mount shares the
# inode: a stub in a user-writable directory could be rewritten to exec the real
# bootstrap, and the block would evaporate without a single mount changing.
_STUB_DIR = Path("/run/steam-backlog-enforcer")
STUB_PATH = _STUB_DIR / "gaming-blocked"
MOUNTINFO_PATH = Path("/proc/self/mountinfo")
_INIT_MOUNTINFO_PATH = Path("/proc/1/mountinfo")
# pacman holds this for the whole transaction. Re-mounting mid-transaction is
# what would actually cause the EBUSY the PreTransaction hook exists to avoid.
PACMAN_LOCK = Path("/var/lib/pacman/db.lck")
_PROC = Path("/proc")
# Bind mounts stack, so one umount need not be enough. guard-lib's
# collapse_bind_mount() uses the same bounded loop for the same reason.
_MAX_UMOUNT_PASSES = 20
_MOUNTINFO_MIN_FIELDS = 5
_MOUNTPOINT_FIELD = 4
_STUB_MODE = 0o755
_STUB_SCRIPT = """#!/bin/sh
# Installed by steam-backlog-enforcer. Bind-mounted over the real launcher
# while the daily gaming budget is exhausted; removed automatically at 06:00.
echo "Gaming blocked: daily budget used up. Unblocks at 06:00." >&2
exit 1
"""
def block_targets() -> tuple[Path, ...]:
"""Return the launcher paths the block masks.
An accessor rather than a direct import of ``BLOCK_TARGETS``: module-level
constants are bound by value at import time, so a second binding elsewhere
would need its own test patch to stay isolated. Routing every reader through
one function keeps that to a single patch point.
Returns:
The masked launcher paths.
"""
return BLOCK_TARGETS
def mounted_targets() -> set[Path]:
"""Return the block targets currently masked by a mount.
Read in-process from mountinfo rather than shelling out to ``findmnt``:
this runs on every enforce tick, and a fork per tick is exactly the polling
cost this codebase avoids elsewhere.
Anything mounted on a block target is treated as ours — nothing else on this
system mounts over those paths.
Returns:
The masked targets.
"""
return _mountpoints(MOUNTINFO_PATH) & set(block_targets())
def mounts_are_visible() -> bool:
"""Whether mounts made here are visible to the rest of the system.
A private mount namespace (``PrivateMounts=``, ``ProtectSystem=`` and
friends) would keep every mount inside the service, so the block would do
nothing while the counter kept running — a silent fail-open. Compared
against PID 1's namespace, which is the one that matters.
Returns:
True if no mount we made is missing from PID 1's view.
"""
ours = mounted_targets()
if not ours:
return True
return bool(ours & _mountpoints(_INIT_MOUNTINFO_PATH))
def _ensure_stub() -> bool:
"""Write the refusal stub, replacing any existing copy.
Returns:
True if the stub is present and executable.
"""
try:
_STUB_DIR.mkdir(parents=True, exist_ok=True)
STUB_PATH.write_text(_STUB_SCRIPT, encoding="utf-8")
STUB_PATH.chmod(_STUB_MODE)
except OSError:
logger.exception("Could not write the playtime refusal stub.")
return False
return True
def apply_block() -> list[Path]:
"""Mask every unmasked block target with the refusal stub.
Skipped entirely while pacman holds its database lock: re-mounting during a
transaction is what makes package extraction fail ``EBUSY``. The kill loop is
not gated this way, so enforcement continues regardless.
Returns:
The targets newly masked by this call.
"""
if PACMAN_LOCK.exists():
logger.info("pacman transaction in progress; deferring playtime mounts.")
return []
if not _ensure_stub():
return []
already = mounted_targets()
newly: list[Path] = []
for target in block_targets():
if target in already or not target.exists():
continue
if not _run([_mount_bin(), "--bind", str(STUB_PATH), str(target)]):
continue
if _run([_mount_bin(), "-o", "remount,ro,bind", str(target)]):
newly.append(target)
return newly
def release_block() -> list[Path]:
"""Unmask every masked block target.
Loops per target because bind mounts stack; bounded so a target that cannot
be unmounted cannot spin forever.
Returns:
The targets released by this call.
"""
released: list[Path] = []
for target in mounted_targets():
for _ in range(_MAX_UMOUNT_PASSES):
if target not in mounted_targets():
released.append(target)
break
if not _run([_umount_bin(), "-l", str(target)]):
logger.warning("Could not release playtime mount on %s.", target)
break
return released
def reconcile(*, should_block: bool) -> tuple[list[Path], list[Path]]:
"""Drive the on-disk mount state toward *should_block*.
Idempotent and driven by mountinfo, so it costs one file read per tick when
nothing needs changing. Because it reads the live mount table rather than
saved state, it releases a stale block even if the state file was deleted,
and re-applies a block that a reboot cleared.
Args:
should_block: Whether the launchers should currently be masked.
Returns:
A ``(newly_masked, newly_released)`` pair.
"""
if not should_block:
return [], release_block()
masked = apply_block()
if masked and not mounts_are_visible():
logger.error(
"Playtime mounts are not visible outside this service — the block "
"is not in force. Check the unit for PrivateMounts/ProtectSystem.",
)
return masked, []
def _mount_bin() -> str:
"""Return the ``mount`` executable path.
Returns:
Absolute path to ``mount``.
"""
return shutil.which("mount") or "/usr/bin/mount"
def _umount_bin() -> str:
"""Return the ``umount`` executable path.
Returns:
Absolute path to ``umount``.
"""
return shutil.which("umount") or "/usr/bin/umount"