mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 18:07:07 +01:00
Accumulates time spent in Steam games and other launchers, and once the daily budget (default 8h) is spent, shuts Steam down, kills the games, and makes them unlaunchable until the next 06:00. The gaming day starts at 06:00 local rather than midnight so a late session counts against the day it began in; rollover is checked lazily against a stored day key, so it survives suspend, reboot and daemon restarts with no timer of its own. Elapsed time comes from wall-clock deltas clamped to two tick intervals, which keeps suspends, missed ticks and NTP steps honest. The block is a read-only bind mount of a refusal stub over all four launcher entry points -- /usr/bin/steam is only a two-line exec wrapper, so masking it alone leaves bin_steam.sh, Steam's own steam.sh and lutris live. A bind mount mutates no bytes, umount restores exactly, and a reboot clears it unconditionally; chmod/rename would leave Steam broken if the daemon died mid-block. /proc/self/mountinfo is the authority on what is masked, never the saved state, which is what makes a stale block release even if the state file was deleted. The mount is deferred one tick past the cutoff so `steam -shutdown` still works and games close cleanly. Launchers are matched by cmdline, not comm: /usr/bin/lutris is a python3 script, so the kernel records its comm as "python3" and the existing scan never sees it. The kill set is wider than the budget predicate, walking down from each launcher, because a Lutris-launched Wine game carries no SteamAppId. State is chattr +i'd -- ~/.config is user-writable, so root ownership alone would not stop `rm` from resetting the counter -- and a missing state file while mounts are live fails closed rather than lifting the block. Also fixes a pre-existing bug: do_enforce returned when no game was assigned, exiting the process. Since the budget is accounted for from inside that loop, it silently stopped enforcing whenever a game was finished but not rescanned. Verified on the desktop with a real game: AoE2 counted 18 processes, tripped at the 60s demo budget, all processes killed, all four paths masked and refusing, then gaming-unblock restored them byte-identical with `pacman -Qkk steam lutris` reporting 0 altered files. 1322 tests pass at 100.00% branch coverage; pacman validated the ALPM hook. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbEaXapjMSkxyzgvbdkUFb
183 lines
6.3 KiB
Python
183 lines
6.3 KiB
Python
"""Tests for the playtime block's mount discovery and stub handling."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from steam_backlog_enforcer import _playtime_block as blk
|
|
from steam_backlog_enforcer._playtime_block import (
|
|
_ensure_stub,
|
|
_mount_bin,
|
|
_mountpoints,
|
|
_run,
|
|
_umount_bin,
|
|
_unescape_mountinfo,
|
|
block_targets,
|
|
mounted_targets,
|
|
mounts_are_visible,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
PKG = "steam_backlog_enforcer._playtime_block"
|
|
|
|
|
|
def _mountinfo_line(mount_point: str) -> str:
|
|
return f"36 25 0:32 / {mount_point} rw,relatime shared:5 - tmpfs tmpfs rw"
|
|
|
|
|
|
def _write_mountinfo(tmp_path: Path, points: list[str]) -> None:
|
|
(tmp_path / "mountinfo").write_text(
|
|
"\n".join(_mountinfo_line(p) for p in points) + "\n" if points else "",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
class TestUnescapeMountinfo:
|
|
def test_space(self) -> None:
|
|
assert _unescape_mountinfo(r"/mnt/my\040dir") == "/mnt/my dir"
|
|
|
|
def test_tab_newline_backslash(self) -> None:
|
|
assert _unescape_mountinfo(r"a\011b") == "a\tb"
|
|
assert _unescape_mountinfo(r"a\012b") == "a\nb"
|
|
assert _unescape_mountinfo(r"a\134b") == "a\\b"
|
|
|
|
def test_plain_string_untouched(self) -> None:
|
|
assert _unescape_mountinfo("/usr/bin/steam") == "/usr/bin/steam"
|
|
|
|
|
|
class TestMountpoints:
|
|
def test_unreadable_file_returns_empty(self, tmp_path: Path) -> None:
|
|
assert _mountpoints(tmp_path / "does-not-exist") == set()
|
|
|
|
def test_empty_file(self, tmp_path: Path) -> None:
|
|
path = tmp_path / "mi"
|
|
path.write_text("", encoding="utf-8")
|
|
assert _mountpoints(path) == set()
|
|
|
|
def test_skips_short_lines(self, tmp_path: Path) -> None:
|
|
path = tmp_path / "mi"
|
|
path.write_text("1 2 3\n" + _mountinfo_line("/a") + "\n", encoding="utf-8")
|
|
assert {str(p) for p in _mountpoints(path)} == {"/a"}
|
|
|
|
def test_decodes_escapes(self, tmp_path: Path) -> None:
|
|
path = tmp_path / "mi"
|
|
path.write_text(_mountinfo_line(r"/mnt/a\040b") + "\n", encoding="utf-8")
|
|
assert {str(p) for p in _mountpoints(path)} == {"/mnt/a b"}
|
|
|
|
|
|
class TestMountedTargets:
|
|
def test_none_mounted(self, tmp_path: Path) -> None:
|
|
_write_mountinfo(tmp_path, [])
|
|
assert mounted_targets() == set()
|
|
|
|
def test_ignores_unrelated_mounts(self, tmp_path: Path) -> None:
|
|
_write_mountinfo(tmp_path, ["/", "/home", "/etc/hosts"])
|
|
assert mounted_targets() == set()
|
|
|
|
def test_detects_a_target(self, tmp_path: Path) -> None:
|
|
target = block_targets()[0]
|
|
_write_mountinfo(tmp_path, ["/", str(target)])
|
|
assert mounted_targets() == {target}
|
|
|
|
def test_detects_all_targets(self, tmp_path: Path) -> None:
|
|
_write_mountinfo(tmp_path, [str(t) for t in block_targets()])
|
|
assert mounted_targets() == set(block_targets())
|
|
|
|
|
|
class TestMountsAreVisible:
|
|
def test_true_when_nothing_mounted(self, tmp_path: Path) -> None:
|
|
_write_mountinfo(tmp_path, [])
|
|
assert mounts_are_visible() is True
|
|
|
|
def test_true_when_init_sees_the_mount(self, tmp_path: Path) -> None:
|
|
target = block_targets()[0]
|
|
_write_mountinfo(tmp_path, [str(target)])
|
|
(tmp_path / "init_mountinfo").write_text(
|
|
_mountinfo_line(str(target)) + "\n", encoding="utf-8"
|
|
)
|
|
assert mounts_are_visible() is True
|
|
|
|
def test_false_when_init_cannot_see_it(self, tmp_path: Path) -> None:
|
|
"""A private mount namespace would look exactly like this."""
|
|
target = block_targets()[0]
|
|
_write_mountinfo(tmp_path, [str(target)])
|
|
(tmp_path / "init_mountinfo").write_text(
|
|
_mountinfo_line("/unrelated") + "\n", encoding="utf-8"
|
|
)
|
|
assert mounts_are_visible() is False
|
|
|
|
|
|
class TestEnsureStub:
|
|
def test_writes_executable_stub(self, tmp_path: Path) -> None:
|
|
assert _ensure_stub() is True
|
|
stub = tmp_path / "run" / "gaming-blocked"
|
|
assert stub.exists()
|
|
assert stub.stat().st_mode & 0o111
|
|
assert "Gaming blocked" in stub.read_text(encoding="utf-8")
|
|
|
|
def test_idempotent(self, tmp_path: Path) -> None:
|
|
assert _ensure_stub() is True
|
|
assert _ensure_stub() is True
|
|
|
|
def test_returns_false_on_oserror(self) -> None:
|
|
with patch(f"{PKG}.Path.mkdir", side_effect=OSError("boom")):
|
|
assert _ensure_stub() is False
|
|
|
|
|
|
class TestRun:
|
|
def test_true_on_zero_exit(self) -> None:
|
|
with patch(
|
|
f"{PKG}.subprocess.run",
|
|
return_value=MagicMock(returncode=0),
|
|
) as mock_run:
|
|
assert _run(["mount", "--bind", "a", "b"]) is True
|
|
mock_run.assert_called_once()
|
|
|
|
def test_false_on_nonzero_exit(self) -> None:
|
|
with patch(f"{PKG}.subprocess.run", return_value=MagicMock(returncode=1)):
|
|
assert _run(["mount"]) is False
|
|
|
|
def test_false_on_oserror(self) -> None:
|
|
with patch(f"{PKG}.subprocess.run", side_effect=OSError("nope")):
|
|
assert _run(["mount"]) is False
|
|
|
|
def test_false_on_timeout(self) -> None:
|
|
with patch(
|
|
f"{PKG}.subprocess.run",
|
|
side_effect=subprocess.TimeoutExpired("mount", 15),
|
|
):
|
|
assert _run(["mount"]) is False
|
|
|
|
|
|
class TestBinaryResolution:
|
|
def test_mount_bin_found(self) -> None:
|
|
with patch(f"{PKG}.shutil.which", return_value="/bin/mount"):
|
|
assert _mount_bin() == "/bin/mount"
|
|
|
|
def test_mount_bin_fallback(self) -> None:
|
|
with patch(f"{PKG}.shutil.which", return_value=None):
|
|
assert _mount_bin() == "/usr/bin/mount"
|
|
|
|
def test_umount_bin_found(self) -> None:
|
|
with patch(f"{PKG}.shutil.which", return_value="/bin/umount"):
|
|
assert _umount_bin() == "/bin/umount"
|
|
|
|
def test_umount_bin_fallback(self) -> None:
|
|
with patch(f"{PKG}.shutil.which", return_value=None):
|
|
assert _umount_bin() == "/usr/bin/umount"
|
|
|
|
|
|
class TestBlockTargets:
|
|
def test_accessor_returns_the_constant(self) -> None:
|
|
assert block_targets() == blk.BLOCK_TARGETS
|
|
|
|
def test_four_targets_are_isolated_in_tests(self) -> None:
|
|
"""Guards the conftest patch: these must never be the real binaries."""
|
|
assert len(block_targets()) == 4
|
|
for target in block_targets():
|
|
assert not str(target).startswith("/usr/")
|