From 8e798f4f2bbf0028825ee401aaede1c94acf5b0e Mon Sep 17 00:00:00 2001 From: argszero Date: Wed, 21 Oct 2026 13:43:50 +0800 Subject: [PATCH] emrg: the config write guard names all three host files and the seam that writes them --- tests/conftest.py | 100 ++++++++++++++++++++++++--- tests/test_config_write_guard.py | 114 +++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 tests/test_config_write_guard.py diff --git a/tests/conftest.py b/tests/conftest.py index acf43aec..764fac18 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,28 +16,82 @@ (unpatched) config_dir → on a fresh runner this hits the real ~/.emrg/ files. -This autouse fixture makes any write to the REAL ~/.emrg/projects.yml -or ~/.emrg/tasks.yml a hard test failure: the offending test is named -immediately instead of the pollution being discovered later (precedent: -#583 assertPortFileInTmp sandbox guard for the emrgd.token file). +This autouse fixture makes any write to the REAL ~/.emrg/config.toml, +~/.emrg/projects.yml or ~/.emrg/tasks.yml a hard test failure: the offending +test is named immediately instead of the pollution being discovered later +(precedent: #583 assertPortFileInTmp sandbox guard for the emrgd.token file). """ from __future__ import annotations import logging +import os import re from pathlib import Path, PurePosixPath, PureWindowsPath import pytest _REAL_CONFIG_FILES = ( + (Path.home() / ".emrg" / "config.toml").resolve(), (Path.home() / ".emrg" / "projects.yml").resolve(), (Path.home() / ".emrg" / "tasks.yml").resolve(), ) +def _is_real_config_target(path) -> bool: + """Is ``path`` one of the host's real config files? + + The ``.emrg`` substring is a cheap pre-filter, not the rule. ``open`` is + called for every file the suite touches, so resolving each one would put a + ``stat`` on that path thousands of times per run; a path that cannot even + spell the directory is answered without one. + + A pure predicate: it resolves the argument and opens nothing, which is what + lets a test name the host's own path without the test depending on a guard + that is working. + """ + try: + text = os.fspath(path) + except TypeError: + return False + if isinstance(text, bytes): + return False + if ".emrg" not in text: + return False + return Path(text).resolve() in _REAL_CONFIG_FILES + + +def _refuse_real_config(path, seam: str) -> None: + raise AssertionError( + f"test attempted to write a real ~/.emrg config file via {seam}; " + f"keep tests hermetic (target={path!r})" + ) + + @pytest.fixture(autouse=True) def _guard_real_config_files(monkeypatch): - """Fail any test that writes the real ~/.emrg/projects.yml / tasks.yml.""" + """Fail any test that writes a real ~/.emrg config file. + + Three files and two seams, both widened together because widening one + without the other measures nothing. + + **The third file is ``config.toml``.** It was the one file of the three this + fixture did not name, and it is the one whose corruption is on record: a + stray line appended to it made ``tomllib`` raise at line 48, and the daemon + exited during startup with the traceback landing in ``emrgd-crash.log`` + rather than in any channel a startup-failure report reads. + + **The second seam is the plain one.** ``atomic_write_yaml`` is how + ``projects.yml`` and ``tasks.yml`` are saved, and patching it was enough + while those were the only two files here. ``config.toml`` is not YAML: it is + written by ``ensure_config`` through ``cfg_path.write_text``, which that + patch never sees. So a guard naming only the YAML seam would leave + ``config.toml`` protected by nothing but ``_redirect_the_config_path`` — and + that redirect is precisely what a mutation arm exists to break, so it cannot + be the only thing between a test and the host's file. + """ + import builtins + import io + import emrg.server.daemon as daemon_mod import emrg.server.scheduler as sched_mod @@ -45,16 +99,42 @@ def _guard_real_config_files(monkeypatch): assert orig is daemon_mod.atomic_write_yaml, "both modules must share atomic_write_yaml" def guarded(data, path, **kwargs): - if Path(path).resolve() in _REAL_CONFIG_FILES: - raise AssertionError( - "test attempted to write a real ~/.emrg config file; " - f"keep tests hermetic (target={path!r})" - ) + if _is_real_config_target(path): + _refuse_real_config(path, "atomic_write_yaml") return orig(data, path, **kwargs) monkeypatch.setattr(sched_mod, "atomic_write_yaml", guarded) monkeypatch.setattr(daemon_mod, "atomic_write_yaml", guarded) + orig_open = builtins.open + orig_io_open = io.open + + def guarded_open(file, mode="r", *args, **kwargs): + # An append is the shape the incident on record took, and it is the one + # shape a `Path` method cannot be asked about: `Path.write_text` only + # ever truncates, so a guard over the modes is the only thing that sees + # a line appended to a file that already exists. + if any(flag in mode for flag in "wax+"): + if _is_real_config_target(file): + _refuse_real_config(file, f"open(mode={mode!r})") + return orig_open(file, mode, *args, **kwargs) + + def guarded_io_open(file, mode="r", *args, **kwargs): + if any(flag in mode for flag in "wax+"): + if _is_real_config_target(file): + _refuse_real_config(file, f"io.open(mode={mode!r})") + return orig_io_open(file, mode, *args, **kwargs) + + # Two seams and no more. `Path.write_text` / `Path.write_bytes` are not + # patched separately because they reach `io.open` through `Path.open`, so a + # patch of their own would be a second wrapper over one write — and a guard + # that names a seam no write reaches is a claim, not a guard. What is left + # is one seam per call shape: the module-level `open`, and `io.open`, which + # is what `Path.open` calls. They are separate attributes, so each has to be + # patched; measured, dropping either one leaves the other's callers open. + monkeypatch.setattr(builtins, "open", guarded_open) + monkeypatch.setattr(io, "open", guarded_io_open) + @pytest.fixture(autouse=True) def _redirect_abort_run_state(monkeypatch, tmp_path): diff --git a/tests/test_config_write_guard.py b/tests/test_config_write_guard.py new file mode 100644 index 00000000..b7a27e8d --- /dev/null +++ b/tests/test_config_write_guard.py @@ -0,0 +1,114 @@ +"""The config-file write guard — measured firing, not merely declared. + +`tests/conftest.py::_guard_real_config_files` is the mechanism behind a host red +line: `~/.emrg/config.toml` is the file whose corruption is on record, where a +stray appended line made `tomllib` raise at line 48 and the daemon exited during +startup with the traceback landing in `emrgd-crash.log` rather than in any channel +a startup-failure report reads. A guard that nothing demonstrates failing is a +claim rather than a guard, so every seam it patches is exercised here. + +**No test in this file can reach the host's files.** The three real paths are only +ever arguments to `_is_real_config_target`, a pure predicate that resolves and +opens nothing — the shape that lets a test name the host's own path without the +test's safety depending on the guard working. Every refusal below is driven by +pointing `_REAL_CONFIG_FILES` at a `tmp_path`, so the write that must fail is a +write into a directory this test created. + +**What each test pins is the refusal, not the wording of the seam that produced +it** — except where the seam is the point. The guard may name `open(mode='a')` +or `io.open(mode='w')`; either way the write must not land. Pinning the label +instead would let a seam that carries no write look load-bearing, and would fail +a change that merely moved one write from one wrapper to another. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests import conftest + +# The invariant every refusal shares, whatever wrapper produced it. +REFUSAL = "keep tests hermetic" + + +@pytest.fixture +def guarded_file(tmp_path, monkeypatch): + """Point the guard at one file inside this test's own directory. + + The directory is named `.emrg` so the cheap pre-filter in + `_is_real_config_target` does not answer before the membership test does — + a guard proven only on paths the pre-filter skips would be proven on + nothing. + """ + victim = tmp_path / ".emrg" / "config.toml" + victim.parent.mkdir(parents=True) + monkeypatch.setattr(conftest, "_REAL_CONFIG_FILES", (victim.resolve(),)) + return victim + + +def test_the_three_real_files_are_named(): + """All three, not two: `config.toml` is the one the incident was about.""" + home = Path.home() + assert conftest._is_real_config_target(home / ".emrg" / "config.toml") + assert conftest._is_real_config_target(home / ".emrg" / "projects.yml") + assert conftest._is_real_config_target(home / ".emrg" / "tasks.yml") + + +def test_a_scratch_path_is_never_a_real_file(tmp_path): + """The counter-direction: a tmp file of the same name must pass through.""" + assert not conftest._is_real_config_target(tmp_path / "config.toml") + assert not conftest._is_real_config_target(tmp_path / ".emrg" / "config.toml") + assert not conftest._is_real_config_target(None) + assert not conftest._is_real_config_target(3) + + +def test_yaml_writer_is_refused(guarded_file): + """`projects.yml` / `tasks.yml`'s own seam, which the guard already had.""" + from emrg.server import scheduler as sched_mod + + with pytest.raises(AssertionError, match=REFUSAL): + sched_mod.atomic_write_yaml({"a": 1}, guarded_file) + assert not guarded_file.exists() + + +def test_path_write_text_is_refused(guarded_file): + """A `Path` write reaches the guard through `Path.open` → `io.open`.""" + with pytest.raises(AssertionError, match=REFUSAL): + guarded_file.write_text("probe\n", encoding="utf-8") + assert not guarded_file.exists() + + +def test_path_write_bytes_is_refused(guarded_file): + with pytest.raises(AssertionError, match=REFUSAL): + guarded_file.write_bytes(b"probe\n") + assert not guarded_file.exists() + + +def test_open_in_append_mode_is_refused(guarded_file): + """The recorded incident's shape: an append, which neither method above is.""" + with pytest.raises(AssertionError, match=REFUSAL): + open(guarded_file, "a", encoding="utf-8") + assert not guarded_file.exists() + + +def test_open_for_reading_is_allowed(guarded_file): + """A read is not a write: the guard must not make the file unreadable.""" + guarded_file.touch() + with open(guarded_file, "r", encoding="utf-8") as fh: + assert fh.read() == "" + + +def test_ensure_config_cannot_create_the_real_file(guarded_file, monkeypatch): + """The seam that actually writes `config.toml`, driven end to end. + + `ensure_config` is the product's own creator of this file, and it writes + through `cfg_path.write_text` — not through the YAML seam. This is the call + the guard exists to stop and could not see before. + """ + import emrg.config as cfg_mod + + monkeypatch.setattr(cfg_mod, "config_path", lambda: guarded_file) + with pytest.raises(AssertionError, match=REFUSAL): + cfg_mod.ensure_config() + assert not guarded_file.exists()